From a2a2ab3cf5fe8d945e6cdcd49622e4a72fd229eb Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:18:26 +0200 Subject: [PATCH 01/45] FE-376: Add a DOCX preview to the entity editor The File Preview section renders .docx files with @stll/folio-react in read-only mode, with page buttons, zoom, full screen and search, loaded only when a DOCX file is opened. The PDF and DOCX previews share the presigned-URL fetch. folio needs newer ProseMirror packages than HASH's editor, so a webpack rule resolves folio's tree to one copy of each. `.yarnrc.yml` adds folio-core's missing `y-protocols` dependency. The file-preview Playwright spec gains DOCX cases: paging, zoom and search; read-only; copy; a hostile document; `.doc` and corrupt files. --- .yarnrc.yml | 4 + apps/hash-frontend/next.config.js | 62 ++ apps/hash-frontend/package.json | 2 + .../src/pages/shared/docx-preview.tsx | 233 +++++++ .../shared/docx-preview/folio-document.tsx | 108 ++++ .../stll-folio-react-messages.d.ts | 10 + .../entity-editor/file-preview-section.tsx | 53 +- .../pages/shared/fetch-presigned-file-url.ts | 36 ++ .../src/pages/shared/pdf-preview.tsx | 17 +- .../tests/features/file-preview.spec.ts | 270 ++++++-- .../tests/shared/generate-test-docx.ts | 136 ++++ yarn.lock | 603 +++++++++++++++++- 12 files changed, 1458 insertions(+), 76 deletions(-) create mode 100644 apps/hash-frontend/src/pages/shared/docx-preview.tsx create mode 100644 apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx create mode 100644 apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-react-messages.d.ts create mode 100644 apps/hash-frontend/src/pages/shared/fetch-presigned-file-url.ts create mode 100644 tests/hash-playwright/tests/shared/generate-test-docx.ts diff --git a/.yarnrc.yml b/.yarnrc.yml index 61c0044911e..568acc40ec7 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -36,3 +36,7 @@ packageExtensions: "@flue/runtime@2.3.0": dependencies: "@standard-schema/spec": "1.1.0" + # `y-prosemirror` imports its peer `y-protocols`, which folio-core does not provide. + "@stll/folio-core@*": + dependencies: + "y-protocols": "1.0.7" diff --git a/apps/hash-frontend/next.config.js b/apps/hash-frontend/next.config.js index dcc60122ee0..52d77e619af 100644 --- a/apps/hash-frontend/next.config.js +++ b/apps/hash-frontend/next.config.js @@ -1,3 +1,7 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + import bundleAnalyzer from "@next/bundle-analyzer"; import { withSentryConfig } from "@sentry/nextjs"; import { config } from "dotenv-flow"; @@ -7,6 +11,62 @@ import webpack from "webpack"; import { buildStamp } from "./buildstamp.js"; const { DefinePlugin } = webpack; + +/** + * @param {string} name + * @param {string} fromDirectory + * @returns {string} + */ +const findInstalledPackage = (name, fromDirectory) => { + for (let directory = fromDirectory; ; directory = path.dirname(directory)) { + const candidate = path.join(directory, "node_modules", name); + if (fs.existsSync(candidate)) { + return candidate; + } + if (directory === path.dirname(directory)) { + throw new Error(`${name} is not installed above ${fromDirectory}`); + } + } +}; + +/** + * The DOCX preview's engine, folio, needs newer ProseMirror packages than + * HASH's own editor, so Yarn installs several copies of them inside folio's + * tree. ProseMirror fails when two copies meet ("Adding different instances + * of a keyed plugin"), so every module in folio's tree resolves them to the + * copies folio-core uses. HASH's editor keeps its own. + */ +const folioSingleProseMirrorRule = () => { + const folioReact = findInstalledPackage( + "@stll/folio-react", + path.dirname(fileURLToPath(import.meta.url)), + ); + const folioCore = findInstalledPackage("@stll/folio-core", folioReact); + + const sharedPackages = new Set(); + for (const directory of [folioReact, folioCore]) { + const { dependencies = {} } = JSON.parse( + fs.readFileSync(path.join(directory, "package.json"), "utf8"), + ); + for (const name of Object.keys(dependencies)) { + if (/^(prosemirror-|y-prosemirror$|yjs$)/.test(name)) { + sharedPackages.add(name); + } + } + } + + const alias = Object.fromEntries( + [...sharedPackages].map((name) => [ + name, + findInstalledPackage(name, folioCore), + ]), + ); + + return { + include: [folioReact, folioCore, ...Object.values(alias)], + resolve: { alias }, + }; +}; const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === "true", }); @@ -268,6 +328,8 @@ export default withSentryConfig( type: "asset/resource", }); + webpackConfig.module.rules.push(folioSingleProseMirrorRule()); + // eslint-disable-next-line no-param-reassign webpackConfig.resolve.extensionAlias = { ".js": [".ts", ".tsx", ".jsx", ".js"], diff --git a/apps/hash-frontend/package.json b/apps/hash-frontend/package.json index f6c6fbcd389..be1a559ebac 100644 --- a/apps/hash-frontend/package.json +++ b/apps/hash-frontend/package.json @@ -69,6 +69,7 @@ "@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-react": "0.25.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", @@ -144,6 +145,7 @@ "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", "zod": "4.4.3" diff --git a/apps/hash-frontend/src/pages/shared/docx-preview.tsx b/apps/hash-frontend/src/pages/shared/docx-preview.tsx new file mode 100644 index 00000000000..ac744708d07 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/docx-preview.tsx @@ -0,0 +1,233 @@ +import { Box, CircularProgress, Stack, Typography } from "@mui/material"; +import * as Sentry from "@sentry/nextjs"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { FullScreen, useFullScreenHandle } from "react-full-screen"; + +import { + ArrowDownLeftAndArrowUpRightToCenterIcon, + ArrowRightRegularIcon, + ArrowUpRightAndArrowDownLeftFromCenterIcon, + MagnifyingGlassMinusLightIcon, + MagnifyingGlassPlusLightIcon, +} from "@hashintel/design-system"; + +import { FolioDocument } from "./docx-preview/folio-document"; +import { fetchPresignedFileUrl } from "./fetch-presigned-file-url"; +import { GrayToBlueIconButton } from "./gray-to-blue-icon-button"; + +import type { + DocxDocumentControls, + DocxDocumentPages, +} from "./docx-preview/folio-document"; + +const zoomStep = 0.2; + +type DocxPreviewProps = { + showSearch: boolean; + setShowSearch: (showSearch: boolean) => void; + url: string; +}; + +export const DocxPreview = ({ + showSearch, + setShowSearch, + url, +}: DocxPreviewProps) => { + const [data, setData] = useState(null); + const [failed, setFailed] = useState(false); + const [pages, setPages] = useState({ + current: 1, + total: 0, + }); + const [zoom, setZoom] = useState(1); + + const controlsRef = useRef(null); + + const fullScreenHandle = useFullScreenHandle(); + + const [viewportHeight, setViewportHeight] = useState( + document.documentElement.clientHeight, + ); + + useEffect(() => { + const handleResize = () => { + setViewportHeight(document.documentElement.clientHeight); + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + const handleError = useCallback((error: unknown) => { + setFailed(true); + Sentry.captureException(error); + }, []); + + useEffect(() => { + let cancelled = false; + + setData(null); + setFailed(false); + + fetchPresignedFileUrl(url) + .then((presignedUrl) => fetch(presignedUrl, { credentials: "omit" })) + .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) { + handleError(error); + } + }); + + return () => { + cancelled = true; + }; + }, [url, handleError]); + + useEffect(() => { + if (showSearch) { + controlsRef.current?.openSearch(); + setShowSearch(false); + } + }, [showSearch, setShowSearch]); + + const height = fullScreenHandle.active + ? viewportHeight + : viewportHeight - 120; + + return ( + + ({ + background: palette.blue[10], + border: `1px solid ${palette.gray[30]}`, + borderRadius: 2, + height, + overflow: "hidden", + position: "relative", + width: "100%", + })} + > + {data ? ( + setFailed(true)}> + + + ) : failed ? null : ( + + + + )} + {pages.total > 0 && !failed && ( + <> + + controlsRef.current?.goToPage(pages.current - 1)} + > + + + palette.gray[70], + textAlign: "center", + width: (pages.total.toString().length * 2 + 4) * 7, + }} + > + {`${pages.current} of ${pages.total}`} + + = pages.total} + onClick={() => controlsRef.current?.goToPage(pages.current + 1)} + > + + + + + + controlsRef.current?.zoomBy(-zoomStep)} + > + + + controlsRef.current?.zoomBy(zoomStep)} + > + + + palette.gray[70], + }} + > + {Math.round(zoom * 100)}% + + + + {fullScreenHandle.active ? ( + + ) : ( + + )} + + + + )} + + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx b/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx new file mode 100644 index 00000000000..98e00070964 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx @@ -0,0 +1,108 @@ +import "@stll/folio-react/standalone.css"; +import { CircularProgress } from "@mui/material"; +import { DocxEditor } from "@stll/folio-react"; +import { getFolioMessages } from "@stll/folio-react/messages"; +import { useCallback, useEffect, useImperativeHandle, useRef } from "react"; +import { IntlProvider } from "use-intl"; + +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 = { + goToPage: (pageNumber: number) => void; + zoomBy: (step: number) => void; + openSearch: () => void; +}; + +export type DocxDocumentPages = { current: number; total: number }; + +type FolioDocumentProps = { + controlsRef: Ref; + data: ArrayBuffer; + failed: boolean; + onError: (error: Error) => void; + onPagesChange: (pages: DocxDocumentPages) => void; + onZoomChange: (zoom: number) => void; +}; + +const folioMessages = getFolioMessages("en"); + +export const FolioDocument = ({ + controlsRef, + data, + failed, + onError, + onPagesChange, + onZoomChange, +}: FolioDocumentProps) => { + const editorRef = useRef(null); + const reportedPages = useRef({ current: 0, total: 0 }); + + const reportPages = useCallback(() => { + const editor = editorRef.current; + if (!editor) { + return; + } + const pages = { + current: editor.getCurrentPage(), + total: editor.getTotalPages(), + }; + if ( + pages.current !== reportedPages.current.current || + pages.total !== reportedPages.current.total + ) { + reportedPages.current = pages; + onPagesChange(pages); + } + }, [onPagesChange]); + + /** + * folio lays pages out in the background and has no event for a finished + * layout, so the page count is polled. + */ + useEffect(() => { + const interval = setInterval(reportPages, 500); + return () => clearInterval(interval); + }, [reportPages]); + + useImperativeHandle( + controlsRef, + () => ({ + goToPage: (pageNumber) => editorRef.current?.scrollToPage(pageNumber), + zoomBy: (step) => { + const editor = editorRef.current; + if (!editor) { + return; + } + editor.setZoom(editor.getZoom() + step); + onZoomChange(editor.getZoom()); + }, + openSearch: () => { + /** Find highlights a hit only once folio's editor view exists. */ + editorRef.current?.getEditorRef()?.ensureView({ focus: false }); + editorRef.current?.openFind(); + }, + }), + [onZoomChange], + ); + + return ( + + } + onError={onError} + onScrollTopChange={reportPages} + readOnly + style={{ height: "100%" }} + /> + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-react-messages.d.ts b/apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-react-messages.d.ts new file mode 100644 index 00000000000..e164febd086 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-react-messages.d.ts @@ -0,0 +1,10 @@ +/** + * The frontend's `moduleResolution: "node"` ignores package `exports`, so + * TypeScript cannot find this subpath's own types. Webpack resolves it at + * runtime. Matches `@stll/folio-core/dist/i18n/messages.d.ts`. + */ +declare module "@stll/folio-react/messages" { + import type { AbstractIntlMessages } from "use-intl"; + + export const getFolioMessages: (locale: string) => AbstractIntlMessages; +} diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/file-preview-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/file-preview-section.tsx index 065b2a52e00..ed1b30ec8e8 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/file-preview-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/file-preview-section.tsx @@ -45,6 +45,17 @@ const PdfPreview = dynamic( { ssr: false }, ); +const DocxPreview = dynamic( + () => import("../../docx-preview").then((module) => module.DocxPreview), + { + ssr: false, + loading: () => , + }, +); + +const docxMimeType = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + const ActionButtonsContainer = ({ children }: PropsWithChildren) => ( { const isPdf = mimeType === "application/pdf" || fileName?.endsWith(".pdf"); + const isDocx = + mimeType === docxMimeType || !!fileName?.toLowerCase().endsWith(".docx"); + return ( - setShowThumbnails(!showThumbnails)} - > - - showThumbnails ? palette.blue[70] : undefined, - }} - /> - + {isPdf && ( + setShowThumbnails(!showThumbnails)} + > + + showThumbnails ? palette.blue[70] : undefined, + }} + /> + + )} setShowSearch(!showSearch)} @@ -306,7 +322,8 @@ export const FilePreviewSection = () => { justifyContent: "center", boxShadow: boxShadows.sm, borderRadius: 1, - height: isPdf ? "auto" : isFullScreen ? "100%" : previewHeight, + height: + isPdf || isDocx ? "auto" : isFullScreen ? "100%" : previewHeight, position: "relative", })} > @@ -344,6 +361,12 @@ export const FilePreviewSection = () => { showThumbnails={showThumbnails} url={fileUrl} /> + ) : isDocx ? ( + ) : ( => { + const response = await fetch(`${fileUrl}?urlOnly=true`, { + credentials: "include", + }); + + if (!response.ok) { + throw new Error( + `Requesting a download URL for ${fileUrl} failed with HTTP ${response.status}`, + ); + } + + const body: unknown = await response.json(); + + if ( + typeof body !== "object" || + body === null || + !("url" in body) || + typeof body.url !== "string" + ) { + throw new Error(`The download URL response for ${fileUrl} has no url`); + } + + return body.url; +}; diff --git a/apps/hash-frontend/src/pages/shared/pdf-preview.tsx b/apps/hash-frontend/src/pages/shared/pdf-preview.tsx index 63b09024801..e1d34a8e517 100644 --- a/apps/hash-frontend/src/pages/shared/pdf-preview.tsx +++ b/apps/hash-frontend/src/pages/shared/pdf-preview.tsx @@ -14,6 +14,7 @@ import { MagnifyingGlassPlusLightIcon, } from "@hashintel/design-system"; +import { fetchPresignedFileUrl } from "./fetch-presigned-file-url"; import { GrayToBlueIconButton } from "./gray-to-blue-icon-button"; import { a4Ratio, @@ -113,21 +114,7 @@ export const PdfPreview = ({ const [url, setUrl] = useState(""); useEffect(() => { - /** - * The API normally handles file downloads by: - * 1. Accepting a request with a cookie - * 2. Checking if the authenticated user can access the file - * 3. Automatically redirecting the request to a presigned URL to fetch the file - * - * This doesn't work for JS fetch because the request to the redirected URL will have origin: null, - * which the object storage's CORS headers don't permit (at least in our deployed configuration). - * So we instead ask the API for the presigned URL only, and pass that to react-pdf. - */ - void fetch(`${initialUrl}?urlOnly=true`, { credentials: "include" }) - .then((resp) => resp.json()) - .then((response) => { - setUrl(response.url); - }); + void fetchPresignedFileUrl(initialUrl).then(setUrl); }, [initialUrl]); const [documentProxy, setDocumentProxy] = useState( diff --git a/tests/hash-playwright/tests/features/file-preview.spec.ts b/tests/hash-playwright/tests/features/file-preview.spec.ts index 7caba0d8851..f5351a7a5bc 100644 --- a/tests/hash-playwright/tests/features/file-preview.spec.ts +++ b/tests/hash-playwright/tests/features/file-preview.spec.ts @@ -2,6 +2,10 @@ import { extractWebIdFromEntityId } from "@blockprotocol/type-system"; import { generateEntityPath } from "@local/hash-isomorphic-utils/frontend-paths"; import { getUser, uploadFile } from "../shared/api-queries"; +import { + generateHostileTestDocx, + generateTestDocx, +} from "../shared/generate-test-docx"; import { generateTestPdf } from "../shared/generate-test-pdf"; import { expect, test } from "../shared/runtime"; import { @@ -15,16 +19,20 @@ const [firstPage, secondPage, thirdPage] = testDocumentPages; const getPdfPage = (page: Page) => page.locator(".react-pdf-page"); -test.beforeEach(async ({ page }) => { +const docxMimeType = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + +const uploadAndOpenFile = async ( + page: Page, + file: { contents: Buffer; mimeType: string; name: string }, +) => { const user = await getUser(page.request); if (!user) { throw new Error("Cannot upload a file without an authenticated user"); } const fileEntity = await uploadFile(page.request, { - contents: generateTestPdf(), - mimeType: "application/pdf", - name: `file-preview-${Date.now()}.pdf`, + ...file, webId: extractWebIdFromEntityId(user.metadata.recordId.entityId), }); @@ -35,67 +43,243 @@ test.beforeEach(async ({ page }) => { shortname: "alice", }), ); +}; - await expect( - getPdfPage(page).getByText(firstPage.heading, { exact: true }), - ).toBeVisible({ timeout: 15_000 }); +test.describe("PDF preview", () => { + test.beforeEach(async ({ page }) => { + await uploadAndOpenFile(page, { + contents: generateTestPdf(), + mimeType: "application/pdf", + name: `file-preview-${Date.now()}.pdf`, + }); + + await expect( + getPdfPage(page).getByText(firstPage.heading, { exact: true }), + ).toBeVisible({ timeout: 15_000 }); + }); + + test("the next page button shows the next page", async ({ page }) => { + await page.getByRole("button", { name: "Next page" }).click(); + await expect( + getPdfPage(page).getByText(secondPage.heading, { exact: true }), + ).toBeVisible(); + }); + + test("a page thumbnail shows its page", async ({ page }) => { + await page.getByRole("group", { name: "Page 3", exact: true }).click(); + await expect( + getPdfPage(page).getByText(thirdPage.heading, { exact: true }), + ).toBeVisible(); + }); + + test("zooming in makes the page wider", async ({ page }) => { + const pdfPage = getPdfPage(page); + + const widthBeforeZoom = (await pdfPage.boundingBox())?.width ?? 0; + await page.getByRole("button", { name: "Zoom in" }).click(); + await expect + .poll(async () => (await pdfPage.boundingBox())?.width ?? 0) + .toBeGreaterThan(widthBeforeZoom); + }); + + test("search highlights a word and the next result moves to the next page holding it", async ({ + page, + }) => { + const pdfPage = getPdfPage(page); + + await page.getByRole("button", { name: "Show search" }).click(); + await page + .getByPlaceholder("Search the document...") + .fill(testDocumentSearchWord); + + const searchHighlight = pdfPage.locator('span[style*="border-bottom"]', { + hasText: testDocumentSearchWord, + }); + + await expect(searchHighlight).toBeVisible(); + + await page.getByRole("button", { name: "Next result" }).click(); + await expect( + pdfPage.getByText(thirdPage.heading, { exact: true }), + ).toBeVisible(); + await expect(searchHighlight).toBeVisible(); + }); + + test("text selected on a page can be copied and pasted", async ({ page }) => { + await getPdfPage(page) + .getByText(firstPage.heading, { exact: true }) + .selectText(); + await page.keyboard.press("ControlOrMeta+C"); + + await page.getByRole("button", { name: "Show search" }).click(); + const searchInput = page.getByPlaceholder("Search the document..."); + await searchInput.press("ControlOrMeta+V"); + await expect(searchInput).toHaveValue(firstPage.heading); + }); }); -test("the next page button shows the next page", async ({ page }) => { +test("a DOCX file entity shows a preview that can be paged, zoomed and searched", async ({ + page, +}) => { + await uploadAndOpenFile(page, { + contents: generateTestDocx(), + mimeType: docxMimeType, + name: `file-preview-${Date.now()}.docx`, + }); + + const docxPage = (heading: string) => + page.locator(".layout-page", { hasText: heading }); + + await expect(docxPage(firstPage.heading)).toBeVisible({ timeout: 15_000 }); + await page.getByRole("button", { name: "Next page" }).click(); - await expect( - getPdfPage(page).getByText(secondPage.heading, { exact: true }), - ).toBeVisible(); + await expect(docxPage(secondPage.heading)).toBeInViewport(); + + await page.getByRole("button", { name: "Previous page" }).click(); + await expect(docxPage(firstPage.heading)).toBeInViewport(); + + const widthBeforeZoom = + (await docxPage(firstPage.heading).boundingBox())?.width ?? 0; + await page.getByRole("button", { name: "Zoom in" }).click(); + await expect + .poll( + async () => (await docxPage(firstPage.heading).boundingBox())?.width ?? 0, + ) + .toBeGreaterThan(widthBeforeZoom); + + await page.getByRole("button", { name: "Show search" }).click(); + await page + .getByPlaceholder("Search document...") + .fill(testDocumentSearchWord); + await expect(page.locator("[data-folio-selection-rect]")).toBeVisible(); + + await page.getByPlaceholder("Search document...").press("Enter"); + await expect(docxPage(thirdPage.heading)).toBeInViewport(); + await expect(page.locator("[data-folio-selection-rect]")).toBeVisible(); }); -test("a page thumbnail shows its page", async ({ page }) => { - await page.getByRole("group", { name: "Page 3", exact: true }).click(); - await expect( - getPdfPage(page).getByText(thirdPage.heading, { exact: true }), - ).toBeVisible(); +test("the DOCX preview cannot be edited", async ({ page }) => { + await uploadAndOpenFile(page, { + contents: generateTestDocx(), + mimeType: docxMimeType, + name: `file-preview-read-only-${Date.now()}.docx`, + }); + + const firstDocxPage = page.locator(".layout-page", { + hasText: firstPage.heading, + }); + await expect(firstDocxPage).toBeVisible({ timeout: 15_000 }); + const textBefore = (await firstDocxPage.textContent()) ?? ""; + + await firstDocxPage.getByText(firstPage.heading, { exact: true }).click(); + await page.keyboard.type("typed text"); + await page.keyboard.press("Enter"); + await page.keyboard.press("Backspace"); + await page.keyboard.press("Delete"); + + await expect(firstDocxPage).toHaveText(textBefore); }); -test("zooming in makes the page wider", async ({ page }) => { - const pdfPage = getPdfPage(page); +test("text selected in the DOCX preview copies exactly", async ({ page }) => { + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + + await uploadAndOpenFile(page, { + contents: generateTestDocx(), + mimeType: docxMimeType, + name: `file-preview-copy-${Date.now()}.docx`, + }); + + const heading = page + .locator(".layout-page") + .first() + .getByText(firstPage.heading, { exact: true }); + await expect(heading).toBeVisible({ timeout: 15_000 }); + + const box = await heading.boundingBox(); + if (!box) { + throw new Error("The heading on page 1 has no bounding box"); + } + const lineY = box.y + box.height / 2; + await page.mouse.move(box.x + 1, lineY); + await page.mouse.down(); + await page.mouse.move(box.x + box.width - 1, lineY, { steps: 5 }); + await page.mouse.up(); + await page.keyboard.press("ControlOrMeta+C"); - const widthBeforeZoom = (await pdfPage.boundingBox())?.width ?? 0; - await page.getByRole("button", { name: "Zoom in" }).click(); await expect - .poll(async () => (await pdfPage.boundingBox())?.width ?? 0) - .toBeGreaterThan(widthBeforeZoom); + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(firstPage.heading); }); -test("search highlights a word and the next result moves to the next page holding it", async ({ +test("the DOCX preview runs and loads nothing from a hostile document", async ({ page, }) => { - const pdfPage = getPdfPage(page); + const dialogs: string[] = []; + page.on("dialog", (dialog) => { + dialogs.push(dialog.message()); + void dialog.dismiss(); + }); - await page.getByRole("button", { name: "Show search" }).click(); - await page - .getByPlaceholder("Search the document...") - .fill(testDocumentSearchWord); + const outsideRequests: string[] = []; + page.on("request", (request) => { + if (new URL(request.url()).hostname !== "localhost") { + outsideRequests.push(request.url()); + } + }); - const searchHighlight = pdfPage.locator('span[style*="border-bottom"]', { - hasText: testDocumentSearchWord, + await uploadAndOpenFile(page, { + contents: generateHostileTestDocx(), + mimeType: docxMimeType, + name: `file-preview-hostile-${Date.now()}.docx`, }); - await expect(searchHighlight).toBeVisible(); + await expect( + page.locator(".layout-page", { hasText: "Hostile document" }), + ).toBeVisible({ timeout: 15_000 }); - await page.getByRole("button", { name: "Next result" }).click(); + const preview = page.locator("[data-folio-scroll]"); + await expect(preview.locator("script")).toHaveCount(0); + await expect(preview.locator("iframe")).toHaveCount(0); + await expect( + preview.locator('a[href^="javascript:"], a[href^="data:"]'), + ).toHaveCount(0); await expect( - pdfPage.getByText(thirdPage.heading, { exact: true }), + preview.locator("[onerror], [onload], [onclick], [onmouseover]"), + ).toHaveCount(0); + + await page.locator(".layout-page", { hasText: "javascript link" }).click(); + await page.locator(".layout-page", { hasText: "data link" }).click(); + + expect(dialogs).toEqual([]); + expect(outsideRequests).toEqual([]); +}); + +test("a .doc file keeps the icon view", async ({ page }) => { + const name = `file-preview-${Date.now()}.doc`; + + await uploadAndOpenFile(page, { + contents: Buffer.from("Not a Word 97-2003 document"), + mimeType: "application/msword", + name, + }); + + await expect( + page.getByRole("paragraph").filter({ hasText: name }), ).toBeVisible(); - await expect(searchHighlight).toBeVisible(); + await expect(page.locator(".layout-page")).toHaveCount(0); }); -test("text selected on a page can be copied and pasted", async ({ page }) => { - await getPdfPage(page) - .getByText(firstPage.heading, { exact: true }) - .selectText(); - await page.keyboard.press("ControlOrMeta+C"); +test("a corrupt .docx file stops the loading indicator and keeps the download", async ({ + page, +}) => { + await uploadAndOpenFile(page, { + contents: Buffer.from("Not a ZIP archive"), + mimeType: docxMimeType, + name: `file-preview-corrupt-${Date.now()}.docx`, + }); - await page.getByRole("button", { name: "Show search" }).click(); - const searchInput = page.getByPlaceholder("Search the document..."); - await searchInput.press("ControlOrMeta+V"); - await expect(searchInput).toHaveValue(firstPage.heading); + await expect(page.getByRole("button", { name: "Download" })).toBeVisible(); + await expect(page.getByRole("progressbar")).toHaveCount(0, { + timeout: 15_000, + }); }); diff --git a/tests/hash-playwright/tests/shared/generate-test-docx.ts b/tests/hash-playwright/tests/shared/generate-test-docx.ts new file mode 100644 index 00000000000..a82078ecb17 --- /dev/null +++ b/tests/hash-playwright/tests/shared/generate-test-docx.ts @@ -0,0 +1,136 @@ +import { crc32 } from "node:zlib"; + +import { testDocumentPages } from "./test-document"; + +type ZipEntry = { name: string; contents: string }; + +/** + * Writes a ZIP archive whose entries are stored uncompressed, which is all a + * DOCX reader needs. + */ +const writeStoredZip = (entries: ZipEntry[]): Buffer => { + const localParts: Buffer[] = []; + const centralParts: Buffer[] = []; + let offset = 0; + + for (const entry of entries) { + const name = Buffer.from(entry.name, "utf8"); + const data = Buffer.from(entry.contents, "utf8"); + const checksum = crc32(data); + + const localHeader = Buffer.alloc(30); + localHeader.writeUInt32LE(0x04034b50, 0); + localHeader.writeUInt16LE(20, 4); + localHeader.writeUInt32LE(checksum, 14); + localHeader.writeUInt32LE(data.length, 18); + localHeader.writeUInt32LE(data.length, 22); + localHeader.writeUInt16LE(name.length, 26); + + const centralHeader = Buffer.alloc(46); + centralHeader.writeUInt32LE(0x02014b50, 0); + centralHeader.writeUInt16LE(20, 4); + centralHeader.writeUInt16LE(20, 6); + centralHeader.writeUInt32LE(checksum, 16); + centralHeader.writeUInt32LE(data.length, 20); + centralHeader.writeUInt32LE(data.length, 24); + centralHeader.writeUInt16LE(name.length, 28); + centralHeader.writeUInt32LE(offset, 42); + + localParts.push(localHeader, name, data); + centralParts.push(centralHeader, name); + offset += localHeader.length + name.length + data.length; + } + + const centralDirectory = Buffer.concat(centralParts); + + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(centralDirectory.length, 12); + end.writeUInt32LE(offset, 16); + + return Buffer.concat([...localParts, centralDirectory, end]); +}; + +const wordNamespace = + "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; +const relationshipNamespace = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; +const relationshipTypes = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + +const writeDocx = ({ + body, + documentRelationships = [], + extraParts = [], + extraContentTypes = "", +}: { + body: string; + documentRelationships?: string[]; + extraParts?: ZipEntry[]; + extraContentTypes?: string; +}): Buffer => + writeStoredZip([ + { + name: "[Content_Types].xml", + contents: `${extraContentTypes}`, + }, + { + name: "_rels/.rels", + contents: ``, + }, + { + name: "word/_rels/document.xml.rels", + contents: `${documentRelationships.join("")}`, + }, + { + name: "word/document.xml", + contents: `${body}`, + }, + ...extraParts, + ]); + +const paragraph = (text: string) => + `${text}`; + +const pageBreak = ``; + +/** Renders `testDocumentPages` as a DOCX, one page per entry. */ +export const generateTestDocx = (): Buffer => + writeDocx({ + body: testDocumentPages + .map(({ heading, body }) => `${paragraph(heading)}${paragraph(body)}`) + .join(pageBreak), + }); + +/** + * Builds a DOCX that tries to make the preview run or load something: a + * `javascript:` link, a `data:` link, body text that looks like an HTML tag + * with an event handler, a picture linked from another origin, and an HTML + * altChunk holding a script. + */ +export const generateHostileTestDocx = (): Buffer => + writeDocx({ + body: [ + paragraph("Hostile document"), + `javascript link`, + `data link`, + paragraph("<img src=x onerror=alert(1)>"), + ``, + ``, + ].join(""), + documentRelationships: [ + ``, + ``, + ``, + ``, + ], + extraParts: [ + { + name: "word/chunk.html", + contents: `

Chunk

`, + }, + ], + extraContentTypes: ``, + }); diff --git a/yarn.lock b/yarn.lock index de961ab3e1c..53e5160cc94 100644 --- a/yarn.lock +++ b/yarn.lock @@ -729,6 +729,7 @@ __metadata: "@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": "npm:3.0.0" "@sigma/node-image": "npm:3.0.0" + "@stll/folio-react": "npm:0.25.0" "@svgr/webpack": "npm:8.1.0" "@tanstack/react-table": "npm:8.21.3" "@testing-library/dom": "npm:10.4.1" @@ -829,6 +830,7 @@ __metadata: typescript: "npm:5.9.3" url-regex-safe: "npm:4.0.0" use-font-face-observer: "npm:1.3.0" + use-intl: "npm:4.14.9" uuid: "npm:14.0.0" vitest: "npm:4.1.11" wait-on: "npm:9.0.1" @@ -4105,6 +4107,13 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.29.7": + version: 7.29.10 + resolution: "@babel/runtime@npm:7.29.10" + checksum: 10c0/32f975da8e9c2cae6b33e1a477f8fe8bbff6722c982b5ce35f99affaf3a7a1b612c8272b43afa0e51d3000928d6f3ba38f9296301115db5640264907c027b0cd + languageName: node + linkType: hard + "@babel/standalone@npm:7.28.5": version: 7.28.5 resolution: "@babel/standalone@npm:7.28.5" @@ -4184,6 +4193,51 @@ __metadata: languageName: node linkType: hard +"@base-ui/react@npm:1.8.0": + version: 1.8.0 + resolution: "@base-ui/react@npm:1.8.0" + dependencies: + "@babel/runtime": "npm:^7.29.7" + "@base-ui/utils": "npm:0.4.0" + "@floating-ui/react-dom": "npm:^2.1.9" + "@floating-ui/utils": "npm:^0.2.12" + use-sync-external-store: "npm:^1.6.0" + peerDependencies: + "@date-fns/tz": ^1.2.0 + "@types/react": ^17 || ^18 || ^19 + date-fns: ^4.0.0 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + "@date-fns/tz": + optional: true + "@types/react": + optional: true + date-fns: + optional: true + checksum: 10c0/bf3faa8891fe6c9695a07b30caa3349f9ffc067f0efd381bb6ed12d68400a2d273ab0f764dd07c5cc6b25f5bc6df6a5688e0164d4e2500ad07efaec8bdd88896 + languageName: node + linkType: hard + +"@base-ui/utils@npm:0.4.0": + version: 0.4.0 + resolution: "@base-ui/utils@npm:0.4.0" + dependencies: + "@babel/runtime": "npm:^7.29.7" + "@floating-ui/utils": "npm:^0.2.12" + reselect: "npm:^5.2.0" + use-sync-external-store: "npm:^1.6.0" + peerDependencies: + "@types/react": ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/dd32220d13d42ed2cf0d0ae18e02bf9ece5c54d6a26e49c9449514d66d38b1fee51c875f7d9d21484a326b7a8a2c33c95be620e8a5976b7b6f3bfa27572b6baf + languageName: node + linkType: hard + "@bcherny/json-schema-ref-parser@npm:10.0.5-fork": version: 10.0.5-fork resolution: "@bcherny/json-schema-ref-parser@npm:10.0.5-fork" @@ -6421,7 +6475,7 @@ __metadata: languageName: node linkType: hard -"@floating-ui/dom@npm:^1.0.0, @floating-ui/dom@npm:^1.7.5, @floating-ui/dom@npm:^1.7.6": +"@floating-ui/dom@npm:^1.0.0, @floating-ui/dom@npm:^1.7.5, @floating-ui/dom@npm:^1.7.6, @floating-ui/dom@npm:^1.8.0": version: 1.8.0 resolution: "@floating-ui/dom@npm:1.8.0" dependencies: @@ -6443,6 +6497,18 @@ __metadata: languageName: node linkType: hard +"@floating-ui/react-dom@npm:^2.1.9": + version: 2.1.9 + resolution: "@floating-ui/react-dom@npm:2.1.9" + dependencies: + "@floating-ui/dom": "npm:^1.8.0" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10c0/9fa9851069738cb9591a1ab2da16e257f193eae5fd2d6d48ad0b1e73f3c049929b6805c5ad43d4af3c1da63c60b215b0ee4651a1fe5caeb80e66d9d423744348 + languageName: node + linkType: hard + "@floating-ui/utils@npm:^0.2.12": version: 0.2.12 resolution: "@floating-ui/utils@npm:0.2.12" @@ -6566,6 +6632,101 @@ __metadata: languageName: node linkType: hard +"@fontsource/arimo@npm:^5.2.8": + version: 5.3.0 + resolution: "@fontsource/arimo@npm:5.3.0" + checksum: 10c0/87cfd2a0a184ba7091d056906427181220ed1492ab6bede6dca1e260d69596b0380abfe578298ba41de3a9b1d3279e7f2ce86e95a96191501868d24552573a8a + languageName: node + linkType: hard + +"@fontsource/caladea@npm:^5.2.8": + version: 5.3.0 + resolution: "@fontsource/caladea@npm:5.3.0" + checksum: 10c0/d9091d8ec8653a7b4e1fa56190b5ff18e3fac58889983165231529f8972e44bf329f6737c509f1c4e5bc72e3674748f8ea715b6e5152a98cab5b0214c7772710 + languageName: node + linkType: hard + +"@fontsource/carlito@npm:^5.2.8": + version: 5.3.0 + resolution: "@fontsource/carlito@npm:5.3.0" + checksum: 10c0/0a41dd1ef555d8619f727530f1c236d6aab5b219297ff485794d6b0519a44cffdda7d5fb930654b08c9e3b52489c922d2ae7ba0eaabad5f4aa4fb60078b54e3b + languageName: node + linkType: hard + +"@fontsource/cousine@npm:^5.2.7": + version: 5.3.0 + resolution: "@fontsource/cousine@npm:5.3.0" + checksum: 10c0/30669b1664363d0f5eb684ee99b78a7f6e15a70d6227e6d5019803fc1833cee996bcd9e4bf845aefbf3240eae03199f29da41a2c6362bba55482a03f3aa94295 + languageName: node + linkType: hard + +"@fontsource/lato@npm:^5.2.7": + version: 5.3.0 + resolution: "@fontsource/lato@npm:5.3.0" + checksum: 10c0/c83b612886a1e49f4b03928d4b5ade04c17e7bca68814cfda3118a250806a5e31e8f453e359093cbb15337829a8f6c65674217ba7c89b1808657c18dd3a75e27 + languageName: node + linkType: hard + +"@fontsource/noto-sans-arabic@npm:^5.2.7": + version: 5.3.0 + resolution: "@fontsource/noto-sans-arabic@npm:5.3.0" + checksum: 10c0/8f25a4e7b050b8edfe0d0db12f74fd9c9ef39eed8abc0c39630edc66599ca260aa4f124e6695fe285715485877b6592d472602d8c21b2dc6a7c3632537a7e812 + languageName: node + linkType: hard + +"@fontsource/source-sans-3@npm:^5.2.9": + version: 5.3.0 + resolution: "@fontsource/source-sans-3@npm:5.3.0" + checksum: 10c0/71c72a8f83622c669ecaa6ab757e946b2bf60024753a7736a393875664d33c3cc7ff3d4ca945fdd973448c93c426635b82e9998355a34b72eab88c62861446d9 + languageName: node + linkType: hard + +"@fontsource/tinos@npm:^5.2.7": + version: 5.3.0 + resolution: "@fontsource/tinos@npm:5.3.0" + checksum: 10c0/a6cbae046206235b5f5bd490205c0ef84a6b0e7f0b86a34a7b5c36f4ac6af844dc7ee1d55a691b9e2ebe0076614e8128a6c39275545f2685644c964d6096d072 + languageName: node + linkType: hard + +"@formatjs/fast-memoize@npm:3.1.7, @formatjs/fast-memoize@npm:^3.1.0": + version: 3.1.7 + resolution: "@formatjs/fast-memoize@npm:3.1.7" + checksum: 10c0/dee801f82eda1f6fdc166b0c907e8750c15e409e20b2006c215d9247fe0db720d84d53fcb2fb7559cec21a24932b69dda326c81ee2885aad2cde31bc25449fd0 + languageName: node + linkType: hard + +"@formatjs/icu-messageformat-parser@npm:3.5.18": + version: 3.5.18 + resolution: "@formatjs/icu-messageformat-parser@npm:3.5.18" + dependencies: + "@formatjs/icu-skeleton-parser": "npm:2.1.11" + checksum: 10c0/4c4ecfb9e99e4f644c934279f91f034dc607e1935d0116e7fc7524c70f2708e9847ab2b335baeb2fbecb21da6f5cb629d2d713c56cd545f189ea0f45673028e9 + languageName: node + linkType: hard + +"@formatjs/icu-messageformat-parser@npm:^3.4.0": + version: 3.5.21 + resolution: "@formatjs/icu-messageformat-parser@npm:3.5.21" + dependencies: + "@formatjs/icu-skeleton-parser": "npm:2.1.12" + checksum: 10c0/4f94c09df9495fbf4d79eb458a85d8abbb10d288cdd8443f089b2c1748be74495f0bf2da154af81ced1a44896bc414421dbb5ebf1fdabd9f8175d23bcd3bffbc + languageName: node + linkType: hard + +"@formatjs/icu-skeleton-parser@npm:2.1.11": + version: 2.1.11 + resolution: "@formatjs/icu-skeleton-parser@npm:2.1.11" + checksum: 10c0/9224b5cbfd64dc1e22ff48d758582e2f36cb4e2cd58f7f1fcfd251527f52ed7a1be27998c800c6dec3b718e06c73f98318a8fd8550bb0bbb467168988c630d29 + languageName: node + linkType: hard + +"@formatjs/icu-skeleton-parser@npm:2.1.12": + version: 2.1.12 + resolution: "@formatjs/icu-skeleton-parser@npm:2.1.12" + checksum: 10c0/56507b7b5e3e38f964e80efbece91ce80f2d33cbeb0533a66efd9bda55b9d17e3aa2e933a8ad56d8368840c80d4777cc200e83ef6c8e2d4f2e24563ac2c404fa + languageName: node + linkType: hard + "@fortawesome/fontawesome-common-types@npm:6.7.2": version: 6.7.2 resolution: "@fortawesome/fontawesome-common-types@npm:6.7.2" @@ -10888,6 +11049,13 @@ __metadata: languageName: node linkType: hard +"@nodable/entities@npm:^3.0.0": + version: 3.1.0 + resolution: "@nodable/entities@npm:3.1.0" + checksum: 10c0/a1a2a081949a72da17846dc44962dc95a2bf5268867407fc4f6b686acda4dc5e9fd85f0398999969309b9f7fe0fbe85947a91c1ddac47eba8f9b58224c36f6fb + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -15004,6 +15172,13 @@ __metadata: languageName: node linkType: hard +"@schummar/icu-type-parser@npm:1.21.5": + version: 1.21.5 + resolution: "@schummar/icu-type-parser@npm:1.21.5" + checksum: 10c0/8c2bdb2087b2aeaafb484afefcc5291b21a7179fb92429cabc5f6b46019c6dedbeac6025b1a8f08e8749fb963f8a011e0ccba340ca3761f3c2348aac4b667c2c + languageName: node + linkType: hard + "@sec-ant/readable-stream@npm:^0.4.1": version: 0.4.1 resolution: "@sec-ant/readable-stream@npm:0.4.1" @@ -16491,6 +16666,115 @@ __metadata: languageName: node linkType: hard +"@stll/conditions@npm:^0.4.0": + version: 0.4.0 + resolution: "@stll/conditions@npm:0.4.0" + dependencies: + better-result: "npm:3.0.1" + valibot: "npm:1.4.2" + checksum: 10c0/602c22bf92842d04ac549774bc4590f96a208b05f10fd5a8f75fb1fb78312619c49fb59f0813dc1c75b1f5d361a0c86795e335c34542bb560f03766277f1e410 + languageName: node + linkType: hard + +"@stll/docx-core@npm:^0.31.0": + version: 0.31.0 + resolution: "@stll/docx-core@npm:0.31.0" + dependencies: + "@nodable/entities": "npm:^3.0.0" + "@stll/docx-utils": "npm:^0.1.0" + better-result: "npm:3.0.1" + fast-xml-parser: "npm:^5.10.1" + jszip: "npm:3.10.2" + marked: "npm:^18.0.5" + checksum: 10c0/b3f1f719eb8ce849e1e9f073360c0661dee7bd697a2fb9ecbb66195e749dfb64809634cf49f5b94b1415f10f54d642ccaba1d25adb0aca394eeb05317d9ce5ad + languageName: node + linkType: hard + +"@stll/docx-utils@npm:^0.1.0": + version: 0.1.5 + resolution: "@stll/docx-utils@npm:0.1.5" + dependencies: + better-result: "npm:3.0.1" + jszip: "npm:3.10.2" + slimdom: "npm:^4.3.5" + checksum: 10c0/00b988b760416047203ae1222c702deb7f275d5b95b1949f6d6219fa3cbd54f49e8906a961777c8f9c34d4ef75457b5a1ab07cfb141cbf9f3ea808a2e4a3ddb1 + languageName: node + linkType: hard + +"@stll/folio-core@npm:^0.55.0": + version: 0.55.0 + resolution: "@stll/folio-core@npm:0.55.0" + dependencies: + "@stll/docx-core": "npm:^0.31.0" + "@stll/docx-utils": "npm:^0.1.0" + "@stll/template-conditions": "npm:^0.6.2" + better-result: "npm:3.0.1" + csstype: "npm:^3.1.3" + dompurify: "npm:^3.4.13" + fast-xml-parser: "npm:^5.10.1" + hyphen: "npm:1.14.1" + jszip: "npm:3.10.2" + prosemirror-commands: "npm:^1.7.1" + prosemirror-dropcursor: "npm:^1.8.2" + prosemirror-gapcursor: "npm:^1.4.1" + prosemirror-history: "npm:^1.5.0" + prosemirror-inputrules: "npm:^1.5.0" + prosemirror-keymap: "npm:^1.2.3" + prosemirror-model: "npm:^1.25.9" + prosemirror-state: "npm:^1.4.4" + prosemirror-tables: "npm:^1.8.5" + prosemirror-transform: "npm:^1.12.0" + prosemirror-view: "npm:^1.41.8" + utif2: "npm:^4.1.0" + valibot: "npm:1.5.0" + y-prosemirror: "npm:^1.3.7" + yjs: "npm:^13.6.31" + checksum: 10c0/3ea55809939464f048632ce5079ef391bbd48308ff9db181fd61e15f2923a22fbf8704cbe6ff56b41a0b17b188ce49f506cb7b7a1a0991094254e9b8b2b8e2ed + languageName: node + linkType: hard + +"@stll/folio-react@npm:0.25.0": + version: 0.25.0 + resolution: "@stll/folio-react@npm:0.25.0" + dependencies: + "@base-ui/react": "npm:1.8.0" + "@fontsource/arimo": "npm:^5.2.8" + "@fontsource/caladea": "npm:^5.2.8" + "@fontsource/carlito": "npm:^5.2.8" + "@fontsource/cousine": "npm:^5.2.7" + "@fontsource/lato": "npm:^5.2.7" + "@fontsource/noto-sans-arabic": "npm:^5.2.7" + "@fontsource/source-sans-3": "npm:^5.2.9" + "@fontsource/tinos": "npm:^5.2.7" + "@stll/folio-core": "npm:^0.55.0" + better-result: "npm:3.0.1" + lucide-react: "npm:1.24.0" + prosemirror-history: "npm:^1.5.0" + prosemirror-model: "npm:^1.25.9" + prosemirror-state: "npm:^1.4.4" + prosemirror-tables: "npm:^1.8.5" + prosemirror-view: "npm:^1.41.8" + react-compiler-runtime: "npm:^1.0.0" + yjs: "npm:^13.6.31" + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + use-intl: ">=4.0.0" + checksum: 10c0/4153b0574d6fb854aa2246c305cf0e2f63c5ea543acfedf678b06622b45ab4a320a9bd2b0995bc47dd254c753d8727d8b15c83c42639bbe100c25ec5ca755585 + languageName: node + linkType: hard + +"@stll/template-conditions@npm:^0.6.2": + version: 0.6.4 + resolution: "@stll/template-conditions@npm:0.6.4" + dependencies: + "@stll/conditions": "npm:^0.4.0" + better-result: "npm:3.0.1" + temporal-polyfill: "npm:1.0.5" + checksum: 10c0/0f4821e885f6f6ec04c0a87cb483983cbd608485a9b0a77233fc71f45b06801415e6099aa7f4b51a75d5be5d384cda9a44b68f603097b0e7fead7bb04b0ae054 + languageName: node + linkType: hard + "@storybook/builder-vite@npm:10.4.6": version: 10.4.6 resolution: "@storybook/builder-vite@npm:10.4.6" @@ -22923,6 +23207,13 @@ __metadata: languageName: node linkType: hard +"better-result@npm:3.0.1": + version: 3.0.1 + resolution: "better-result@npm:3.0.1" + checksum: 10c0/e1057cc22aa78fa1c239223d9a33e0643858d65a7b32625c174fa44b402b15fa9217448f6310924ad03b911c5df1b9890270e7640d3d84d53e9d1d30dbc84bc9 + languageName: node + linkType: hard + "big.js@npm:7.0.1": version: 7.0.1 resolution: "big.js@npm:7.0.1" @@ -30579,6 +30870,13 @@ __metadata: languageName: node linkType: hard +"hyphen@npm:1.14.1": + version: 1.14.1 + resolution: "hyphen@npm:1.14.1" + checksum: 10c0/347af725468eeb79fb7c2a42a494fd8cbf6d34d560bb6b2d49e777194dfceffa1d31b91e43d6fdec0ae3ab100b6fe6524656d51214e901389828a2e1d91657fb + languageName: node + linkType: hard + "i18next@npm:^26.0.7": version: 26.3.6 resolution: "i18next@npm:26.3.6" @@ -30627,6 +30925,15 @@ __metadata: languageName: node linkType: hard +"icu-minify@npm:^4.14.9": + version: 4.14.9 + resolution: "icu-minify@npm:4.14.9" + dependencies: + "@formatjs/icu-messageformat-parser": "npm:^3.4.0" + checksum: 10c0/aa09b8f0b3178fefa5a6e522cd7dd30689643f7585651c72c215bad3aaa030e0bca5b917ce4f7e6dbe91ad90221ad5c20f561d042c5eff21cb39d5e2f14781ef + languageName: node + linkType: hard + "idb-keyval@npm:^6.2.0": version: 6.2.2 resolution: "idb-keyval@npm:6.2.2" @@ -30669,6 +30976,13 @@ __metadata: languageName: node linkType: hard +"immediate@npm:~3.0.5": + version: 3.0.6 + resolution: "immediate@npm:3.0.6" + checksum: 10c0/f8ba7ede69bee9260241ad078d2d535848745ff5f6995c7c7cb41cfdc9ccc213f66e10fa5afb881f90298b24a3f7344b637b592beb4f54e582770cdce3f1f039 + languageName: node + linkType: hard + "immer@npm:10.1.3": version: 10.1.3 resolution: "immer@npm:10.1.3" @@ -30894,6 +31208,16 @@ __metadata: languageName: node linkType: hard +"intl-messageformat@npm:^11.1.0": + version: 11.2.15 + resolution: "intl-messageformat@npm:11.2.15" + dependencies: + "@formatjs/fast-memoize": "npm:3.1.7" + "@formatjs/icu-messageformat-parser": "npm:3.5.18" + checksum: 10c0/868247481c9a1bf8ae5041f2264de79ea7bb9efc7f5fdd7419ae9aad30ebd2b18e5d6f611283c9efed2409338290f2628be12685043aaff56f136cf714ebfff0 + languageName: node + linkType: hard + "invariant@npm:^2.2.4": version: 2.2.4 resolution: "invariant@npm:2.2.4" @@ -31738,6 +32062,13 @@ __metadata: languageName: node linkType: hard +"isomorphic.js@npm:^0.2.4": + version: 0.2.5 + resolution: "isomorphic.js@npm:0.2.5" + checksum: 10c0/7cd268c8e58146a8160c8cd16596291fd1fbf3e8799a325f269accda9dc1238806e371ccef0b66fe2ad957209230c55997248d8b6d02cf2d7c575ffeb759c789 + languageName: node + linkType: hard + "isows@npm:^1.0.7": version: 1.0.7 resolution: "isows@npm:1.0.7" @@ -32431,6 +32762,18 @@ __metadata: languageName: node linkType: hard +"jszip@npm:3.10.2": + version: 3.10.2 + resolution: "jszip@npm:3.10.2" + dependencies: + lie: "npm:~3.3.0" + pako: "npm:~1.0.2" + readable-stream: "npm:~2.3.6" + setimmediate: "npm:^1.0.5" + checksum: 10c0/c51b1473062bb7ac81c0c76047d99a2b56a062f2427ac989e28b46b31ecab636a440f137d15ff6320a961b7e1048c802f0bed83a2e10ed0ba9c2cfd84e818bc6 + languageName: node + linkType: hard + "jwa@npm:^2.0.1": version: 2.0.1 resolution: "jwa@npm:2.0.1" @@ -32753,6 +33096,19 @@ __metadata: languageName: node linkType: hard +"lib0@npm:^0.2.109, lib0@npm:^0.2.85, lib0@npm:^0.2.99": + version: 0.2.119 + resolution: "lib0@npm:0.2.119" + dependencies: + isomorphic.js: "npm:^0.2.4" + bin: + 0ecdsa-generate-keypair: bin/0ecdsa-generate-keypair.js + 0gentesthtml: bin/gentesthtml.js + 0serve: bin/0serve.js + checksum: 10c0/722277c01158cc560c4cdc6093803435d5c1c1ed278a7ccfca4693968cc8a8ff361a0bbfcf7d76d6085c8b65716d381af81cf8d415bd9b5e48428df8896393bf + languageName: node + linkType: hard + "libp2p@npm:3.3.11": version: 3.3.11 resolution: "libp2p@npm:3.3.11" @@ -32788,6 +33144,15 @@ __metadata: languageName: node linkType: hard +"lie@npm:~3.3.0": + version: 3.3.0 + resolution: "lie@npm:3.3.0" + dependencies: + immediate: "npm:~3.0.5" + checksum: 10c0/56dd113091978f82f9dc5081769c6f3b947852ecf9feccaf83e14a123bc630c2301439ce6182521e5fbafbde88e88ac38314327a4e0493a1bea7e0699a7af808 + languageName: node + linkType: hard + "lightningcss-android-arm64@npm:1.31.1": version: 1.31.1 resolution: "lightningcss-android-arm64@npm:1.31.1" @@ -33509,6 +33874,15 @@ __metadata: languageName: node linkType: hard +"lucide-react@npm:1.24.0": + version: 1.24.0 + resolution: "lucide-react@npm:1.24.0" + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/8bc2a7c6cfa33bfc6df5533cec12454197f9182b98da0dcb79c6f167ca99710d9360e68b4ba7959953b4c501a48a100713e1b047a18c326c955920f72b9207ee + languageName: node + linkType: hard + "luxon@npm:^3.2.1": version: 3.5.0 resolution: "luxon@npm:3.5.0" @@ -33649,6 +34023,15 @@ __metadata: languageName: node linkType: hard +"marked@npm:^18.0.5": + version: 18.1.0 + resolution: "marked@npm:18.1.0" + bin: + marked: bin/marked.js + checksum: 10c0/6022a30a15d25fe0eb2958f84ebb45518290e512b61afbf9a0b9452297b30cb5f1af8289fc0057a83fa8232e6115d902d85546aba5036063a97f93be88f4df70 + languageName: node + linkType: hard + "material-ui-popup-state@npm:4.1.0": version: 4.1.0 resolution: "material-ui-popup-state@npm:4.1.0" @@ -36883,6 +37266,13 @@ __metadata: languageName: node linkType: hard +"pako@npm:^1.0.11, pako@npm:~1.0.2": + version: 1.0.11 + resolution: "pako@npm:1.0.11" + checksum: 10c0/86dd99d8b34c3930345b8bbeb5e1cd8a05f608eeb40967b293f72fe469d0e9c88b783a8777e4cc7dc7c91ce54c5e93d88ff4b4f060e6ff18408fd21030d9ffbe + languageName: node + linkType: hard + "pandemonium@npm:^2.4.0": version: 2.4.1 resolution: "pandemonium@npm:2.4.1" @@ -38050,6 +38440,17 @@ __metadata: languageName: node linkType: hard +"prosemirror-commands@npm:^1.7.1": + version: 1.7.2 + resolution: "prosemirror-commands@npm:1.7.2" + dependencies: + prosemirror-model: "npm:^1.0.0" + prosemirror-state: "npm:^1.0.0" + prosemirror-transform: "npm:^1.10.2" + checksum: 10c0/50f5b7ddcd9dc497a3d2e672da87fd457dae4ed4d5e2af326f71dad66dfa2b7937162d32e5e118703d2234e1cc2596b23fc5fd05a917e9f7d89d6602e0480051 + languageName: node + linkType: hard + "prosemirror-dev-tools@npm:4.3.0": version: 4.3.0 resolution: "prosemirror-dev-tools@npm:4.3.0" @@ -38082,6 +38483,41 @@ __metadata: languageName: node linkType: hard +"prosemirror-dropcursor@npm:^1.8.2": + version: 1.8.4 + resolution: "prosemirror-dropcursor@npm:1.8.4" + dependencies: + prosemirror-state: "npm:^1.0.0" + prosemirror-transform: "npm:^1.1.0" + prosemirror-view: "npm:^1.1.0" + checksum: 10c0/1c3c708e2b9b04edfed7ec95691eddcd522ebc30ca3f89457a806691b5e3c75f720014302422ec865692c72a350ea869f5cb2a0ef0f8b9ec85608d054676e2c8 + languageName: node + linkType: hard + +"prosemirror-gapcursor@npm:^1.4.1": + version: 1.4.1 + resolution: "prosemirror-gapcursor@npm:1.4.1" + dependencies: + prosemirror-keymap: "npm:^1.0.0" + prosemirror-model: "npm:^1.0.0" + prosemirror-state: "npm:^1.0.0" + prosemirror-view: "npm:^1.0.0" + checksum: 10c0/1719ef91274d0f76f6c5e667d839aae0576e38b1fc08e178425fa076821ef3da88ebd0fe40049d7dd157aa224672270e569f032316de9de588a1baabcfe802f1 + languageName: node + linkType: hard + +"prosemirror-history@npm:^1.5.0": + version: 1.5.1 + resolution: "prosemirror-history@npm:1.5.1" + dependencies: + prosemirror-state: "npm:^1.2.2" + prosemirror-transform: "npm:^1.0.0" + prosemirror-view: "npm:^1.31.0" + rope-sequence: "npm:^1.3.0" + checksum: 10c0/3918d2fa9601e98fa45337dcf85601f47dbb09da02ff560388b81420c22b68f0ca462c5ba651466f33d6539aaff30781f8c3dd97143f77cc92ce376c7d85f199 + languageName: node + linkType: hard + "prosemirror-inputrules@npm:1.2.0": version: 1.2.0 resolution: "prosemirror-inputrules@npm:1.2.0" @@ -38092,6 +38528,16 @@ __metadata: languageName: node linkType: hard +"prosemirror-inputrules@npm:^1.5.0": + version: 1.5.1 + resolution: "prosemirror-inputrules@npm:1.5.1" + dependencies: + prosemirror-state: "npm:^1.0.0" + prosemirror-transform: "npm:^1.0.0" + checksum: 10c0/cff1ff9f7e726bf324e6cddd2c48984e6b2970cc98cd5dc59e6dbe2e9df0e01e4a2d100c77c721067804170b9607769c0fee7c98f2cfb4f3cff9af919fce31b9 + languageName: node + linkType: hard + "prosemirror-keymap@npm:1.2.0": version: 1.2.0 resolution: "prosemirror-keymap@npm:1.2.0" @@ -38102,6 +38548,16 @@ __metadata: languageName: node linkType: hard +"prosemirror-keymap@npm:^1.0.0, prosemirror-keymap@npm:^1.2.3": + version: 1.2.3 + resolution: "prosemirror-keymap@npm:1.2.3" + dependencies: + prosemirror-state: "npm:^1.0.0" + w3c-keyname: "npm:^2.2.0" + checksum: 10c0/0ec2f8bd9b608d0e6a0cdab1d66f9a6b41edcff0239b32ccca1018a0733e52448e4758218a2d472fb8c33c1609426dc6bad4944b28c1c3d509a83201a23035e9 + languageName: node + linkType: hard + "prosemirror-model@npm:1.25.12": version: 1.25.12 resolution: "prosemirror-model@npm:1.25.12" @@ -38122,6 +38578,19 @@ __metadata: languageName: node linkType: hard +"prosemirror-tables@npm:^1.8.5": + version: 1.8.5 + resolution: "prosemirror-tables@npm:1.8.5" + dependencies: + prosemirror-keymap: "npm:^1.2.3" + prosemirror-model: "npm:^1.25.4" + prosemirror-state: "npm:^1.4.4" + prosemirror-transform: "npm:^1.10.5" + prosemirror-view: "npm:^1.41.4" + checksum: 10c0/d6e8cd9d333987ce6492202d5f815b268bb9a722d81456628eff999444d44fa97e8a801a5a20a44e678892f37053f314885d5a03e1df834653e3ea455f0f9290 + languageName: node + linkType: hard + "prosemirror-transform@npm:1.12.2": version: 1.12.2 resolution: "prosemirror-transform@npm:1.12.2" @@ -38546,6 +39015,15 @@ __metadata: languageName: node linkType: hard +"react-compiler-runtime@npm:^1.0.0": + version: 1.0.0 + resolution: "react-compiler-runtime@npm:1.0.0" + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental + checksum: 10c0/e081192380ae32d18bebaf341071ae8aecf80f4401b188f80e4b77e8d8995bb4c7175b8c3f75e8307019eac2015edf45c7999f788dcfcd99fdbe2ba9a2d465a4 + languageName: node + linkType: hard + "react-docgen-typescript@npm:^2.2.2": version: 2.2.2 resolution: "react-docgen-typescript@npm:2.2.2" @@ -39106,7 +39584,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^2.0.1, readable-stream@npm:^2.0.2, readable-stream@npm:^2.0.5, readable-stream@npm:^2.2.2": +"readable-stream@npm:^2.0.1, readable-stream@npm:^2.0.2, readable-stream@npm:^2.0.5, readable-stream@npm:^2.2.2, readable-stream@npm:~2.3.6": version: 2.3.8 resolution: "readable-stream@npm:2.3.8" dependencies: @@ -39825,6 +40303,13 @@ __metadata: languageName: node linkType: hard +"reselect@npm:^5.2.0": + version: 5.3.0 + resolution: "reselect@npm:5.3.0" + checksum: 10c0/c79728e4f315152bfb2d82456d35075b705392d9e72c485d7d9b1061bd80af628416216d80f02a42f24dbb24d738aec7fec45806930aaa1d17c32e9875fa1bd6 + languageName: node + linkType: hard + "resize-observer-polyfill@npm:^1.5.1": version: 1.5.1 resolution: "resize-observer-polyfill@npm:1.5.1" @@ -40361,6 +40846,13 @@ __metadata: languageName: node linkType: hard +"rope-sequence@npm:^1.3.0": + version: 1.3.4 + resolution: "rope-sequence@npm:1.3.4" + checksum: 10c0/caa90be3d7a7cad155fb354a4679a1280dc9819c81bd319542a0d893a64e152284abb9cc1631d4351b328016a8d6c35a48c912234edfaf5173daef44b2e3609b + languageName: node + linkType: hard + "router@npm:^2.2.0": version: 2.2.0 resolution: "router@npm:2.2.0" @@ -40946,7 +41438,7 @@ __metadata: languageName: node linkType: hard -"setimmediate@npm:1.0.5": +"setimmediate@npm:1.0.5, setimmediate@npm:^1.0.5": version: 1.0.5 resolution: "setimmediate@npm:1.0.5" checksum: 10c0/5bae81bfdbfbd0ce992893286d49c9693c82b1bcc00dcaaf3a09c8f428fdeacf4190c013598b81875dfac2b08a572422db7df779a99332d0fce186d15a3e4d49 @@ -41363,6 +41855,13 @@ __metadata: languageName: node linkType: hard +"slimdom@npm:^4.3.5": + version: 4.3.5 + resolution: "slimdom@npm:4.3.5" + checksum: 10c0/b0944eac389ba148a9ae96b515e91d351ab9df36b6fcb63d028ece4f73b8d654e8cb9080032e8d0090ae58e40e92900cc546eaa02365d87e08add2a47d321ffb + languageName: node + linkType: hard + "smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0" @@ -42417,6 +42916,30 @@ __metadata: languageName: node linkType: hard +"temporal-polyfill@npm:1.0.5": + version: 1.0.5 + resolution: "temporal-polyfill@npm:1.0.5" + dependencies: + temporal-spec: "npm:1.0.1" + temporal-utils: "npm:1.0.3" + checksum: 10c0/88928e40e60d0da0c998af2b8da09768ee2c8784fec7526317fde83f55eb69926209a6bbc4360792cac7ace7371949718bc1f12edac1df0fe42eba5d55a43451 + languageName: node + linkType: hard + +"temporal-spec@npm:1.0.1": + version: 1.0.1 + resolution: "temporal-spec@npm:1.0.1" + checksum: 10c0/99f6bfe4a8e59cd0664d0e97706798a1e10d42b1b682da9b60778a4cb9a23362da01ea3d83a674ef426ed743bbab835e2e3d1acea5d2d4a9eabe33863237b014 + languageName: node + linkType: hard + +"temporal-utils@npm:1.0.3": + version: 1.0.3 + resolution: "temporal-utils@npm:1.0.3" + checksum: 10c0/185928a75aba0496ebbbdecbbbbd55cc817756bc39e099424e86477da01042a84ed27482bfc2168b6802fed2f01f004739f174f907e2e125b2fa060a978024b6 + languageName: node + linkType: hard + "terser-webpack-plugin@npm:5.4.0": version: 5.4.0 resolution: "terser-webpack-plugin@npm:5.4.0" @@ -44244,6 +44767,24 @@ __metadata: languageName: node linkType: hard +"use-intl@npm:4.14.9": + version: 4.14.9 + resolution: "use-intl@npm:4.14.9" + dependencies: + "@formatjs/fast-memoize": "npm:^3.1.0" + "@schummar/icu-type-parser": "npm:1.21.5" + icu-minify: "npm:^4.14.9" + intl-messageformat: "npm:^11.1.0" + peerDependencies: + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/dfb8f934aa618cf765b3d3be08572b3fd21291fdf3a3d57507e76531ea19c2bb9e26dfbdd119d437c14ef05f49f95edf198c19816c15e348532e0102b1262094 + languageName: node + linkType: hard + "use-memo-one@npm:^1.1.1": version: 1.1.3 resolution: "use-memo-one@npm:1.1.3" @@ -44278,6 +44819,15 @@ __metadata: languageName: node linkType: hard +"utif2@npm:^4.1.0": + version: 4.1.0 + resolution: "utif2@npm:4.1.0" + dependencies: + pako: "npm:^1.0.11" + checksum: 10c0/c60fd91427548f6b866b66fa983d7f08347aef0ac4c5b85b517dd70a5b0031548f3496e55dad28435031534fda0d237673d881636ff60845bf4a014dc9edf676 + languageName: node + linkType: hard + "util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2, util-deprecate@npm:~1.0.1": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2" @@ -44356,6 +44906,18 @@ __metadata: languageName: node linkType: hard +"valibot@npm:1.5.0": + version: 1.5.0 + resolution: "valibot@npm:1.5.0" + peerDependencies: + typescript: ">=5" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/d49dab2b2fcbb477c894def73466de598b602859a47ab92b26d387c4d2a472cbfd4ef57b49eea91aeeff143c38f5a9523914dc5a73469ec93c7cfd4326eb5fbf + languageName: node + linkType: hard + "validate-npm-package-license@npm:^3.0.1": version: 3.0.4 resolution: "validate-npm-package-license@npm:3.0.4" @@ -45743,6 +46305,32 @@ __metadata: languageName: node linkType: hard +"y-prosemirror@npm:^1.3.7": + version: 1.3.7 + resolution: "y-prosemirror@npm:1.3.7" + dependencies: + lib0: "npm:^0.2.109" + peerDependencies: + prosemirror-model: ^1.7.1 + prosemirror-state: ^1.2.3 + prosemirror-view: ^1.9.10 + y-protocols: ^1.0.1 + yjs: ^13.5.38 + checksum: 10c0/4f544af2be316b28cc25f12ed5d1658b34eb9b6194479cafadd63074baf890c0fb6688dc67a7ec9d869187676ce72c1efed220f002e84418f5fbf790c5060d5f + languageName: node + linkType: hard + +"y-protocols@npm:1.0.7": + version: 1.0.7 + resolution: "y-protocols@npm:1.0.7" + dependencies: + lib0: "npm:^0.2.85" + peerDependencies: + yjs: ^13.0.0 + checksum: 10c0/70e14ab50b9200e97602c5effc3e14ae4ea0f51479a1628569af7cc12a6739fbc02484335828f5c508a4bf6fa0c3ef5ae1c17e13346b7a6093abeb9627f59021 + languageName: node + linkType: hard + "y18n@npm:^5.0.5": version: 5.0.8 resolution: "y18n@npm:5.0.8" @@ -45901,6 +46489,15 @@ __metadata: languageName: node linkType: hard +"yjs@npm:^13.6.31": + version: 13.6.33 + resolution: "yjs@npm:13.6.33" + dependencies: + lib0: "npm:^0.2.99" + checksum: 10c0/85dd66a715b04fe4eda0a7676d363b2e0573830113c941568112ffe83857e9b9092b4912de135896fb56e6a7242eaca0abc5804fda7d2847dcf719fc77ba4940 + languageName: node + linkType: hard + "ylru@npm:^1.2.0": version: 1.4.0 resolution: "ylru@npm:1.4.0" From 9e186b9a0d794fa4cc06802ceea614c869505812 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:35:14 +0200 Subject: [PATCH 02/45] Rename the PDF preview's search bar to FilePreviewSearch Move `pdf-preview/pdf-search.tsx` to `file-preview-search.tsx`, next to the PDF and DOCX previews, so the DOCX preview can use the same bar. --- .../pdf-search.tsx => file-preview-search.tsx} | 10 +++++----- apps/hash-frontend/src/pages/shared/pdf-preview.tsx | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) rename apps/hash-frontend/src/pages/shared/{pdf-preview/pdf-search.tsx => file-preview-search.tsx} (97%) diff --git a/apps/hash-frontend/src/pages/shared/pdf-preview/pdf-search.tsx b/apps/hash-frontend/src/pages/shared/file-preview-search.tsx similarity index 97% rename from apps/hash-frontend/src/pages/shared/pdf-preview/pdf-search.tsx rename to apps/hash-frontend/src/pages/shared/file-preview-search.tsx index 5cff75c31a2..cedf55aab0e 100644 --- a/apps/hash-frontend/src/pages/shared/pdf-preview/pdf-search.tsx +++ b/apps/hash-frontend/src/pages/shared/file-preview-search.tsx @@ -19,8 +19,8 @@ import { } from "@hashintel/design-system"; import { typedKeys } from "@local/advanced-types/typed-entries"; -import { FontCaseRegularIcon } from "../../../shared/icons/font-case-regular-icon"; -import { GrayToBlueIconButton } from "../gray-to-blue-icon-button"; +import { FontCaseRegularIcon } from "../../shared/icons/font-case-regular-icon"; +import { GrayToBlueIconButton } from "./gray-to-blue-icon-button"; import type { TextItem } from "pdfjs-dist/types/src/display/api"; import type { DocumentCallback } from "react-pdf/dist/cjs/shared/types"; @@ -144,7 +144,7 @@ const findStringInPages = ({ return results; }; -type PdfSearchProps = { +type FilePreviewSearchProps = { closeSearch: () => void; document: DocumentCallback | null; searchHits: SearchHits; @@ -154,7 +154,7 @@ type PdfSearchProps = { showSearch: boolean; }; -export const PdfSearch = ({ +export const FilePreviewSearch = ({ closeSearch, document, searchHits, @@ -162,7 +162,7 @@ export const PdfSearch = ({ selectedSearchHit, setSelectedSearchHit, showSearch, -}: PdfSearchProps) => { +}: FilePreviewSearchProps) => { const [ignoreCase, setIgnoreCase] = useState(true); const [searchText, setSearchText] = useState(""); diff --git a/apps/hash-frontend/src/pages/shared/pdf-preview.tsx b/apps/hash-frontend/src/pages/shared/pdf-preview.tsx index e1d34a8e517..f3398c4cd98 100644 --- a/apps/hash-frontend/src/pages/shared/pdf-preview.tsx +++ b/apps/hash-frontend/src/pages/shared/pdf-preview.tsx @@ -15,6 +15,11 @@ import { } from "@hashintel/design-system"; import { fetchPresignedFileUrl } from "./fetch-presigned-file-url"; +import { + FilePreviewSearch, + type SearchHit, + type SearchHits, +} from "./file-preview-search"; import { GrayToBlueIconButton } from "./gray-to-blue-icon-button"; import { a4Ratio, @@ -24,11 +29,6 @@ import { } from "./pdf-preview/dimensions"; import { PageThumbnail } from "./pdf-preview/page-thumbnail"; import { PdfPreviewSkeleton } from "./pdf-preview/pdf-preview-skeleton"; -import { - PdfSearch, - type SearchHit, - type SearchHits, -} from "./pdf-preview/pdf-search"; import { useElementBorderBoxSize } from "./use-element-dimensions"; import type { @@ -244,7 +244,7 @@ export const PdfPreview = ({ zIndex: 100, }} > - setShowSearch(false)} document={documentProxy} searchHits={searchHits} From 54ffc4efff3fa73108255f098f49dda4f6be59e7 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:35:22 +0200 Subject: [PATCH 03/45] Share the PDF preview's search bar between file previews Move the PDF text extraction and hit finding into `pdf-preview/pdf-search-hits.ts`. `FilePreviewSearch` keeps the bar and its input state, and takes the hit count, the selected hit's index and callbacks to search and to select a hit, so it does not depend on pdf.js. --- .../src/pages/shared/file-preview-search.tsx | 269 ++---------------- .../src/pages/shared/pdf-preview.tsx | 57 ++-- .../shared/pdf-preview/pdf-search-hits.ts | 184 ++++++++++++ 3 files changed, 250 insertions(+), 260 deletions(-) create mode 100644 apps/hash-frontend/src/pages/shared/pdf-preview/pdf-search-hits.ts diff --git a/apps/hash-frontend/src/pages/shared/file-preview-search.tsx b/apps/hash-frontend/src/pages/shared/file-preview-search.tsx index cedf55aab0e..4f591d3c082 100644 --- a/apps/hash-frontend/src/pages/shared/file-preview-search.tsx +++ b/apps/hash-frontend/src/pages/shared/file-preview-search.tsx @@ -17,233 +17,37 @@ import { IconButton, TextField, } from "@hashintel/design-system"; -import { typedKeys } from "@local/advanced-types/typed-entries"; import { FontCaseRegularIcon } from "../../shared/icons/font-case-regular-icon"; import { GrayToBlueIconButton } from "./gray-to-blue-icon-button"; -import type { TextItem } from "pdfjs-dist/types/src/display/api"; -import type { DocumentCallback } from "react-pdf/dist/cjs/shared/types"; - -type PositionInPage = { - rowIndex: number; - characterIndex: number; -}; - -export type SearchHit = { - start: PositionInPage; - end: PositionInPage; - pageNumber: number; - indexInAllHits: number; -}; - -export type SearchHitsByPageNumber = { - [pageNumber: number]: SearchHit[]; -}; - -export type SearchHits = { - hitsByPageNumber: SearchHitsByPageNumber; - total: number; -}; - -type Page = { - /** - * The result of combining the text from each line, i.e. the full text for the page - */ - concatenatedText: string; - /** - * The text content of the page, broken down by line - */ - textLines: TextItem[]; - /** - * The character index at which each line starts in the concatenated text - */ - lineCharacterStartIndices: number[]; - /** - * The number of the page in the document (starting at 1) - */ - pageNumber: number; -}; - -/** - * Find all the occurrences of a string in a collection of pages. - * - * Potential improvements: - * 1. Identify occurrences that span multiple pages - */ -const findStringInPages = ({ - pages, - searchString: unnormalizedSearchString, - ignoreCase, -}: { - pages: Page[]; - searchString: string; - ignoreCase: boolean; -}): SearchHits => { - const results: SearchHits = { - hitsByPageNumber: {}, - total: 0, - }; - - const searchString = ignoreCase - ? unnormalizedSearchString.toLowerCase() - : unnormalizedSearchString; - - for (const { - concatenatedText: unnormalizedText, - textLines, - lineCharacterStartIndices, - pageNumber, - } of pages) { - const fullPageText = ignoreCase - ? unnormalizedText.toLowerCase() - : unnormalizedText; - - /** Find the first occurrence of the search string in the full text for the page */ - let nextStartIndex = fullPageText.indexOf(searchString); - - while (nextStartIndex !== -1) { - results.total += 1; - - const matchEndIndex = nextStartIndex + searchString.length - 1; - const matchStartIndex = nextStartIndex; - - /** Determine which line the match starts on, and at which character within the line */ - const startItemIndex = lineCharacterStartIndices.findIndex( - (startIdx, idx) => - startIdx <= matchStartIndex && - (idx === textLines.length - 1 || - lineCharacterStartIndices[idx + 1]! > matchStartIndex), - ); - const startCharIndex = - matchStartIndex - lineCharacterStartIndices[startItemIndex]!; - - /** Determine which line the match ends on, and at which character within the line */ - const endItemIndex = lineCharacterStartIndices.findIndex( - (startIdx, idx) => - startIdx <= matchEndIndex && - (idx === textLines.length - 1 || - lineCharacterStartIndices[idx + 1]! > matchEndIndex), - ); - const endCharIndex = - matchEndIndex - lineCharacterStartIndices[endItemIndex]!; - - results.hitsByPageNumber[pageNumber] ??= []; - results.hitsByPageNumber[pageNumber].push({ - start: { rowIndex: startItemIndex, characterIndex: startCharIndex }, - end: { rowIndex: endItemIndex, characterIndex: endCharIndex }, - pageNumber, - indexInAllHits: results.total - 1, - }); - - /** Find the next occurrence */ - nextStartIndex = fullPageText.indexOf(searchString, nextStartIndex + 1); - } - } - - return results; -}; - type FilePreviewSearchProps = { closeSearch: () => void; - document: DocumentCallback | null; - searchHits: SearchHits; - setSearchHits: (hits: SearchHits) => void; - selectedSearchHit: SearchHit | null; - setSelectedSearchHit: (hit: SearchHit | null) => void; + /** + * Called, debounced, when the search text or the case setting changes, + * and at once with an empty `text` when the search is cleared. + */ + onSearch: (text: string, ignoreCase: boolean) => void; + onSelectHit: (index: number) => void; + selectedIndex: number | null; showSearch: boolean; + total: number; }; export const FilePreviewSearch = ({ closeSearch, - document, - searchHits, - setSearchHits, - selectedSearchHit, - setSelectedSearchHit, + onSearch, + onSelectHit, + selectedIndex, showSearch, + total, }: FilePreviewSearchProps) => { const [ignoreCase, setIgnoreCase] = useState(true); const [searchText, setSearchText] = useState(""); - const [pages, setPages] = useState([]); - const inputRef = useRef(null); - /** - * Build the information we need to identify search hits within the document. - */ - useEffect(() => { - if (!document) { - return; - } - - const newPages: Page[] = []; - - const getPages = async () => { - for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber++) { - const page = await document.getPage(pageNumber); - const textContent = await page.getTextContent(); - - const textLines: TextItem[] = []; - - let concatenatedText = ""; - const itemCharacterStartIndices: number[] = []; - - for (const item of textContent.items) { - if ("str" in item) { - itemCharacterStartIndices.push(concatenatedText.length); - - /** - * This assumes that the text on one line and the text on the next should be separated by a space. - * This may not be the case if one line ends with an em/en dash or other potentially 'joining' characters, - * but we can't be sure that such characters are intended to imply joining either. - */ - concatenatedText += `${item.str.trim()} `; - textLines.push(item); - } - } - - newPages.push({ - concatenatedText, - textLines, - lineCharacterStartIndices: itemCharacterStartIndices, - pageNumber, - }); - } - - setPages(newPages); - }; - - void getPages(); - }, [document]); - - const getSearchHits = debounce( - (currentText: string, shouldIgnoreCase: boolean) => { - if (!currentText) { - setSearchHits({ hitsByPageNumber: {}, total: 0 }); - setSelectedSearchHit(null); - return; - } - - const hits = findStringInPages({ - pages, - searchString: currentText, - ignoreCase: shouldIgnoreCase, - }); - setSearchHits(hits); - - const firstPageWithHit = typedKeys(hits.hitsByPageNumber)[0]; - const firstHit = firstPageWithHit - ? hits.hitsByPageNumber[firstPageWithHit]?.[0] - : null; - - setSelectedSearchHit(firstHit ?? null); - }, - 300, - ); - - const { total, hitsByPageNumber } = searchHits; + const debouncedSearch = debounce(onSearch, 300); useEffect(() => { if (showSearch) { @@ -251,8 +55,6 @@ export const FilePreviewSearch = ({ } }, [showSearch]); - const allHits = Object.values(hitsByPageNumber).flat(); - return ( @@ -265,8 +67,7 @@ export const FilePreviewSearch = ({ { setSearchText(""); - setSelectedSearchHit(null); - setSearchHits({ hitsByPageNumber: {}, total: 0 }); + onSearch("", ignoreCase); closeSearch(); }} sx={{ mr: 0.5 }} @@ -280,20 +81,15 @@ export const FilePreviewSearch = ({ }} onKeyDown={(event) => { if (event.key === "Enter") { - if ( - selectedSearchHit && - selectedSearchHit.indexInAllHits < total - 1 - ) { - setSelectedSearchHit( - allHits[selectedSearchHit.indexInAllHits + 1]!, - ); + if (selectedIndex !== null && selectedIndex < total - 1) { + onSelectHit(selectedIndex + 1); } } }} onChange={(event) => { const text = event.target.value; setSearchText(text); - getSearchHits(text, ignoreCase); + debouncedSearch(text, ignoreCase); }} placeholder="Search the document..." sx={{ @@ -310,7 +106,7 @@ export const FilePreviewSearch = ({ { - getSearchHits(searchText, !ignoreCase); + debouncedSearch(searchText, !ignoreCase); setIgnoreCase(!ignoreCase); inputRef.current?.focus(); }} @@ -333,17 +129,10 @@ export const FilePreviewSearch = ({ > { - if ( - selectedSearchHit && - selectedSearchHit.indexInAllHits > 0 - ) { - setSelectedSearchHit( - allHits[selectedSearchHit.indexInAllHits - 1]!, - ); + if (selectedIndex !== null && selectedIndex > 0) { + onSelectHit(selectedIndex - 1); } }} sx={{ "& svg": { fontSize: 10 }, p: 0.6 }} @@ -358,22 +147,14 @@ export const FilePreviewSearch = ({ whiteSpace: "nowrap", }} > - {`${(selectedSearchHit?.indexInAllHits ?? -1) + 1} of ${total}`} + {`${(selectedIndex ?? -1) + 1} of ${total}`} { - if ( - selectedSearchHit && - selectedSearchHit.indexInAllHits < total - 1 - ) { - setSelectedSearchHit( - allHits[selectedSearchHit.indexInAllHits + 1]!, - ); + if (selectedIndex !== null && selectedIndex < total - 1) { + onSelectHit(selectedIndex + 1); } }} sx={{ "& svg": { fontSize: 10 }, p: 0.6 }} diff --git a/apps/hash-frontend/src/pages/shared/pdf-preview.tsx b/apps/hash-frontend/src/pages/shared/pdf-preview.tsx index f3398c4cd98..5f5d20c1085 100644 --- a/apps/hash-frontend/src/pages/shared/pdf-preview.tsx +++ b/apps/hash-frontend/src/pages/shared/pdf-preview.tsx @@ -13,13 +13,10 @@ import { MagnifyingGlassMinusLightIcon, MagnifyingGlassPlusLightIcon, } from "@hashintel/design-system"; +import { typedKeys } from "@local/advanced-types/typed-entries"; import { fetchPresignedFileUrl } from "./fetch-presigned-file-url"; -import { - FilePreviewSearch, - type SearchHit, - type SearchHits, -} from "./file-preview-search"; +import { FilePreviewSearch } from "./file-preview-search"; import { GrayToBlueIconButton } from "./gray-to-blue-icon-button"; import { a4Ratio, @@ -29,6 +26,11 @@ import { } from "./pdf-preview/dimensions"; import { PageThumbnail } from "./pdf-preview/page-thumbnail"; import { PdfPreviewSkeleton } from "./pdf-preview/pdf-preview-skeleton"; +import { + type SearchHit, + type SearchHits, + useFindSearchHits, +} from "./pdf-preview/pdf-search-hits"; import { useElementBorderBoxSize } from "./use-element-dimensions"; import type { @@ -131,6 +133,36 @@ export const PdfPreview = ({ null, ); + const findSearchHits = useFindSearchHits(documentProxy); + + const selectSearchHit = (searchHit: SearchHit | null) => { + setSelectedSearchHit(searchHit); + + if (searchHit) { + setSelectedPageNumber(searchHit.pageNumber); + } + }; + + const search = (text: string, ignoreCase: boolean) => { + if (!text) { + setSearchHits({ hitsByPageNumber: {}, total: 0 }); + selectSearchHit(null); + return; + } + + const hits = findSearchHits(text, ignoreCase); + setSearchHits(hits); + + const firstPageWithHit = typedKeys(hits.hitsByPageNumber)[0]; + const firstHit = firstPageWithHit + ? hits.hitsByPageNumber[firstPageWithHit]?.[0] + : null; + + selectSearchHit(firstHit ?? null); + }; + + const allSearchHits = Object.values(searchHits.hitsByPageNumber).flat(); + const onDocumentLoadSuccess: OnDocumentLoadSuccess = (docProxy) => { setTotalPages(docProxy.numPages); setDocumentProxy(docProxy); @@ -246,18 +278,11 @@ export const PdfPreview = ({ > setShowSearch(false)} - document={documentProxy} - searchHits={searchHits} - setSearchHits={setSearchHits} - selectedSearchHit={selectedSearchHit} - setSelectedSearchHit={(searchHit) => { - setSelectedSearchHit(searchHit); - - if (searchHit) { - setSelectedPageNumber(searchHit.pageNumber); - } - }} + onSearch={search} + onSelectHit={(index) => selectSearchHit(allSearchHits[index]!)} + selectedIndex={selectedSearchHit?.indexInAllHits ?? null} showSearch={showSearch} + total={searchHits.total} /> {url ? ( diff --git a/apps/hash-frontend/src/pages/shared/pdf-preview/pdf-search-hits.ts b/apps/hash-frontend/src/pages/shared/pdf-preview/pdf-search-hits.ts new file mode 100644 index 00000000000..1013ec3cae6 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/pdf-preview/pdf-search-hits.ts @@ -0,0 +1,184 @@ +import { useCallback, useEffect, useState } from "react"; + +import type { TextItem } from "pdfjs-dist/types/src/display/api"; +import type { DocumentCallback } from "react-pdf/dist/cjs/shared/types"; + +type PositionInPage = { + rowIndex: number; + characterIndex: number; +}; + +export type SearchHit = { + start: PositionInPage; + end: PositionInPage; + pageNumber: number; + indexInAllHits: number; +}; + +export type SearchHitsByPageNumber = { + [pageNumber: number]: SearchHit[]; +}; + +export type SearchHits = { + hitsByPageNumber: SearchHitsByPageNumber; + total: number; +}; + +type Page = { + /** + * The result of combining the text from each line, i.e. the full text for the page + */ + concatenatedText: string; + /** + * The text content of the page, broken down by line + */ + textLines: TextItem[]; + /** + * The character index at which each line starts in the concatenated text + */ + lineCharacterStartIndices: number[]; + /** + * The number of the page in the document (starting at 1) + */ + pageNumber: number; +}; + +/** + * Find all the occurrences of a string in a collection of pages. + * + * Potential improvements: + * 1. Identify occurrences that span multiple pages + */ +const findStringInPages = ({ + pages, + searchString: unnormalizedSearchString, + ignoreCase, +}: { + pages: Page[]; + searchString: string; + ignoreCase: boolean; +}): SearchHits => { + const results: SearchHits = { + hitsByPageNumber: {}, + total: 0, + }; + + const searchString = ignoreCase + ? unnormalizedSearchString.toLowerCase() + : unnormalizedSearchString; + + for (const { + concatenatedText: unnormalizedText, + textLines, + lineCharacterStartIndices, + pageNumber, + } of pages) { + const fullPageText = ignoreCase + ? unnormalizedText.toLowerCase() + : unnormalizedText; + + /** Find the first occurrence of the search string in the full text for the page */ + let nextStartIndex = fullPageText.indexOf(searchString); + + while (nextStartIndex !== -1) { + results.total += 1; + + const matchEndIndex = nextStartIndex + searchString.length - 1; + const matchStartIndex = nextStartIndex; + + /** Determine which line the match starts on, and at which character within the line */ + const startItemIndex = lineCharacterStartIndices.findIndex( + (startIdx, idx) => + startIdx <= matchStartIndex && + (idx === textLines.length - 1 || + lineCharacterStartIndices[idx + 1]! > matchStartIndex), + ); + const startCharIndex = + matchStartIndex - lineCharacterStartIndices[startItemIndex]!; + + /** Determine which line the match ends on, and at which character within the line */ + const endItemIndex = lineCharacterStartIndices.findIndex( + (startIdx, idx) => + startIdx <= matchEndIndex && + (idx === textLines.length - 1 || + lineCharacterStartIndices[idx + 1]! > matchEndIndex), + ); + const endCharIndex = + matchEndIndex - lineCharacterStartIndices[endItemIndex]!; + + results.hitsByPageNumber[pageNumber] ??= []; + results.hitsByPageNumber[pageNumber].push({ + start: { rowIndex: startItemIndex, characterIndex: startCharIndex }, + end: { rowIndex: endItemIndex, characterIndex: endCharIndex }, + pageNumber, + indexInAllHits: results.total - 1, + }); + + /** Find the next occurrence */ + nextStartIndex = fullPageText.indexOf(searchString, nextStartIndex + 1); + } + } + + return results; +}; + +/** + * Returns a function that finds the occurrences of a string in the document. + */ +export const useFindSearchHits = (document: DocumentCallback | null) => { + const [pages, setPages] = useState([]); + + /** + * Build the information we need to identify search hits within the document. + */ + useEffect(() => { + if (!document) { + return; + } + + const newPages: Page[] = []; + + const getPages = async () => { + for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber++) { + const page = await document.getPage(pageNumber); + const textContent = await page.getTextContent(); + + const textLines: TextItem[] = []; + + let concatenatedText = ""; + const itemCharacterStartIndices: number[] = []; + + for (const item of textContent.items) { + if ("str" in item) { + itemCharacterStartIndices.push(concatenatedText.length); + + /** + * This assumes that the text on one line and the text on the next should be separated by a space. + * This may not be the case if one line ends with an em/en dash or other potentially 'joining' characters, + * but we can't be sure that such characters are intended to imply joining either. + */ + concatenatedText += `${item.str.trim()} `; + textLines.push(item); + } + } + + newPages.push({ + concatenatedText, + textLines, + lineCharacterStartIndices: itemCharacterStartIndices, + pageNumber, + }); + } + + setPages(newPages); + }; + + void getPages(); + }, [document]); + + return useCallback( + (searchString: string, ignoreCase: boolean) => + findStringInPages({ pages, searchString, ignoreCase }), + [pages], + ); +}; From 1fe23717a6ddb38c04169a1cb973696ef75f8902 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:44:05 +0200 Subject: [PATCH 04/45] Use the shared search bar in the DOCX preview The DOCX preview now searches with `FilePreviewSearch`, like the PDF preview, instead of opening folio's own find dialog. `FolioDocument` finds the hits in folio's block snapshot and shows each one with `showInDocument`, which selects the exact range and scrolls to it. `keyboardShortcuts="none"` keeps folio's dialog closed. `@stll/folio-core` becomes a direct dependency, at the version `@stll/folio-react` uses, because the frontend now relies on its `ai-edits` types. --- apps/hash-frontend/package.json | 1 + .../src/pages/shared/docx-preview.tsx | 34 +++++++++-- .../shared/docx-preview/folio-document.tsx | 61 ++++++++++++++++--- .../stll-folio-core-ai-edits.d.ts | 7 +++ .../tests/features/file-preview.spec.ts | 6 +- yarn.lock | 3 +- 6 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-core-ai-edits.d.ts diff --git a/apps/hash-frontend/package.json b/apps/hash-frontend/package.json index be1a559ebac..4e5e97ea13d 100644 --- a/apps/hash-frontend/package.json +++ b/apps/hash-frontend/package.json @@ -69,6 +69,7 @@ "@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.55.0", "@stll/folio-react": "0.25.0", "@svgr/webpack": "8.1.0", "@tanstack/react-table": "8.21.3", diff --git a/apps/hash-frontend/src/pages/shared/docx-preview.tsx b/apps/hash-frontend/src/pages/shared/docx-preview.tsx index ac744708d07..9a44668c2bf 100644 --- a/apps/hash-frontend/src/pages/shared/docx-preview.tsx +++ b/apps/hash-frontend/src/pages/shared/docx-preview.tsx @@ -13,6 +13,7 @@ import { import { FolioDocument } from "./docx-preview/folio-document"; import { fetchPresignedFileUrl } from "./fetch-presigned-file-url"; +import { FilePreviewSearch } from "./file-preview-search"; import { GrayToBlueIconButton } from "./gray-to-blue-icon-button"; import type { @@ -40,6 +41,10 @@ export const DocxPreview = ({ total: 0, }); const [zoom, setZoom] = useState(1); + const [searchHitCount, setSearchHitCount] = useState(0); + const [selectedSearchHit, setSelectedSearchHit] = useState( + null, + ); const controlsRef = useRef(null); @@ -95,12 +100,19 @@ export const DocxPreview = ({ }; }, [url, handleError]); - useEffect(() => { - if (showSearch) { - controlsRef.current?.openSearch(); - setShowSearch(false); + const selectSearchHit = (index: number | null) => { + setSelectedSearchHit(index); + + if (index !== null) { + controlsRef.current?.showHit(index); } - }, [showSearch, setShowSearch]); + }; + + const search = (text: string, ignoreCase: boolean) => { + const hitCount = controlsRef.current?.search(text, ignoreCase) ?? 0; + setSearchHitCount(hitCount); + selectSearchHit(hitCount > 0 ? 0 : null); + }; const height = fullScreenHandle.active ? viewportHeight @@ -138,6 +150,18 @@ export const DocxPreview = ({
)} + {!failed && ( + + setShowSearch(false)} + onSearch={search} + onSelectHit={selectSearchHit} + selectedIndex={selectedSearchHit} + showSearch={showSearch} + total={searchHitCount} + /> + + )} {pages.total > 0 && !failed && ( <> void; zoomBy: (step: number) => void; - openSearch: () => 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 = { current: number; total: number }; +type SearchHit = Parameters[0]; + type FolioDocumentProps = { controlsRef: Ref; data: ArrayBuffer; @@ -41,6 +46,10 @@ export const FolioDocument = ({ }: FolioDocumentProps) => { const editorRef = useRef(null); const reportedPages = useRef({ current: 0, total: 0 }); + const searchResult = useRef<{ + hits: SearchHit[]; + snapshot: FolioAIEditSnapshot | null; + }>({ hits: [], snapshot: null }); const reportPages = useCallback(() => { const editor = editorRef.current; @@ -81,10 +90,46 @@ export const FolioDocument = ({ editor.setZoom(editor.getZoom() + step); onZoomChange(editor.getZoom()); }, - openSearch: () => { - /** Find highlights a hit only once folio's editor view exists. */ - editorRef.current?.getEditorRef()?.ensureView({ focus: false }); - editorRef.current?.openFind(); + search: (text, ignoreCase) => { + const editor = editorRef.current; + if (!editor || !text) { + searchResult.current = { hits: [], snapshot: null }; + return 0; + } + + /** folio creates its editor view lazily and has no snapshot before it. */ + editor.ensureEditorView({ focus: false }); + const snapshot = editor.createAIEditSnapshot(); + const pattern = new RegExp(escapeRegExp(text), ignoreCase ? "gi" : "g"); + + const hits = (snapshot?.blocks ?? []) + .filter((block) => block.kind !== "diagnostic") + .flatMap((block) => + [...block.text.matchAll(pattern)].map((match): SearchHit => { + const startOffset = match.index; + const endOffset = startOffset + match[0].length; + return { + type: "textRange", + story: "main", + blockId: block.id, + startOffset, + endOffset, + selectedTextHash: hashFolioAIBlockText( + block.text.slice(startOffset, endOffset), + ), + }; + }), + ); + + searchResult.current = { hits, snapshot }; + return hits.length; + }, + showHit: (index) => { + const { hits, snapshot } = searchResult.current; + const hit = hits[index]; + if (hit) { + editorRef.current?.showInDocument(hit, snapshot ?? undefined); + } }, }), [onZoomChange], @@ -96,7 +141,7 @@ export const FolioDocument = ({ ref={editorRef} autoOpenReviewSidebar={false} documentBuffer={data} - keyboardShortcuts="editor" + keyboardShortcuts="none" loadingIndicator={failed ? null : } onError={onError} onScrollTopChange={reportPages} diff --git a/apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-core-ai-edits.d.ts b/apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-core-ai-edits.d.ts new file mode 100644 index 00000000000..50f5bed294d --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-core-ai-edits.d.ts @@ -0,0 +1,7 @@ +/** + * `@stll/folio-react`'s types import this subpath, which the frontend's + * `moduleResolution: "node"` cannot find because it ignores package `exports`. + */ +declare module "@stll/folio-core/ai-edits" { + export * from "@stll/folio-core/dist/ai-edits/index"; +} diff --git a/tests/hash-playwright/tests/features/file-preview.spec.ts b/tests/hash-playwright/tests/features/file-preview.spec.ts index f5351a7a5bc..2effbdcc1f1 100644 --- a/tests/hash-playwright/tests/features/file-preview.spec.ts +++ b/tests/hash-playwright/tests/features/file-preview.spec.ts @@ -149,11 +149,13 @@ test("a DOCX file entity shows a preview that can be paged, zoomed and searched" await page.getByRole("button", { name: "Show search" }).click(); await page - .getByPlaceholder("Search document...") + .getByPlaceholder("Search the document...") .fill(testDocumentSearchWord); + await expect(page.getByText("1 of 2", { exact: true })).toBeVisible(); await expect(page.locator("[data-folio-selection-rect]")).toBeVisible(); - await page.getByPlaceholder("Search document...").press("Enter"); + await page.getByRole("button", { name: "Next result" }).click(); + await expect(page.getByText("2 of 2", { exact: true })).toBeVisible(); await expect(docxPage(thirdPage.heading)).toBeInViewport(); await expect(page.locator("[data-folio-selection-rect]")).toBeVisible(); }); diff --git a/yarn.lock b/yarn.lock index 53e5160cc94..927748f6f5f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -729,6 +729,7 @@ __metadata: "@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": "npm:3.0.0" "@sigma/node-image": "npm:3.0.0" + "@stll/folio-core": "npm:0.55.0" "@stll/folio-react": "npm:0.25.0" "@svgr/webpack": "npm:8.1.0" "@tanstack/react-table": "npm:8.21.3" @@ -16701,7 +16702,7 @@ __metadata: languageName: node linkType: hard -"@stll/folio-core@npm:^0.55.0": +"@stll/folio-core@npm:0.55.0, @stll/folio-core@npm:^0.55.0": version: 0.55.0 resolution: "@stll/folio-core@npm:0.55.0" dependencies: From ec19459a1f10d0770f74361c4b19eba2f1d0d655 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:54:48 +0200 Subject: [PATCH 05/45] Keep HASH's ProseMirror copies when deduplicating the lockfile folio brings prosemirror-state 1.4.4 and prosemirror-transform 1.12.2. `yarn dedupe --strategy highest` then moves the `^1.0.0` ranges that HASH's prosemirror-view, -keymap and -commands use onto them, while HASH's own code stays on prosemirror-state 1.4.3. Each of those packages would get its own nested copy, and HASH's editor would mix copies. Pin those ranges to the versions they resolve to today, as the root `resolutions` already do for prosemirror-model and prosemirror-view, and dedupe the rest (reselect, valibot, @floating-ui/react-dom). The webpack rule that keeps folio on its own ProseMirror copies now fails the build if one of those copies is the frontend's own, because aliasing its imports would change the frontend's editor too. --- apps/hash-frontend/next.config.js | 20 ++++++++++++---- yarn.lock | 38 +++++-------------------------- 2 files changed, 22 insertions(+), 36 deletions(-) diff --git a/apps/hash-frontend/next.config.js b/apps/hash-frontend/next.config.js index 52d77e619af..ea6a898939f 100644 --- a/apps/hash-frontend/next.config.js +++ b/apps/hash-frontend/next.config.js @@ -37,10 +37,8 @@ const findInstalledPackage = (name, fromDirectory) => { * copies folio-core uses. HASH's editor keeps its own. */ const folioSingleProseMirrorRule = () => { - const folioReact = findInstalledPackage( - "@stll/folio-react", - path.dirname(fileURLToPath(import.meta.url)), - ); + const frontend = path.dirname(fileURLToPath(import.meta.url)); + const folioReact = findInstalledPackage("@stll/folio-react", frontend); const folioCore = findInstalledPackage("@stll/folio-core", folioReact); const sharedPackages = new Set(); @@ -62,6 +60,20 @@ const folioSingleProseMirrorRule = () => { ]), ); + const { dependencies: frontendDependencies } = JSON.parse( + fs.readFileSync(path.join(frontend, "package.json"), "utf8"), + ); + for (const [name, directory] of Object.entries(alias)) { + if ( + name in frontendDependencies && + directory === findInstalledPackage(name, frontend) + ) { + throw new Error( + `folio and the frontend resolve ${name} to the same copy at ${directory}, so folio's ProseMirror imports cannot be kept apart from the frontend's`, + ); + } + } + return { include: [folioReact, folioCore, ...Object.values(alias)], resolve: { alias }, diff --git a/yarn.lock b/yarn.lock index 927748f6f5f..a6180509722 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4101,14 +4101,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.16.3, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.23.8, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.5, @babel/runtime@npm:^7.26.0, @babel/runtime@npm:^7.26.10, @babel/runtime@npm:^7.28.4, @babel/runtime@npm:^7.28.6, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": - version: 7.29.7 - resolution: "@babel/runtime@npm:7.29.7" - checksum: 10c0/ca11572f7146b21e0bde6a9ed4bb6a89eafbee5f0944c7eb54d0d8a2dac962c33638a1d611e14faa71dfbb92b4b5f9236232208568a6b7d5c6f3f39ddb91771e - languageName: node - linkType: hard - -"@babel/runtime@npm:^7.29.7": +"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.16.3, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.23.8, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.5, @babel/runtime@npm:^7.26.0, @babel/runtime@npm:^7.26.10, @babel/runtime@npm:^7.28.4, @babel/runtime@npm:^7.28.6, @babel/runtime@npm:^7.29.7, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": version: 7.29.10 resolution: "@babel/runtime@npm:7.29.10" checksum: 10c0/32f975da8e9c2cae6b33e1a477f8fe8bbff6722c982b5ce35f99affaf3a7a1b612c8272b43afa0e51d3000928d6f3ba38f9296301115db5640264907c027b0cd @@ -6476,7 +6469,7 @@ __metadata: languageName: node linkType: hard -"@floating-ui/dom@npm:^1.0.0, @floating-ui/dom@npm:^1.7.5, @floating-ui/dom@npm:^1.7.6, @floating-ui/dom@npm:^1.8.0": +"@floating-ui/dom@npm:^1.7.5, @floating-ui/dom@npm:^1.7.6, @floating-ui/dom@npm:^1.8.0": version: 1.8.0 resolution: "@floating-ui/dom@npm:1.8.0" dependencies: @@ -6486,19 +6479,7 @@ __metadata: languageName: node linkType: hard -"@floating-ui/react-dom@npm:^2.0.0": - version: 2.1.2 - resolution: "@floating-ui/react-dom@npm:2.1.2" - dependencies: - "@floating-ui/dom": "npm:^1.0.0" - peerDependencies: - react: ">=16.8.0" - react-dom: ">=16.8.0" - checksum: 10c0/e855131c74e68cab505f7f44f92cd4e2efab1c125796db3116c54c0859323adae4bf697bf292ee83ac77b9335a41ad67852193d7aeace90aa2e1c4a640cafa60 - languageName: node - linkType: hard - -"@floating-ui/react-dom@npm:^2.1.9": +"@floating-ui/react-dom@npm:^2.0.0, @floating-ui/react-dom@npm:^2.1.9": version: 2.1.9 resolution: "@floating-ui/react-dom@npm:2.1.9" dependencies: @@ -40297,14 +40278,7 @@ __metadata: languageName: node linkType: hard -"reselect@npm:^5.1.0": - version: 5.2.0 - resolution: "reselect@npm:5.2.0" - checksum: 10c0/d0a8497e404b25a769fe792eacae88b9b576c30870450cd243d76ec9427d91a59c4a2cc00d824d283448b444c4c698a8f961d771c8ae39c759313afb31950e2e - languageName: node - linkType: hard - -"reselect@npm:^5.2.0": +"reselect@npm:^5.1.0, reselect@npm:^5.2.0": version: 5.3.0 resolution: "reselect@npm:5.3.0" checksum: 10c0/c79728e4f315152bfb2d82456d35075b705392d9e72c485d7d9b1061bd80af628416216d80f02a42f24dbb24d738aec7fec45806930aaa1d17c32e9875fa1bd6 @@ -44895,7 +44869,7 @@ __metadata: languageName: node linkType: hard -"valibot@npm:1.4.2, valibot@npm:^1.1.0": +"valibot@npm:1.4.2": version: 1.4.2 resolution: "valibot@npm:1.4.2" peerDependencies: @@ -44907,7 +44881,7 @@ __metadata: languageName: node linkType: hard -"valibot@npm:1.5.0": +"valibot@npm:1.5.0, valibot@npm:^1.1.0": version: 1.5.0 resolution: "valibot@npm:1.5.0" peerDependencies: From e063678c708a4a3161b779e5fbb426cbe9d59199 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:54:48 +0200 Subject: [PATCH 06/45] Scope the corrupt .docx test's spinner check to the page's main content The sidebar's entity list can still be loading when the check runs, and its spinners are not the preview's. --- tests/hash-playwright/tests/features/file-preview.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/hash-playwright/tests/features/file-preview.spec.ts b/tests/hash-playwright/tests/features/file-preview.spec.ts index 2effbdcc1f1..bbcee4f7608 100644 --- a/tests/hash-playwright/tests/features/file-preview.spec.ts +++ b/tests/hash-playwright/tests/features/file-preview.spec.ts @@ -281,7 +281,7 @@ test("a corrupt .docx file stops the loading indicator and keeps the download", }); await expect(page.getByRole("button", { name: "Download" })).toBeVisible(); - await expect(page.getByRole("progressbar")).toHaveCount(0, { + await expect(page.getByRole("main").getByRole("progressbar")).toHaveCount(0, { timeout: 15_000, }); }); From bfc3ba56b79db622a2ea4404baa950127e10e783 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:08:13 +0200 Subject: [PATCH 07/45] Split the DOCX preview test into one test per behaviour The DOCX tests that share a three-page document now upload it in a `beforeEach`, as the PDF tests do, so paging, zoom, search, read-only and copy each fail on their own. --- .../tests/features/file-preview.spec.ts | 157 +++++++++--------- 1 file changed, 77 insertions(+), 80 deletions(-) diff --git a/tests/hash-playwright/tests/features/file-preview.spec.ts b/tests/hash-playwright/tests/features/file-preview.spec.ts index bbcee4f7608..65068a3ce1a 100644 --- a/tests/hash-playwright/tests/features/file-preview.spec.ts +++ b/tests/hash-playwright/tests/features/file-preview.spec.ts @@ -19,6 +19,9 @@ const [firstPage, secondPage, thirdPage] = testDocumentPages; const getPdfPage = (page: Page) => page.locator(".react-pdf-page"); +const getDocxPage = (page: Page, heading: string) => + page.locator(".layout-page", { hasText: heading }); + const docxMimeType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; @@ -118,99 +121,93 @@ test.describe("PDF preview", () => { }); }); -test("a DOCX file entity shows a preview that can be paged, zoomed and searched", async ({ - page, -}) => { - await uploadAndOpenFile(page, { - contents: generateTestDocx(), - mimeType: docxMimeType, - name: `file-preview-${Date.now()}.docx`, +test.describe("DOCX preview", () => { + test.beforeEach(async ({ page }) => { + await uploadAndOpenFile(page, { + contents: generateTestDocx(), + mimeType: docxMimeType, + name: `file-preview-${Date.now()}.docx`, + }); + + await expect(getDocxPage(page, firstPage.heading)).toBeVisible({ + timeout: 15_000, + }); }); - const docxPage = (heading: string) => - page.locator(".layout-page", { hasText: heading }); - - await expect(docxPage(firstPage.heading)).toBeVisible({ timeout: 15_000 }); - - await page.getByRole("button", { name: "Next page" }).click(); - await expect(docxPage(secondPage.heading)).toBeInViewport(); - - await page.getByRole("button", { name: "Previous page" }).click(); - await expect(docxPage(firstPage.heading)).toBeInViewport(); - - const widthBeforeZoom = - (await docxPage(firstPage.heading).boundingBox())?.width ?? 0; - await page.getByRole("button", { name: "Zoom in" }).click(); - await expect - .poll( - async () => (await docxPage(firstPage.heading).boundingBox())?.width ?? 0, - ) - .toBeGreaterThan(widthBeforeZoom); - - await page.getByRole("button", { name: "Show search" }).click(); - await page - .getByPlaceholder("Search the document...") - .fill(testDocumentSearchWord); - await expect(page.getByText("1 of 2", { exact: true })).toBeVisible(); - await expect(page.locator("[data-folio-selection-rect]")).toBeVisible(); - - await page.getByRole("button", { name: "Next result" }).click(); - await expect(page.getByText("2 of 2", { exact: true })).toBeVisible(); - await expect(docxPage(thirdPage.heading)).toBeInViewport(); - await expect(page.locator("[data-folio-selection-rect]")).toBeVisible(); -}); + test("the next and previous page buttons move between pages", async ({ + page, + }) => { + await page.getByRole("button", { name: "Next page" }).click(); + await expect(getDocxPage(page, secondPage.heading)).toBeInViewport(); -test("the DOCX preview cannot be edited", async ({ page }) => { - await uploadAndOpenFile(page, { - contents: generateTestDocx(), - mimeType: docxMimeType, - name: `file-preview-read-only-${Date.now()}.docx`, + await page.getByRole("button", { name: "Previous page" }).click(); + await expect(getDocxPage(page, firstPage.heading)).toBeInViewport(); }); - const firstDocxPage = page.locator(".layout-page", { - hasText: firstPage.heading, + test("zooming in makes the page wider", async ({ page }) => { + const docxPage = getDocxPage(page, firstPage.heading); + + const widthBeforeZoom = (await docxPage.boundingBox())?.width ?? 0; + await page.getByRole("button", { name: "Zoom in" }).click(); + await expect + .poll(async () => (await docxPage.boundingBox())?.width ?? 0) + .toBeGreaterThan(widthBeforeZoom); }); - await expect(firstDocxPage).toBeVisible({ timeout: 15_000 }); - const textBefore = (await firstDocxPage.textContent()) ?? ""; - await firstDocxPage.getByText(firstPage.heading, { exact: true }).click(); - await page.keyboard.type("typed text"); - await page.keyboard.press("Enter"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Delete"); + test("search selects a word and the next result moves to the next page holding it", async ({ + page, + }) => { + await page.getByRole("button", { name: "Show search" }).click(); + await page + .getByPlaceholder("Search the document...") + .fill(testDocumentSearchWord); + await expect(page.getByText("1 of 2", { exact: true })).toBeVisible(); + await expect(page.locator("[data-folio-selection-rect]")).toBeVisible(); - await expect(firstDocxPage).toHaveText(textBefore); -}); + await page.getByRole("button", { name: "Next result" }).click(); + await expect(page.getByText("2 of 2", { exact: true })).toBeVisible(); + await expect(getDocxPage(page, thirdPage.heading)).toBeInViewport(); + await expect(page.locator("[data-folio-selection-rect]")).toBeVisible(); + }); -test("text selected in the DOCX preview copies exactly", async ({ page }) => { - await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + test("the preview cannot be edited", async ({ page }) => { + const docxPage = getDocxPage(page, firstPage.heading); + const textBefore = (await docxPage.textContent()) ?? ""; - await uploadAndOpenFile(page, { - contents: generateTestDocx(), - mimeType: docxMimeType, - name: `file-preview-copy-${Date.now()}.docx`, + await docxPage.getByText(firstPage.heading, { exact: true }).click(); + await page.keyboard.type("typed text"); + await page.keyboard.press("Enter"); + await page.keyboard.press("Backspace"); + await page.keyboard.press("Delete"); + + await expect(docxPage).toHaveText(textBefore); }); - const heading = page - .locator(".layout-page") - .first() - .getByText(firstPage.heading, { exact: true }); - await expect(heading).toBeVisible({ timeout: 15_000 }); + test("text selected on a page copies exactly", async ({ page }) => { + await page + .context() + .grantPermissions(["clipboard-read", "clipboard-write"]); - const box = await heading.boundingBox(); - if (!box) { - throw new Error("The heading on page 1 has no bounding box"); - } - const lineY = box.y + box.height / 2; - await page.mouse.move(box.x + 1, lineY); - await page.mouse.down(); - await page.mouse.move(box.x + box.width - 1, lineY, { steps: 5 }); - await page.mouse.up(); - await page.keyboard.press("ControlOrMeta+C"); - - await expect - .poll(() => page.evaluate(() => navigator.clipboard.readText())) - .toBe(firstPage.heading); + const heading = page + .locator(".layout-page") + .first() + .getByText(firstPage.heading, { exact: true }); + + const box = await heading.boundingBox(); + if (!box) { + throw new Error("The heading on page 1 has no bounding box"); + } + const lineY = box.y + box.height / 2; + await page.mouse.move(box.x + 1, lineY); + await page.mouse.down(); + await page.mouse.move(box.x + box.width - 1, lineY, { steps: 5 }); + await page.mouse.up(); + await page.keyboard.press("ControlOrMeta+C"); + + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(firstPage.heading); + }); }); test("the DOCX preview runs and loads nothing from a hostile document", async ({ From 181dc00c8efb30bc1ff02143e251039fd327b643 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:12:10 +0200 Subject: [PATCH 08/45] Update `@stll/folio-react` to 0.26.0 folio-core moves to 0.58.5, the version folio-react 0.26.0 uses. Both, and `@stll/docx-core` 0.36.2, are younger than the 7-day age gate, so `npmPreapprovedPackages` admits exactly these versions. folio's commits up to 0.26.0 were read through before the update. --- apps/hash-frontend/package.json | 4 ++-- yarn.lock | 42 ++++++++++++++++----------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/apps/hash-frontend/package.json b/apps/hash-frontend/package.json index 4e5e97ea13d..96589caeec8 100644 --- a/apps/hash-frontend/package.json +++ b/apps/hash-frontend/package.json @@ -69,8 +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.55.0", - "@stll/folio-react": "0.25.0", + "@stll/folio-core": "0.58.5", + "@stll/folio-react": "0.26.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", diff --git a/yarn.lock b/yarn.lock index a6180509722..52fde741de9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -729,8 +729,8 @@ __metadata: "@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": "npm:3.0.0" "@sigma/node-image": "npm:3.0.0" - "@stll/folio-core": "npm:0.55.0" - "@stll/folio-react": "npm:0.25.0" + "@stll/folio-core": "npm:0.58.5" + "@stll/folio-react": "npm:0.26.0" "@svgr/webpack": "npm:8.1.0" "@tanstack/react-table": "npm:8.21.3" "@testing-library/dom": "npm:10.4.1" @@ -16658,9 +16658,9 @@ __metadata: languageName: node linkType: hard -"@stll/docx-core@npm:^0.31.0": - version: 0.31.0 - resolution: "@stll/docx-core@npm:0.31.0" +"@stll/docx-core@npm:^0.36.2": + version: 0.36.2 + resolution: "@stll/docx-core@npm:0.36.2" dependencies: "@nodable/entities": "npm:^3.0.0" "@stll/docx-utils": "npm:^0.1.0" @@ -16668,7 +16668,7 @@ __metadata: fast-xml-parser: "npm:^5.10.1" jszip: "npm:3.10.2" marked: "npm:^18.0.5" - checksum: 10c0/b3f1f719eb8ce849e1e9f073360c0661dee7bd697a2fb9ecbb66195e749dfb64809634cf49f5b94b1415f10f54d642ccaba1d25adb0aca394eeb05317d9ce5ad + checksum: 10c0/9135fb0606233bf2b0c8243712ffffa359a5a0ab9366a551872788b5f16e5f8b1dd1606a553a023d33a69eaaadc89a69893f3fdee8dfef1f4a4d30412b615193 languageName: node linkType: hard @@ -16683,16 +16683,16 @@ __metadata: languageName: node linkType: hard -"@stll/folio-core@npm:0.55.0, @stll/folio-core@npm:^0.55.0": - version: 0.55.0 - resolution: "@stll/folio-core@npm:0.55.0" +"@stll/folio-core@npm:0.58.5, @stll/folio-core@npm:^0.58.5": + version: 0.58.5 + resolution: "@stll/folio-core@npm:0.58.5" dependencies: - "@stll/docx-core": "npm:^0.31.0" + "@stll/docx-core": "npm:^0.36.2" "@stll/docx-utils": "npm:^0.1.0" "@stll/template-conditions": "npm:^0.6.2" better-result: "npm:3.0.1" csstype: "npm:^3.1.3" - dompurify: "npm:^3.4.13" + dompurify: "npm:^3.4.16" fast-xml-parser: "npm:^5.10.1" hyphen: "npm:1.14.1" jszip: "npm:3.10.2" @@ -16706,18 +16706,18 @@ __metadata: prosemirror-state: "npm:^1.4.4" prosemirror-tables: "npm:^1.8.5" prosemirror-transform: "npm:^1.12.0" - prosemirror-view: "npm:^1.41.8" + prosemirror-view: "npm:^1.42.6" utif2: "npm:^4.1.0" valibot: "npm:1.5.0" y-prosemirror: "npm:^1.3.7" yjs: "npm:^13.6.31" - checksum: 10c0/3ea55809939464f048632ce5079ef391bbd48308ff9db181fd61e15f2923a22fbf8704cbe6ff56b41a0b17b188ce49f506cb7b7a1a0991094254e9b8b2b8e2ed + checksum: 10c0/84ccd412b0bb316c728118f51c29ee741af54ad18f19f24264f8b9f50468a7ac67764ba838e6e013d8c9ee362c727aeca1f9b253484c898b6ebf842f983fe35d languageName: node linkType: hard -"@stll/folio-react@npm:0.25.0": - version: 0.25.0 - resolution: "@stll/folio-react@npm:0.25.0" +"@stll/folio-react@npm:0.26.0": + version: 0.26.0 + resolution: "@stll/folio-react@npm:0.26.0" dependencies: "@base-ui/react": "npm:1.8.0" "@fontsource/arimo": "npm:^5.2.8" @@ -16728,21 +16728,21 @@ __metadata: "@fontsource/noto-sans-arabic": "npm:^5.2.7" "@fontsource/source-sans-3": "npm:^5.2.9" "@fontsource/tinos": "npm:^5.2.7" - "@stll/folio-core": "npm:^0.55.0" + "@stll/folio-core": "npm:^0.58.5" better-result: "npm:3.0.1" lucide-react: "npm:1.24.0" prosemirror-history: "npm:^1.5.0" prosemirror-model: "npm:^1.25.9" prosemirror-state: "npm:^1.4.4" prosemirror-tables: "npm:^1.8.5" - prosemirror-view: "npm:^1.41.8" + prosemirror-view: "npm:^1.42.6" react-compiler-runtime: "npm:^1.0.0" yjs: "npm:^13.6.31" peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 + react: ^19.2.0 + react-dom: ^19.2.0 use-intl: ">=4.0.0" - checksum: 10c0/4153b0574d6fb854aa2246c305cf0e2f63c5ea543acfedf678b06622b45ab4a320a9bd2b0995bc47dd254c753d8727d8b15c83c42639bbe100c25ec5ca755585 + checksum: 10c0/7447bc22a1286c519ed75ebc2511c8e5952e1f74704e95dc7be47ab6d0ff092a79be3e88cb1cd0269ac5b9d7177a03b8eab3f7dfa8b9a93f71fa85bfaaa5dded languageName: node linkType: hard From 5c962389fe96a73bf5b450cb3513a30b760f62e8 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:12:10 +0200 Subject: [PATCH 09/45] Take the DOCX preview's message types from folio The `@stll/folio-react/messages` declaration re-exports folio-core's own types instead of a hand-written signature, so it returns `FolioMessages` and follows folio when it changes. --- .../shared/docx-preview/stll-folio-react-messages.d.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-react-messages.d.ts b/apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-react-messages.d.ts index e164febd086..7d541303ba4 100644 --- a/apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-react-messages.d.ts +++ b/apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-react-messages.d.ts @@ -1,10 +1,8 @@ /** * The frontend's `moduleResolution: "node"` ignores package `exports`, so * TypeScript cannot find this subpath's own types. Webpack resolves it at - * runtime. Matches `@stll/folio-core/dist/i18n/messages.d.ts`. + * runtime. The subpath re-exports `@stll/folio-core`'s messages. */ declare module "@stll/folio-react/messages" { - import type { AbstractIntlMessages } from "use-intl"; - - export const getFolioMessages: (locale: string) => AbstractIntlMessages; + export * from "@stll/folio-core/dist/i18n/messages"; } From 1fb1053f46d8d2811ab1b53ec1fec0af11ff366d Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:15:56 +0200 Subject: [PATCH 10/45] Clear the DOCX preview's previous hit when a search finds nothing `showHit` marks a hit by selecting it, and a later search with no hits left that selection on screen. The search now collapses it. --- .../pages/shared/docx-preview/folio-document.tsx | 15 ++++++++++++++- .../tests/features/file-preview.spec.ts | 12 ++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx b/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx index 890a62bc48e..5613d4dafca 100644 --- a/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx +++ b/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx @@ -78,6 +78,15 @@ export const FolioDocument = ({ return () => clearInterval(interval); }, [reportPages]); + /** Collapses the selection `showHit` made, so no earlier hit stays marked. */ + const clearShownHit = useCallback(() => { + const pagedEditor = editorRef.current?.getEditorRef(); + const selection = pagedEditor?.getState()?.selection; + if (pagedEditor && selection && !selection.empty) { + pagedEditor.setSelection(selection.head); + } + }, []); + useImperativeHandle( controlsRef, () => ({ @@ -94,6 +103,7 @@ export const FolioDocument = ({ const editor = editorRef.current; if (!editor || !text) { searchResult.current = { hits: [], snapshot: null }; + clearShownHit(); return 0; } @@ -122,6 +132,9 @@ export const FolioDocument = ({ ); searchResult.current = { hits, snapshot }; + if (hits.length === 0) { + clearShownHit(); + } return hits.length; }, showHit: (index) => { @@ -132,7 +145,7 @@ export const FolioDocument = ({ } }, }), - [onZoomChange], + [clearShownHit, onZoomChange], ); return ( diff --git a/tests/hash-playwright/tests/features/file-preview.spec.ts b/tests/hash-playwright/tests/features/file-preview.spec.ts index 65068a3ce1a..7a0d4bd2d4c 100644 --- a/tests/hash-playwright/tests/features/file-preview.spec.ts +++ b/tests/hash-playwright/tests/features/file-preview.spec.ts @@ -170,6 +170,18 @@ test.describe("DOCX preview", () => { await expect(page.locator("[data-folio-selection-rect]")).toBeVisible(); }); + test("a search without results clears the previous hit", async ({ page }) => { + await page.getByRole("button", { name: "Show search" }).click(); + const searchInput = page.getByPlaceholder("Search the document..."); + + await searchInput.fill(testDocumentSearchWord); + await expect(page.locator("[data-folio-selection-rect]")).toBeVisible(); + + await searchInput.fill("nonexistentword"); + await expect(page.getByText("0 of 0", { exact: true })).toBeVisible(); + await expect(page.locator("[data-folio-selection-rect]")).toHaveCount(0); + }); + test("the preview cannot be edited", async ({ page }) => { const docxPage = getDocxPage(page, firstPage.heading); const textBefore = (await docxPage.textContent()) ?? ""; From 25faa2ff01123bad00d328066c2ec022456053d9 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:54:50 +0200 Subject: [PATCH 11/45] Show the file's icon when the DOCX preview cannot draw a document An error before folio lays out any page, as with a corrupt file or one whose bookmarks folio rejects, now falls back to the icon view other files get. An error after pages are showing is reported to Sentry and leaves them in place. Download stays available either way. --- .../src/pages/shared/docx-preview.tsx | 54 +++++++++++-------- .../shared/docx-preview/folio-document.tsx | 36 ++++++++----- .../entity-editor/file-preview-section.tsx | 15 +++++- .../tests/features/file-preview.spec.ts | 33 ++++++++---- .../tests/shared/generate-test-docx.ts | 13 +++++ 5 files changed, 102 insertions(+), 49 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/docx-preview.tsx b/apps/hash-frontend/src/pages/shared/docx-preview.tsx index 9a44668c2bf..80aa1d5ee18 100644 --- a/apps/hash-frontend/src/pages/shared/docx-preview.tsx +++ b/apps/hash-frontend/src/pages/shared/docx-preview.tsx @@ -24,18 +24,23 @@ import type { 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(null); - const [failed, setFailed] = useState(false); const [pages, setPages] = useState({ current: 1, total: 0, @@ -63,16 +68,24 @@ export const DocxPreview = ({ return () => window.removeEventListener("resize", handleResize); }, []); - const handleError = useCallback((error: unknown) => { - setFailed(true); - Sentry.captureException(error); - }, []); + /** + * 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 = useCallback( + (error: unknown) => { + Sentry.captureException(error); + if (!controlsRef.current?.hasPages()) { + onUnavailable(); + } + }, + [onUnavailable], + ); useEffect(() => { let cancelled = false; setData(null); - setFailed(false); fetchPresignedFileUrl(url) .then((presignedUrl) => fetch(presignedUrl, { credentials: "omit" })) @@ -135,34 +148,31 @@ export const DocxPreview = ({ })} > {data ? ( - setFailed(true)}> + - ) : failed ? null : ( + ) : ( )} - {!failed && ( - - setShowSearch(false)} - onSearch={search} - onSelectHit={selectSearchHit} - selectedIndex={selectedSearchHit} - showSearch={showSearch} - total={searchHitCount} - /> - - )} - {pages.total > 0 && !failed && ( + + setShowSearch(false)} + onSearch={search} + onSelectHit={selectSearchHit} + selectedIndex={selectedSearchHit} + showSearch={showSearch} + total={searchHitCount} + /> + + {pages.total > 0 && ( <> boolean; goToPage: (pageNumber: number) => void; zoomBy: (step: number) => void; /** Finds every hit of `text` and returns how many there are. */ @@ -28,7 +30,6 @@ type SearchHit = Parameters[0]; type FolioDocumentProps = { controlsRef: Ref; data: ArrayBuffer; - failed: boolean; onError: (error: Error) => void; onPagesChange: (pages: DocxDocumentPages) => void; onZoomChange: (zoom: number) => void; @@ -39,12 +40,12 @@ const folioMessages = getFolioMessages("en"); export const FolioDocument = ({ controlsRef, data, - failed, onError, onPagesChange, onZoomChange, }: FolioDocumentProps) => { const editorRef = useRef(null); + const containerRef = useRef(null); const reportedPages = useRef({ current: 0, total: 0 }); const searchResult = useRef<{ hits: SearchHit[]; @@ -90,6 +91,11 @@ export const FolioDocument = ({ 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; @@ -150,17 +156,19 @@ export const FolioDocument = ({ return ( - } - onError={onError} - onScrollTopChange={reportPages} - readOnly - style={{ height: "100%" }} - /> + + } + onError={onError} + onScrollTopChange={reportPages} + readOnly + style={{ height: "100%" }} + /> + ); }; diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/file-preview-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/file-preview-section.tsx index ed1b30ec8e8..2afaef8665e 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/file-preview-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/file-preview-section.tsx @@ -6,7 +6,7 @@ import { Typography, } from "@mui/material"; import dynamic from "next/dynamic"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { FullScreen, useFullScreenHandle } from "react-full-screen"; import { extractWebIdFromEntityId } from "@blockprotocol/type-system"; @@ -200,6 +200,14 @@ export const FilePreviewSection = () => { const isFullScreen = fullScreenHandle.active; + /** The file the DOCX preview could not show, which then gets the icon view. */ + const [unavailableDocxUrl, setUnavailableDocxUrl] = useState(); + + const markDocxUnavailable = useCallback( + () => setUnavailableDocxUrl(fileUrl), + [fileUrl], + ); + if (!fileUrl) { return null; } @@ -216,7 +224,9 @@ export const FilePreviewSection = () => { const isPdf = mimeType === "application/pdf" || fileName?.endsWith(".pdf"); const isDocx = - mimeType === docxMimeType || !!fileName?.toLowerCase().endsWith(".docx"); + (mimeType === docxMimeType || + !!fileName?.toLowerCase().endsWith(".docx")) && + unavailableDocxUrl !== fileUrl; return ( { /> ) : isDocx ? ( { await expect(page.locator(".layout-page")).toHaveCount(0); }); -test("a corrupt .docx file stops the loading indicator and keeps the download", async ({ - page, -}) => { - await uploadAndOpenFile(page, { +for (const { description, contents } of [ + { + description: "a corrupt .docx file", contents: Buffer.from("Not a ZIP archive"), - mimeType: docxMimeType, - name: `file-preview-corrupt-${Date.now()}.docx`, - }); + }, + { + description: "a .docx file folio cannot draw", + contents: generateUndrawableTestDocx(), + }, +]) { + test(`${description} falls back to the icon view and keeps the download`, async ({ + page, + }) => { + const name = `file-preview-unavailable-${Date.now()}.docx`; + + await uploadAndOpenFile(page, { contents, mimeType: docxMimeType, name }); - await expect(page.getByRole("button", { name: "Download" })).toBeVisible(); - await expect(page.getByRole("main").getByRole("progressbar")).toHaveCount(0, { - timeout: 15_000, + await expect( + page.getByRole("paragraph").filter({ hasText: name }), + ).toBeVisible({ timeout: 15_000 }); + await expect(page.locator(".layout-page")).toHaveCount(0); + await expect(page.getByRole("button", { name: "Download" })).toBeVisible(); }); -}); +} diff --git a/tests/hash-playwright/tests/shared/generate-test-docx.ts b/tests/hash-playwright/tests/shared/generate-test-docx.ts index a82078ecb17..ddb675c2998 100644 --- a/tests/hash-playwright/tests/shared/generate-test-docx.ts +++ b/tests/hash-playwright/tests/shared/generate-test-docx.ts @@ -134,3 +134,16 @@ export const generateHostileTestDocx = (): Buffer => ], extraContentTypes: ``, }); + +/** + * Builds a DOCX that folio refuses to draw: a bookmark that ends between + * paragraphs without having started. + */ +export const generateUndrawableTestDocx = (): Buffer => + writeDocx({ + body: [ + paragraph("Undrawable document"), + ``, + paragraph("After the broken bookmark"), + ].join(""), + }); From 3e6168dae52e6808ebb3e8eef72974335d55794f Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:07:38 +0200 Subject: [PATCH 12/45] Expect folio's refusal of an undrawable document in the e2e console check The DOCX preview's engine logs a `ProseMirrorDocumentValidationError` when it refuses a document, and the preview then shows the file's icon. The test that opens such a document on purpose failed on that console message. --- tests/hash-playwright/tests/shared/runtime.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/hash-playwright/tests/shared/runtime.ts b/tests/hash-playwright/tests/shared/runtime.ts index 2f348305115..709fcc8463d 100644 --- a/tests/hash-playwright/tests/shared/runtime.ts +++ b/tests/hash-playwright/tests/shared/runtime.ts @@ -18,6 +18,10 @@ const tolerableConsoleMessageMatches: RegExp[] = [ // observability – see notifications-with-links-context.tsx /^Skipping (mention|comment) notification .+ because the target of the following link\(s\) could not be resolved: /, + // The DOCX preview's engine logs why it refuses a document it cannot draw; + // the preview then falls back to the file's icon. + /^ProseMirrorDocumentValidationError: Document conversion produced an invalid ProseMirror document:/, + // You can add temporarily add more RegExps, but please track their removal /No validator provided for shape type bpBlock/, // canvas page warning from TLDraw // Dev-only deprecation warnings via the shared Link component (shared/ui/link.tsx) From fea74d35a21b8195061e3828e61c8732fb6d28c9 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:31:13 +0200 Subject: [PATCH 13/45] Move folio's missing `y-protocols` from `.yarnrc.yml` to the frontend's dependencies --- .yarnrc.yml | 4 ---- apps/hash-frontend/package.json | 1 + yarn.lock | 1 + 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.yarnrc.yml b/.yarnrc.yml index 568acc40ec7..61c0044911e 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -36,7 +36,3 @@ packageExtensions: "@flue/runtime@2.3.0": dependencies: "@standard-schema/spec": "1.1.0" - # `y-prosemirror` imports its peer `y-protocols`, which folio-core does not provide. - "@stll/folio-core@*": - dependencies: - "y-protocols": "1.0.7" diff --git a/apps/hash-frontend/package.json b/apps/hash-frontend/package.json index 96589caeec8..8fb87732d57 100644 --- a/apps/hash-frontend/package.json +++ b/apps/hash-frontend/package.json @@ -149,6 +149,7 @@ "use-intl": "4.14.9", "uuid": "14.0.0", "web-worker": "1.4.1", + "y-protocols": "1.0.7", "zod": "4.4.3" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index 52fde741de9..b7e48e279ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -837,6 +837,7 @@ __metadata: wait-on: "npm:9.0.1" web-worker: "npm:1.4.1" webpack: "npm:5.104.1" + y-protocols: "npm:1.0.7" zod: "npm:4.4.3" languageName: unknown linkType: soft From 7152d9d51fee8fa8da13dfbe3fa8429803576754 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:53:25 +0200 Subject: [PATCH 14/45] Build the test DOCX files with jszip The generator wrote the ZIP headers byte by byte, which a reviewer could only check against the ZIP specification. jszip is already in the lockfile through folio, so the generator now hands it the DOCX parts instead. The parts are unchanged, and the generators return a promise. The page size and margins move into a named constant with a comment on their units. --- tests/hash-playwright/package.json | 3 +- .../tests/features/file-preview.spec.ts | 10 ++- .../tests/shared/generate-test-docx.ts | 73 +++++-------------- yarn.lock | 1 + 4 files changed, 30 insertions(+), 57 deletions(-) diff --git a/tests/hash-playwright/package.json b/tests/hash-playwright/package.json index 3f1cb40bd44..11216538f84 100644 --- a/tests/hash-playwright/package.json +++ b/tests/hash-playwright/package.json @@ -21,7 +21,8 @@ "@playwright/test": "1.58.2", "execa": "9.6.0", "graphql": "16.11.0", - "js-yaml": "4.3.2" + "js-yaml": "4.3.2", + "jszip": "3.10.2" }, "devDependencies": { "@apps/hash-api": "workspace:*", diff --git a/tests/hash-playwright/tests/features/file-preview.spec.ts b/tests/hash-playwright/tests/features/file-preview.spec.ts index 538e0408fec..0b24fc6a309 100644 --- a/tests/hash-playwright/tests/features/file-preview.spec.ts +++ b/tests/hash-playwright/tests/features/file-preview.spec.ts @@ -125,7 +125,7 @@ test.describe("PDF preview", () => { test.describe("DOCX preview", () => { test.beforeEach(async ({ page }) => { await uploadAndOpenFile(page, { - contents: generateTestDocx(), + contents: await generateTestDocx(), mimeType: docxMimeType, name: `file-preview-${Date.now()}.docx`, }); @@ -240,7 +240,7 @@ test("the DOCX preview runs and loads nothing from a hostile document", async ({ }); await uploadAndOpenFile(page, { - contents: generateHostileTestDocx(), + contents: await generateHostileTestDocx(), mimeType: docxMimeType, name: `file-preview-hostile-${Date.now()}.docx`, }); @@ -296,7 +296,11 @@ for (const { description, contents } of [ }) => { const name = `file-preview-unavailable-${Date.now()}.docx`; - await uploadAndOpenFile(page, { contents, mimeType: docxMimeType, name }); + await uploadAndOpenFile(page, { + contents: await contents, + mimeType: docxMimeType, + name, + }); await expect( page.getByRole("paragraph").filter({ hasText: name }), diff --git a/tests/hash-playwright/tests/shared/generate-test-docx.ts b/tests/hash-playwright/tests/shared/generate-test-docx.ts index ddb675c2998..66c441f34ff 100644 --- a/tests/hash-playwright/tests/shared/generate-test-docx.ts +++ b/tests/hash-playwright/tests/shared/generate-test-docx.ts @@ -1,56 +1,15 @@ -import { crc32 } from "node:zlib"; +import JSZip from "jszip"; import { testDocumentPages } from "./test-document"; type ZipEntry = { name: string; contents: string }; -/** - * Writes a ZIP archive whose entries are stored uncompressed, which is all a - * DOCX reader needs. - */ -const writeStoredZip = (entries: ZipEntry[]): Buffer => { - const localParts: Buffer[] = []; - const centralParts: Buffer[] = []; - let offset = 0; - - for (const entry of entries) { - const name = Buffer.from(entry.name, "utf8"); - const data = Buffer.from(entry.contents, "utf8"); - const checksum = crc32(data); - - const localHeader = Buffer.alloc(30); - localHeader.writeUInt32LE(0x04034b50, 0); - localHeader.writeUInt16LE(20, 4); - localHeader.writeUInt32LE(checksum, 14); - localHeader.writeUInt32LE(data.length, 18); - localHeader.writeUInt32LE(data.length, 22); - localHeader.writeUInt16LE(name.length, 26); - - const centralHeader = Buffer.alloc(46); - centralHeader.writeUInt32LE(0x02014b50, 0); - centralHeader.writeUInt16LE(20, 4); - centralHeader.writeUInt16LE(20, 6); - centralHeader.writeUInt32LE(checksum, 16); - centralHeader.writeUInt32LE(data.length, 20); - centralHeader.writeUInt32LE(data.length, 24); - centralHeader.writeUInt16LE(name.length, 28); - centralHeader.writeUInt32LE(offset, 42); - - localParts.push(localHeader, name, data); - centralParts.push(centralHeader, name); - offset += localHeader.length + name.length + data.length; +const writeZip = (entries: ZipEntry[]): Promise => { + const zip = new JSZip(); + for (const { name, contents } of entries) { + zip.file(name, contents); } - - const centralDirectory = Buffer.concat(centralParts); - - const end = Buffer.alloc(22); - end.writeUInt32LE(0x06054b50, 0); - end.writeUInt16LE(entries.length, 8); - end.writeUInt16LE(entries.length, 10); - end.writeUInt32LE(centralDirectory.length, 12); - end.writeUInt32LE(offset, 16); - - return Buffer.concat([...localParts, centralDirectory, end]); + return zip.generateAsync({ type: "nodebuffer" }); }; const wordNamespace = @@ -60,6 +19,14 @@ const relationshipNamespace = const relationshipTypes = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; +/** + * A4 (210 by 297 mm) with one-inch margins, in twentieths of a point. + * + * @see https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessing.pagesize + * @see https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessing.pagemargin + */ +const sectionProperties = ``; + const writeDocx = ({ body, documentRelationships = [], @@ -70,8 +37,8 @@ const writeDocx = ({ documentRelationships?: string[]; extraParts?: ZipEntry[]; extraContentTypes?: string; -}): Buffer => - writeStoredZip([ +}): Promise => + writeZip([ { name: "[Content_Types].xml", contents: `${extraContentTypes}`, @@ -86,7 +53,7 @@ const writeDocx = ({ }, { name: "word/document.xml", - contents: `${body}`, + contents: `${body}${sectionProperties}`, }, ...extraParts, ]); @@ -97,7 +64,7 @@ const paragraph = (text: string) => const pageBreak = ``; /** Renders `testDocumentPages` as a DOCX, one page per entry. */ -export const generateTestDocx = (): Buffer => +export const generateTestDocx = (): Promise => writeDocx({ body: testDocumentPages .map(({ heading, body }) => `${paragraph(heading)}${paragraph(body)}`) @@ -110,7 +77,7 @@ export const generateTestDocx = (): Buffer => * with an event handler, a picture linked from another origin, and an HTML * altChunk holding a script. */ -export const generateHostileTestDocx = (): Buffer => +export const generateHostileTestDocx = (): Promise => writeDocx({ body: [ paragraph("Hostile document"), @@ -139,7 +106,7 @@ export const generateHostileTestDocx = (): Buffer => * Builds a DOCX that folio refuses to draw: a bookmark that ends between * paragraphs without having started. */ -export const generateUndrawableTestDocx = (): Buffer => +export const generateUndrawableTestDocx = (): Promise => writeDocx({ body: [ paragraph("Undrawable document"), diff --git a/yarn.lock b/yarn.lock index b7e48e279ae..b88b0e7c742 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17685,6 +17685,7 @@ __metadata: execa: "npm:9.6.0" graphql: "npm:16.11.0" js-yaml: "npm:4.3.2" + jszip: "npm:3.10.2" rimraf: "npm:6.1.3" typescript: "npm:5.9.3" languageName: unknown From 74206fcd5fee5d55d666b21861b345574e9f0186 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:09:04 +0200 Subject: [PATCH 15/45] Tolerate folio's refusal only in the test that expects it The shared list of tolerable console messages let every e2e test log a `ProseMirrorDocumentValidationError`. The console check now also reads a `tolerableConsoleMessages` option, and the test that opens a DOCX folio cannot draw sets it with `test.use`. Every other test still fails on that message. --- .../tests/features/file-preview.spec.ts | 40 +++++++++++++------ tests/hash-playwright/tests/shared/runtime.ts | 18 ++++----- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/tests/hash-playwright/tests/features/file-preview.spec.ts b/tests/hash-playwright/tests/features/file-preview.spec.ts index 0b24fc6a309..63e0d654f29 100644 --- a/tests/hash-playwright/tests/features/file-preview.spec.ts +++ b/tests/hash-playwright/tests/features/file-preview.spec.ts @@ -281,23 +281,39 @@ test("a .doc file keeps the icon view", async ({ page }) => { await expect(page.locator(".layout-page")).toHaveCount(0); }); -for (const { description, contents } of [ - { - description: "a corrupt .docx file", +test("a corrupt .docx file falls back to the icon view and keeps the download", async ({ + page, +}) => { + const name = `file-preview-unavailable-${Date.now()}.docx`; + + await uploadAndOpenFile(page, { contents: Buffer.from("Not a ZIP archive"), - }, - { - description: "a .docx file folio cannot draw", - contents: generateUndrawableTestDocx(), - }, -]) { - test(`${description} falls back to the icon view and keeps the download`, async ({ + mimeType: docxMimeType, + name, + }); + + await expect( + page.getByRole("paragraph").filter({ hasText: name }), + ).toBeVisible({ timeout: 15_000 }); + await expect(page.locator(".layout-page")).toHaveCount(0); + await expect(page.getByRole("button", { name: "Download" })).toBeVisible(); +}); + +test.describe("a .docx file folio cannot draw", () => { + // React logs the refusal that the preview's error boundary catches. + test.use({ + tolerableConsoleMessages: [ + /^ProseMirrorDocumentValidationError: Document conversion produced an invalid ProseMirror document:/, + ], + }); + + test("falls back to the icon view and keeps the download", async ({ page, }) => { const name = `file-preview-unavailable-${Date.now()}.docx`; await uploadAndOpenFile(page, { - contents: await contents, + contents: await generateUndrawableTestDocx(), mimeType: docxMimeType, name, }); @@ -308,4 +324,4 @@ for (const { description, contents } of [ await expect(page.locator(".layout-page")).toHaveCount(0); await expect(page.getByRole("button", { name: "Download" })).toBeVisible(); }); -} +}); diff --git a/tests/hash-playwright/tests/shared/runtime.ts b/tests/hash-playwright/tests/shared/runtime.ts index 709fcc8463d..d503eb9638b 100644 --- a/tests/hash-playwright/tests/shared/runtime.ts +++ b/tests/hash-playwright/tests/shared/runtime.ts @@ -1,7 +1,5 @@ import { expect, test as base } from "@playwright/test"; -import type { Page } from "@playwright/test"; - const tolerableConsoleMessageMatches: RegExp[] = [ /Download the Apollo DevTools for a better development experience/, /Download the React DevTools for a better development experience/, @@ -18,10 +16,6 @@ const tolerableConsoleMessageMatches: RegExp[] = [ // observability – see notifications-with-links-context.tsx /^Skipping (mention|comment) notification .+ because the target of the following link\(s\) could not be resolved: /, - // The DOCX preview's engine logs why it refuses a document it cannot draw; - // the preview then falls back to the file's icon. - /^ProseMirrorDocumentValidationError: Document conversion produced an invalid ProseMirror document:/, - // You can add temporarily add more RegExps, but please track their removal /No validator provided for shape type bpBlock/, // canvas page warning from TLDraw // Dev-only deprecation warnings via the shared Link component (shared/ui/link.tsx) @@ -56,10 +50,12 @@ export * from "@playwright/test"; /** * This is a wrapper around the Playwright test function that adds checks for console messages. + * A test that expects further console messages lists them in `tolerableConsoleMessages`. * @see https://github.com/microsoft/playwright/discussions/11690#discussioncomment-2060397 */ -export const test = base.extend({ - page: async ({ page }, use) => { +export const test = base.extend<{ tolerableConsoleMessages: RegExp[] }>({ + tolerableConsoleMessages: [[], { option: true }], + page: async ({ page, tolerableConsoleMessages }, use) => { const messages: string[] = []; // Browser "Failed to load resource" console messages only carry the @@ -86,7 +82,11 @@ export const test = base.extend({ page.on("console", (msg) => { const text = msg.text(); - if (tolerableConsoleMessageMatches.some((match) => match.test(text))) { + if ( + [...tolerableConsoleMessageMatches, ...tolerableConsoleMessages].some( + (match) => match.test(text), + ) + ) { return; } From 838d77b4bcc8fd3eacbb83c04f640adca1eeb0aa Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:10:46 +0200 Subject: [PATCH 16/45] Keep the PDF preview's search hits in `pdf-search.tsx` Move `pdf-preview/pdf-search-hits.ts` back to the path the file had before the search bar moved out, so the diff shows the file's changes rather than a deletion and a new file. --- apps/hash-frontend/src/pages/shared/pdf-preview.tsx | 2 +- .../shared/pdf-preview/{pdf-search-hits.ts => pdf-search.tsx} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename apps/hash-frontend/src/pages/shared/pdf-preview/{pdf-search-hits.ts => pdf-search.tsx} (100%) diff --git a/apps/hash-frontend/src/pages/shared/pdf-preview.tsx b/apps/hash-frontend/src/pages/shared/pdf-preview.tsx index 5f5d20c1085..b1621b5734e 100644 --- a/apps/hash-frontend/src/pages/shared/pdf-preview.tsx +++ b/apps/hash-frontend/src/pages/shared/pdf-preview.tsx @@ -30,7 +30,7 @@ import { type SearchHit, type SearchHits, useFindSearchHits, -} from "./pdf-preview/pdf-search-hits"; +} from "./pdf-preview/pdf-search"; import { useElementBorderBoxSize } from "./use-element-dimensions"; import type { diff --git a/apps/hash-frontend/src/pages/shared/pdf-preview/pdf-search-hits.ts b/apps/hash-frontend/src/pages/shared/pdf-preview/pdf-search.tsx similarity index 100% rename from apps/hash-frontend/src/pages/shared/pdf-preview/pdf-search-hits.ts rename to apps/hash-frontend/src/pages/shared/pdf-preview/pdf-search.tsx From ab39797a1cef4c7a1957144c8c44e9827a6459f3 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:11:04 +0200 Subject: [PATCH 17/45] Move folio's ProseMirror webpack rule out of `next.config.js` The rule now lives in `folio-single-prosemirror-rule.js`, and `next.config.js` imports it. The rule lists the packages it redirects in two arrays: those HASH's editor also installs, and those only folio uses. It no longer derives them from folio's `package.json` files with a regex. Node's `findPackageJSON` replaces the hand-written walk up the `node_modules` folders. The check that stops the build when folio and the frontend share a copy now runs over the first array, so it also covers `prosemirror-dropcursor`, which HASH installs through `hash-isomorphic-utils`. The rule's output is unchanged: the same 13 aliases and the same `include` directories. --- .../folio-single-prosemirror-rule.js | 67 +++++++++++++++++ apps/hash-frontend/next.config.js | 74 +------------------ 2 files changed, 69 insertions(+), 72 deletions(-) create mode 100644 apps/hash-frontend/folio-single-prosemirror-rule.js diff --git a/apps/hash-frontend/folio-single-prosemirror-rule.js b/apps/hash-frontend/folio-single-prosemirror-rule.js new file mode 100644 index 00000000000..ceabfd41ec6 --- /dev/null +++ b/apps/hash-frontend/folio-single-prosemirror-rule.js @@ -0,0 +1,67 @@ +import { findPackageJSON } from "node:module"; +import path from "node:path"; + +/** + * @param {string} name + * @param {string} importer the file, or directory ending in `/`, that imports `name` + * @returns {string} + */ +const packageDirectory = (name, importer) => { + const packageJson = findPackageJSON(name, importer); + if (!packageJson) { + throw new Error(`${name} has no package.json above ${importer}`); + } + return path.dirname(packageJson); +}; + +/** + * The DOCX preview's engine, folio, needs newer ProseMirror packages than + * HASH's own editor, so Yarn installs several copies of them inside folio's + * tree. ProseMirror fails when two copies meet ("Adding different instances + * of a keyed plugin"), so every module in folio's tree resolves them to the + * copies folio-core uses. HASH's editor keeps its own. + */ +const folioSingleProseMirrorRule = () => { + const folioReact = packageDirectory("@stll/folio-react", import.meta.url); + const folioCore = packageDirectory("@stll/folio-core", `${folioReact}/`); + + const sharedWithHash = [ + "prosemirror-commands", + "prosemirror-dropcursor", + "prosemirror-inputrules", + "prosemirror-keymap", + "prosemirror-model", + "prosemirror-state", + "prosemirror-transform", + "prosemirror-view", + ]; + const folioOnly = [ + "prosemirror-gapcursor", + "prosemirror-history", + "prosemirror-tables", + "y-prosemirror", + "yjs", + ]; + + const alias = Object.fromEntries( + [...sharedWithHash, ...folioOnly].map((name) => [ + name, + packageDirectory(name, `${folioCore}/`), + ]), + ); + + for (const name of sharedWithHash) { + if (alias[name] === packageDirectory(name, import.meta.url)) { + throw new Error( + `folio and the frontend resolve ${name} to the same copy at ${alias[name]}, so folio's ProseMirror imports cannot be kept apart from the frontend's`, + ); + } + } + + return { + include: [folioReact, folioCore, ...Object.values(alias)], + resolve: { alias }, + }; +}; + +export { folioSingleProseMirrorRule }; diff --git a/apps/hash-frontend/next.config.js b/apps/hash-frontend/next.config.js index ea6a898939f..dbd4ef19ddd 100644 --- a/apps/hash-frontend/next.config.js +++ b/apps/hash-frontend/next.config.js @@ -1,7 +1,3 @@ -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - import bundleAnalyzer from "@next/bundle-analyzer"; import { withSentryConfig } from "@sentry/nextjs"; import { config } from "dotenv-flow"; @@ -9,76 +5,10 @@ import webpack from "webpack"; // eslint-disable-next-line import/extensions import { buildStamp } from "./buildstamp.js"; +// eslint-disable-next-line import/extensions +import { folioSingleProseMirrorRule } from "./folio-single-prosemirror-rule.js"; const { DefinePlugin } = webpack; - -/** - * @param {string} name - * @param {string} fromDirectory - * @returns {string} - */ -const findInstalledPackage = (name, fromDirectory) => { - for (let directory = fromDirectory; ; directory = path.dirname(directory)) { - const candidate = path.join(directory, "node_modules", name); - if (fs.existsSync(candidate)) { - return candidate; - } - if (directory === path.dirname(directory)) { - throw new Error(`${name} is not installed above ${fromDirectory}`); - } - } -}; - -/** - * The DOCX preview's engine, folio, needs newer ProseMirror packages than - * HASH's own editor, so Yarn installs several copies of them inside folio's - * tree. ProseMirror fails when two copies meet ("Adding different instances - * of a keyed plugin"), so every module in folio's tree resolves them to the - * copies folio-core uses. HASH's editor keeps its own. - */ -const folioSingleProseMirrorRule = () => { - const frontend = path.dirname(fileURLToPath(import.meta.url)); - const folioReact = findInstalledPackage("@stll/folio-react", frontend); - const folioCore = findInstalledPackage("@stll/folio-core", folioReact); - - const sharedPackages = new Set(); - for (const directory of [folioReact, folioCore]) { - const { dependencies = {} } = JSON.parse( - fs.readFileSync(path.join(directory, "package.json"), "utf8"), - ); - for (const name of Object.keys(dependencies)) { - if (/^(prosemirror-|y-prosemirror$|yjs$)/.test(name)) { - sharedPackages.add(name); - } - } - } - - const alias = Object.fromEntries( - [...sharedPackages].map((name) => [ - name, - findInstalledPackage(name, folioCore), - ]), - ); - - const { dependencies: frontendDependencies } = JSON.parse( - fs.readFileSync(path.join(frontend, "package.json"), "utf8"), - ); - for (const [name, directory] of Object.entries(alias)) { - if ( - name in frontendDependencies && - directory === findInstalledPackage(name, frontend) - ) { - throw new Error( - `folio and the frontend resolve ${name} to the same copy at ${directory}, so folio's ProseMirror imports cannot be kept apart from the frontend's`, - ); - } - } - - return { - include: [folioReact, folioCore, ...Object.values(alias)], - resolve: { alias }, - }; -}; const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === "true", }); From 6c6e448f767e0982673f590019838304b293cdf9 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:09:22 +0200 Subject: [PATCH 18/45] Delete folio's ProseMirror webpack rule FE-47 moves HASH to the ProseMirror versions folio uses, so each ProseMirror package installs once and folio's imports no longer need redirecting to folio-core's copies. `next.config.js` is back to its state before this branch. --- .../folio-single-prosemirror-rule.js | 67 ------------------- apps/hash-frontend/next.config.js | 4 -- 2 files changed, 71 deletions(-) delete mode 100644 apps/hash-frontend/folio-single-prosemirror-rule.js diff --git a/apps/hash-frontend/folio-single-prosemirror-rule.js b/apps/hash-frontend/folio-single-prosemirror-rule.js deleted file mode 100644 index ceabfd41ec6..00000000000 --- a/apps/hash-frontend/folio-single-prosemirror-rule.js +++ /dev/null @@ -1,67 +0,0 @@ -import { findPackageJSON } from "node:module"; -import path from "node:path"; - -/** - * @param {string} name - * @param {string} importer the file, or directory ending in `/`, that imports `name` - * @returns {string} - */ -const packageDirectory = (name, importer) => { - const packageJson = findPackageJSON(name, importer); - if (!packageJson) { - throw new Error(`${name} has no package.json above ${importer}`); - } - return path.dirname(packageJson); -}; - -/** - * The DOCX preview's engine, folio, needs newer ProseMirror packages than - * HASH's own editor, so Yarn installs several copies of them inside folio's - * tree. ProseMirror fails when two copies meet ("Adding different instances - * of a keyed plugin"), so every module in folio's tree resolves them to the - * copies folio-core uses. HASH's editor keeps its own. - */ -const folioSingleProseMirrorRule = () => { - const folioReact = packageDirectory("@stll/folio-react", import.meta.url); - const folioCore = packageDirectory("@stll/folio-core", `${folioReact}/`); - - const sharedWithHash = [ - "prosemirror-commands", - "prosemirror-dropcursor", - "prosemirror-inputrules", - "prosemirror-keymap", - "prosemirror-model", - "prosemirror-state", - "prosemirror-transform", - "prosemirror-view", - ]; - const folioOnly = [ - "prosemirror-gapcursor", - "prosemirror-history", - "prosemirror-tables", - "y-prosemirror", - "yjs", - ]; - - const alias = Object.fromEntries( - [...sharedWithHash, ...folioOnly].map((name) => [ - name, - packageDirectory(name, `${folioCore}/`), - ]), - ); - - for (const name of sharedWithHash) { - if (alias[name] === packageDirectory(name, import.meta.url)) { - throw new Error( - `folio and the frontend resolve ${name} to the same copy at ${alias[name]}, so folio's ProseMirror imports cannot be kept apart from the frontend's`, - ); - } - } - - return { - include: [folioReact, folioCore, ...Object.values(alias)], - resolve: { alias }, - }; -}; - -export { folioSingleProseMirrorRule }; diff --git a/apps/hash-frontend/next.config.js b/apps/hash-frontend/next.config.js index dbd4ef19ddd..dcc60122ee0 100644 --- a/apps/hash-frontend/next.config.js +++ b/apps/hash-frontend/next.config.js @@ -5,8 +5,6 @@ import webpack from "webpack"; // eslint-disable-next-line import/extensions import { buildStamp } from "./buildstamp.js"; -// eslint-disable-next-line import/extensions -import { folioSingleProseMirrorRule } from "./folio-single-prosemirror-rule.js"; const { DefinePlugin } = webpack; const withBundleAnalyzer = bundleAnalyzer({ @@ -270,8 +268,6 @@ export default withSentryConfig( type: "asset/resource", }); - webpackConfig.module.rules.push(folioSingleProseMirrorRule()); - // eslint-disable-next-line no-param-reassign webpackConfig.resolve.extensionAlias = { ".js": [".ts", ".tsx", ".jsx", ".js"], From 44187a555387c2067b20350908f5228fb997e3f4 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:22:00 +0200 Subject: [PATCH 19/45] Update `@stll/folio-react` to 0.28.0 folio-core 0.60.0 expects the host to install ProseMirror, yjs, `y-prosemirror` and `y-protocols`. The frontend now lists the ones it lacked, and raises `prosemirror-commands`, `-inputrules`, `-keymap` and `-dropcursor` to folio's ranges, so each installs once. folio-react 0.28.0, folio-core 0.60.0 and docx-core 0.37.0 are younger than the 7-day age gate, so `npmPreapprovedPackages` admits exactly these versions. --- apps/hash-frontend/package.json | 16 +- .../@local/hash-isomorphic-utils/package.json | 6 +- yarn.lock | 156 +++++++----------- 3 files changed, 75 insertions(+), 103 deletions(-) diff --git a/apps/hash-frontend/package.json b/apps/hash-frontend/package.json index 8fb87732d57..758672be561 100644 --- a/apps/hash-frontend/package.json +++ b/apps/hash-frontend/package.json @@ -69,8 +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.58.5", - "@stll/folio-react": "0.26.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", @@ -114,12 +114,16 @@ "pdfjs-dist": "4.8.69", "prismjs": "1.30.0", "prosemirror-collab": "1.3.0", - "prosemirror-commands": "1.3.1", + "prosemirror-commands": "1.7.2", "prosemirror-dev-tools": "4.3.0", - "prosemirror-inputrules": "1.2.0", - "prosemirror-keymap": "1.2.0", + "prosemirror-dropcursor": "1.8.4", + "prosemirror-gapcursor": "1.4.1", + "prosemirror-history": "1.5.1", + "prosemirror-inputrules": "1.5.1", + "prosemirror-keymap": "1.2.3", "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", @@ -149,7 +153,9 @@ "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": { diff --git a/libs/@local/hash-isomorphic-utils/package.json b/libs/@local/hash-isomorphic-utils/package.json index af244e6a978..0778c341eb0 100644 --- a/libs/@local/hash-isomorphic-utils/package.json +++ b/libs/@local/hash-isomorphic-utils/package.json @@ -44,9 +44,9 @@ "jsonschema": "1.5.0", "lodash-es": "4.18.1", "pluralize": "8.0.0", - "prosemirror-commands": "1.3.1", - "prosemirror-dropcursor": "1.6.1", - "prosemirror-keymap": "1.2.0", + "prosemirror-commands": "1.7.2", + "prosemirror-dropcursor": "1.8.4", + "prosemirror-keymap": "1.2.3", "prosemirror-model": "1.25.12", "prosemirror-state": "1.4.4", "prosemirror-transform": "1.12.2", diff --git a/yarn.lock b/yarn.lock index b88b0e7c742..181c10b7155 100644 --- a/yarn.lock +++ b/yarn.lock @@ -729,8 +729,8 @@ __metadata: "@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": "npm:3.0.0" "@sigma/node-image": "npm:3.0.0" - "@stll/folio-core": "npm:0.58.5" - "@stll/folio-react": "npm:0.26.0" + "@stll/folio-core": "npm:0.60.0" + "@stll/folio-react": "npm:0.28.0" "@svgr/webpack": "npm:8.1.0" "@tanstack/react-table": "npm:8.21.3" "@testing-library/dom": "npm:10.4.1" @@ -796,12 +796,16 @@ __metadata: pdfjs-dist: "npm:4.8.69" prismjs: "npm:1.30.0" prosemirror-collab: "npm:1.3.0" - prosemirror-commands: "npm:1.3.1" + prosemirror-commands: "npm:1.7.2" prosemirror-dev-tools: "npm:4.3.0" - prosemirror-inputrules: "npm:1.2.0" - prosemirror-keymap: "npm:1.2.0" + prosemirror-dropcursor: "npm:1.8.4" + prosemirror-gapcursor: "npm:1.4.1" + prosemirror-history: "npm:1.5.1" + prosemirror-inputrules: "npm:1.5.1" + prosemirror-keymap: "npm:1.2.3" prosemirror-model: "npm:1.25.12" prosemirror-state: "npm:1.4.4" + prosemirror-tables: "npm:1.8.5" prosemirror-transform: "npm:1.12.2" prosemirror-view: "npm:1.42.6" react: "npm:19.2.6" @@ -837,7 +841,9 @@ __metadata: wait-on: "npm:9.0.1" web-worker: "npm:1.4.1" webpack: "npm:5.104.1" + y-prosemirror: "npm:1.3.7" y-protocols: "npm:1.0.7" + yjs: "npm:13.6.33" zod: "npm:4.4.3" languageName: unknown linkType: soft @@ -9944,9 +9950,9 @@ __metadata: lodash-es: "npm:4.18.1" next: "npm:15.5.27" pluralize: "npm:8.0.0" - prosemirror-commands: "npm:1.3.1" - prosemirror-dropcursor: "npm:1.6.1" - prosemirror-keymap: "npm:1.2.0" + prosemirror-commands: "npm:1.7.2" + prosemirror-dropcursor: "npm:1.8.4" + prosemirror-keymap: "npm:1.2.3" prosemirror-model: "npm:1.25.12" prosemirror-state: "npm:1.4.4" prosemirror-transform: "npm:1.12.2" @@ -16659,9 +16665,9 @@ __metadata: languageName: node linkType: hard -"@stll/docx-core@npm:^0.36.2": - version: 0.36.2 - resolution: "@stll/docx-core@npm:0.36.2" +"@stll/docx-core@npm:^0.37.0": + version: 0.37.0 + resolution: "@stll/docx-core@npm:0.37.0" dependencies: "@nodable/entities": "npm:^3.0.0" "@stll/docx-utils": "npm:^0.1.0" @@ -16669,7 +16675,7 @@ __metadata: fast-xml-parser: "npm:^5.10.1" jszip: "npm:3.10.2" marked: "npm:^18.0.5" - checksum: 10c0/9135fb0606233bf2b0c8243712ffffa359a5a0ab9366a551872788b5f16e5f8b1dd1606a553a023d33a69eaaadc89a69893f3fdee8dfef1f4a4d30412b615193 + checksum: 10c0/6978eabec0fbeec30e49a7976a6cefb8d5478c6e84409ed52af235870736f1dabd511dbd98fc849f323e462aa980cb27a4a89c02901b86145253e97ca7fb5256 languageName: node linkType: hard @@ -16684,11 +16690,11 @@ __metadata: languageName: node linkType: hard -"@stll/folio-core@npm:0.58.5, @stll/folio-core@npm:^0.58.5": - version: 0.58.5 - resolution: "@stll/folio-core@npm:0.58.5" +"@stll/folio-core@npm:0.60.0, @stll/folio-core@npm:^0.60.0": + version: 0.60.0 + resolution: "@stll/folio-core@npm:0.60.0" dependencies: - "@stll/docx-core": "npm:^0.36.2" + "@stll/docx-core": "npm:^0.37.0" "@stll/docx-utils": "npm:^0.1.0" "@stll/template-conditions": "npm:^0.6.2" better-result: "npm:3.0.1" @@ -16697,28 +16703,30 @@ __metadata: fast-xml-parser: "npm:^5.10.1" hyphen: "npm:1.14.1" jszip: "npm:3.10.2" - prosemirror-commands: "npm:^1.7.1" - prosemirror-dropcursor: "npm:^1.8.2" - prosemirror-gapcursor: "npm:^1.4.1" - prosemirror-history: "npm:^1.5.0" - prosemirror-inputrules: "npm:^1.5.0" - prosemirror-keymap: "npm:^1.2.3" - prosemirror-model: "npm:^1.25.9" - prosemirror-state: "npm:^1.4.4" - prosemirror-tables: "npm:^1.8.5" - prosemirror-transform: "npm:^1.12.0" - prosemirror-view: "npm:^1.42.6" utif2: "npm:^4.1.0" valibot: "npm:1.5.0" - y-prosemirror: "npm:^1.3.7" - yjs: "npm:^13.6.31" - checksum: 10c0/84ccd412b0bb316c728118f51c29ee741af54ad18f19f24264f8b9f50468a7ac67764ba838e6e013d8c9ee362c727aeca1f9b253484c898b6ebf842f983fe35d - languageName: node - linkType: hard - -"@stll/folio-react@npm:0.26.0": - version: 0.26.0 - resolution: "@stll/folio-react@npm:0.26.0" + peerDependencies: + prosemirror-commands: ^1.7.1 + prosemirror-dropcursor: ^1.8.2 + prosemirror-gapcursor: ^1.4.1 + prosemirror-history: ^1.5.0 + prosemirror-inputrules: ^1.5.0 + prosemirror-keymap: ^1.2.3 + prosemirror-model: ^1.25.9 + prosemirror-state: ^1.4.4 + prosemirror-tables: ^1.8.5 + prosemirror-transform: ^1.12.0 + prosemirror-view: ^1.42.6 + y-prosemirror: ^1.3.7 + y-protocols: ^1.0.6 + yjs: ^13.6.31 + checksum: 10c0/6e3a7be13ec85547ee3a342dd8937c33e748d4dc881fb9e5e5affe3187808b3e7999d35e00269dceb877d5ad08d700ce4165787eafc1f28698af69afef7cf094 + languageName: node + linkType: hard + +"@stll/folio-react@npm:0.28.0": + version: 0.28.0 + resolution: "@stll/folio-react@npm:0.28.0" dependencies: "@base-ui/react": "npm:1.8.0" "@fontsource/arimo": "npm:^5.2.8" @@ -16729,21 +16737,21 @@ __metadata: "@fontsource/noto-sans-arabic": "npm:^5.2.7" "@fontsource/source-sans-3": "npm:^5.2.9" "@fontsource/tinos": "npm:^5.2.7" - "@stll/folio-core": "npm:^0.58.5" + "@stll/folio-core": "npm:^0.60.0" better-result: "npm:3.0.1" lucide-react: "npm:1.24.0" - prosemirror-history: "npm:^1.5.0" - prosemirror-model: "npm:^1.25.9" - prosemirror-state: "npm:^1.4.4" - prosemirror-tables: "npm:^1.8.5" - prosemirror-view: "npm:^1.42.6" react-compiler-runtime: "npm:^1.0.0" - yjs: "npm:^13.6.31" peerDependencies: + prosemirror-history: ^1.5.0 + prosemirror-model: ^1.25.9 + prosemirror-state: ^1.4.4 + prosemirror-tables: ^1.8.5 + prosemirror-view: ^1.42.6 react: ^19.2.0 react-dom: ^19.2.0 use-intl: ">=4.0.0" - checksum: 10c0/7447bc22a1286c519ed75ebc2511c8e5952e1f74704e95dc7be47ab6d0ff092a79be3e88cb1cd0269ac5b9d7177a03b8eab3f7dfa8b9a93f71fa85bfaaa5dded + yjs: ^13.6.31 + checksum: 10c0/37361be518a0b1ddd8b20ee7f207a1a8a9fedf1b1656cba6f85c04c79f1766cc9a4cabef9a7154de683762b270f9996de9517dd1c1ada0922b2a8b9e61c0a1ca languageName: node linkType: hard @@ -38413,18 +38421,7 @@ __metadata: languageName: node linkType: hard -"prosemirror-commands@npm:1.3.1": - version: 1.3.1 - resolution: "prosemirror-commands@npm:1.3.1" - dependencies: - prosemirror-model: "npm:^1.0.0" - prosemirror-state: "npm:^1.0.0" - prosemirror-transform: "npm:^1.0.0" - checksum: 10c0/2de3360c9ecf03776943b776aae3a05106bca69cce68cd5be2970de88fbfd8e37a4c4a1657c9586203e76fc8f65de3737dfd42b7b4e084d3adecd7568503c724 - languageName: node - linkType: hard - -"prosemirror-commands@npm:^1.7.1": +"prosemirror-commands@npm:1.7.2": version: 1.7.2 resolution: "prosemirror-commands@npm:1.7.2" dependencies: @@ -38456,18 +38453,7 @@ __metadata: languageName: node linkType: hard -"prosemirror-dropcursor@npm:1.6.1": - version: 1.6.1 - resolution: "prosemirror-dropcursor@npm:1.6.1" - dependencies: - prosemirror-state: "npm:^1.0.0" - prosemirror-transform: "npm:^1.1.0" - prosemirror-view: "npm:^1.1.0" - checksum: 10c0/e4bc8bb4abd013a9aed7336c0f819aa5ecb0dd58434fc495437293e38b24d970909454ab7fa512fa4a0428919ea9473adca1b9d588619ff4948db50c1b47dc77 - languageName: node - linkType: hard - -"prosemirror-dropcursor@npm:^1.8.2": +"prosemirror-dropcursor@npm:1.8.4": version: 1.8.4 resolution: "prosemirror-dropcursor@npm:1.8.4" dependencies: @@ -38478,7 +38464,7 @@ __metadata: languageName: node linkType: hard -"prosemirror-gapcursor@npm:^1.4.1": +"prosemirror-gapcursor@npm:1.4.1": version: 1.4.1 resolution: "prosemirror-gapcursor@npm:1.4.1" dependencies: @@ -38490,7 +38476,7 @@ __metadata: languageName: node linkType: hard -"prosemirror-history@npm:^1.5.0": +"prosemirror-history@npm:1.5.1": version: 1.5.1 resolution: "prosemirror-history@npm:1.5.1" dependencies: @@ -38502,17 +38488,7 @@ __metadata: languageName: node linkType: hard -"prosemirror-inputrules@npm:1.2.0": - version: 1.2.0 - resolution: "prosemirror-inputrules@npm:1.2.0" - dependencies: - prosemirror-state: "npm:^1.0.0" - prosemirror-transform: "npm:^1.0.0" - checksum: 10c0/fd3df9077638d917d5c005afd305c104111e545f9988b7c5ecad39ecc06fd60ff4bf5b020dd7a175b6b7c984f426a63a10bf76fb7eb983bb4995af2245d72a12 - languageName: node - linkType: hard - -"prosemirror-inputrules@npm:^1.5.0": +"prosemirror-inputrules@npm:1.5.1": version: 1.5.1 resolution: "prosemirror-inputrules@npm:1.5.1" dependencies: @@ -38522,17 +38498,7 @@ __metadata: languageName: node linkType: hard -"prosemirror-keymap@npm:1.2.0": - version: 1.2.0 - resolution: "prosemirror-keymap@npm:1.2.0" - dependencies: - prosemirror-state: "npm:^1.0.0" - w3c-keyname: "npm:^2.2.0" - checksum: 10c0/6e5bf4c03efd8a529443da0279993893722fe797b7d7a124065effb6118d5a236233c6b4a25487c0a57e86987e9748b82c890e513d98a974bcde0d3d38aa6e96 - languageName: node - linkType: hard - -"prosemirror-keymap@npm:^1.0.0, prosemirror-keymap@npm:^1.2.3": +"prosemirror-keymap@npm:1.2.3, prosemirror-keymap@npm:^1.0.0, prosemirror-keymap@npm:^1.2.3": version: 1.2.3 resolution: "prosemirror-keymap@npm:1.2.3" dependencies: @@ -38562,7 +38528,7 @@ __metadata: languageName: node linkType: hard -"prosemirror-tables@npm:^1.8.5": +"prosemirror-tables@npm:1.8.5": version: 1.8.5 resolution: "prosemirror-tables@npm:1.8.5" dependencies: @@ -46282,7 +46248,7 @@ __metadata: languageName: node linkType: hard -"y-prosemirror@npm:^1.3.7": +"y-prosemirror@npm:1.3.7": version: 1.3.7 resolution: "y-prosemirror@npm:1.3.7" dependencies: @@ -46466,7 +46432,7 @@ __metadata: languageName: node linkType: hard -"yjs@npm:^13.6.31": +"yjs@npm:13.6.33": version: 13.6.33 resolution: "yjs@npm:13.6.33" dependencies: From 236c11ab1a8130c7324faafed2b07372966041b6 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:21:36 +0200 Subject: [PATCH 20/45] Reset the file preview when its file URL changes Editing an entity's "File URL" property changes the URL of the mounted PDF or DOCX preview, and the preview kept the old file's page, zoom and search state. The PDF preview then drew the new file at the old zoom and asked for a page it does not have. Keying both previews by the file URL mounts a fresh preview for each file. The file-preview spec gains a test per preview that edits the File URL to a second file and checks its page, zoom and search state. --- .../entity-editor/file-preview-section.tsx | 2 + .../tests/features/file-preview.spec.ts | 125 +++++++++++++++++- .../tests/shared/generate-test-docx.ts | 10 +- .../tests/shared/generate-test-pdf.ts | 7 +- .../tests/shared/test-document.ts | 13 ++ 5 files changed, 146 insertions(+), 11 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/file-preview-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/file-preview-section.tsx index 2afaef8665e..47c81fcbdbc 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/file-preview-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/file-preview-section.tsx @@ -366,6 +366,7 @@ export const FilePreviewSection = () => { ) : isPdf ? ( { /> ) : isDocx ? ( page.locator(".react-pdf-page"); const getDocxPage = (page: Page, heading: string) => page.locator(".layout-page", { hasText: heading }); +/** + * The page and zoom labels. The DOCX preview also announces the page in a + * status element, so the label is told apart by its role. + */ +const getLabel = (page: Page, text: string) => + page.getByRole("paragraph").and(page.getByText(text, { exact: true })); + const docxMimeType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; -const uploadAndOpenFile = async ( - page: Page, - file: { contents: Buffer; mimeType: string; name: string }, -) => { +type TestFile = { contents: Buffer; mimeType: string; name: string }; + +const uploadTestFile = async (page: Page, file: TestFile) => { const user = await getUser(page.request); if (!user) { throw new Error("Cannot upload a file without an authenticated user"); } - const fileEntity = await uploadFile(page.request, { + return uploadFile(page.request, { ...file, webId: extractWebIdFromEntityId(user.metadata.recordId.entityId), }); +}; + +const uploadAndOpenFile = async (page: Page, file: TestFile) => { + const fileEntity = await uploadTestFile(page, file); await page.goto( generateEntityPath({ @@ -49,6 +64,74 @@ const uploadAndOpenFile = async ( ); }; +const uploadTestFileAndGetUrl = async (page: Page, file: TestFile) => { + const fileEntity = await uploadTestFile(page, file); + + return simplifyProperties(fileEntity.properties).fileUrl; +}; + +/** + * Changes the "File URL" value in the properties table without saving the + * entity. The table draws its cells on a canvas, so the cell is reached with + * the keyboard from the first row. + */ +const editFileUrl = async (page: Page, fileUrl: string) => { + const fileUrlLabelTestId = await page + .locator(".dvn-underlay > canvas:first-of-type") + .first() + .locator('[data-testid^="glide-cell-0-"]', { hasText: /^File URL$/ }) + .getAttribute("data-testid"); + const fileUrlRowIndex = Number(fileUrlLabelTestId?.split("-").at(-1)); + + await page + .locator(".dvn-scroller") + .first() + .click({ position: { x: 20, y: gridRowHeight * 1.5 } }); + for (let rowIndex = 0; rowIndex < fileUrlRowIndex; rowIndex++) { + await page.keyboard.press("ArrowDown"); + } + await page.keyboard.press("ArrowRight"); + await page.keyboard.press("Enter"); + + const valueInput = page.getByPlaceholder("Start typing..."); + await valueInput.fill(fileUrl); + await valueInput.press("Enter"); +}; + +/** + * Zooms in, searches for `testDocumentSearchWord` and moves to page 2, then + * changes the file URL to a one-page file that does not hold that word. + */ +const zoomSearchAndEditFileUrl = async (page: Page, otherFileUrl: string) => { + await page.getByRole("button", { name: "Zoom in" }).click(); + await expect(getLabel(page, "120%")).toBeVisible(); + + await page.getByRole("button", { name: "Show search" }).click(); + await page + .getByPlaceholder("Search the document...") + .fill(testDocumentSearchWord); + await expect(page.getByText("1 of 2", { exact: true })).toBeVisible(); + + await page.getByRole("button", { name: "Next page" }).click(); + await expect(getLabel(page, "2 of 3")).toBeVisible(); + + await editFileUrl(page, otherFileUrl); +}; + +const expectStateOfOtherFile = async (page: Page) => { + await expect(getLabel(page, "1 of 1")).toBeVisible(); + await expect(getLabel(page, "100%")).toBeVisible(); + + const searchInput = page.getByPlaceholder("Search the document..."); + await expect(searchInput).toHaveValue(""); + await expect(page.getByRole("button", { name: "Next result" })).toBeHidden(); + + await searchInput.fill(otherTestDocumentSearchWord); + await expect(page.getByText("1 of 2", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Next result" }).click(); + await expect(page.getByText("2 of 2", { exact: true })).toBeVisible(); +}; + test.describe("PDF preview", () => { test.beforeEach(async ({ page }) => { await uploadAndOpenFile(page, { @@ -120,6 +203,23 @@ test.describe("PDF preview", () => { await searchInput.press("ControlOrMeta+V"); await expect(searchInput).toHaveValue(firstPage.heading); }); + + test("editing the file URL shows the new file's pages, zoom and search state", async ({ + page, + }) => { + const otherFileUrl = await uploadTestFileAndGetUrl(page, { + contents: generateTestPdf(otherTestDocumentPages), + mimeType: "application/pdf", + name: `file-preview-other-${Date.now()}.pdf`, + }); + + await zoomSearchAndEditFileUrl(page, otherFileUrl); + + await expect( + getPdfPage(page).getByText(otherDocumentPage.heading, { exact: true }), + ).toBeVisible(); + await expectStateOfOtherFile(page); + }); }); test.describe("DOCX preview", () => { @@ -196,6 +296,21 @@ test.describe("DOCX preview", () => { await expect(docxPage).toHaveText(textBefore); }); + test("editing the file URL shows the new file's pages, zoom and search state", async ({ + page, + }) => { + const otherFileUrl = await uploadTestFileAndGetUrl(page, { + contents: await generateTestDocx(otherTestDocumentPages), + mimeType: docxMimeType, + name: `file-preview-other-${Date.now()}.docx`, + }); + + await zoomSearchAndEditFileUrl(page, otherFileUrl); + + await expect(getDocxPage(page, otherDocumentPage.heading)).toBeVisible(); + await expectStateOfOtherFile(page); + }); + test("text selected on a page copies exactly", async ({ page }) => { await page .context() diff --git a/tests/hash-playwright/tests/shared/generate-test-docx.ts b/tests/hash-playwright/tests/shared/generate-test-docx.ts index 66c441f34ff..cb0bf6fab35 100644 --- a/tests/hash-playwright/tests/shared/generate-test-docx.ts +++ b/tests/hash-playwright/tests/shared/generate-test-docx.ts @@ -1,6 +1,6 @@ import JSZip from "jszip"; -import { testDocumentPages } from "./test-document"; +import { type PageContent, testDocumentPages } from "./test-document"; type ZipEntry = { name: string; contents: string }; @@ -63,10 +63,12 @@ const paragraph = (text: string) => const pageBreak = ``; -/** Renders `testDocumentPages` as a DOCX, one page per entry. */ -export const generateTestDocx = (): Promise => +/** Renders `pages`, by default `testDocumentPages`, as a DOCX, one page per entry. */ +export const generateTestDocx = ( + pages: readonly PageContent[] = testDocumentPages, +): Promise => writeDocx({ - body: testDocumentPages + body: pages .map(({ heading, body }) => `${paragraph(heading)}${paragraph(body)}`) .join(pageBreak), }); diff --git a/tests/hash-playwright/tests/shared/generate-test-pdf.ts b/tests/hash-playwright/tests/shared/generate-test-pdf.ts index 69a7f594076..4f873beb353 100644 --- a/tests/hash-playwright/tests/shared/generate-test-pdf.ts +++ b/tests/hash-playwright/tests/shared/generate-test-pdf.ts @@ -39,7 +39,7 @@ const buildContentStream = ({ heading, body }: PageContent) => * * @see https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=708 */ -const generatePdf = (pages: PageContent[]): Buffer => { +const generatePdf = (pages: readonly PageContent[]): Buffer => { // After the catalog, page tree and font const firstPageObjectNumber = 4; @@ -83,4 +83,7 @@ const generatePdf = (pages: PageContent[]): Buffer => { return Buffer.from(pdf); }; -export const generateTestPdf = (): Buffer => generatePdf(testDocumentPages); +/** Renders `pages`, by default the test document's, as a PDF. */ +export const generateTestPdf = ( + pages: readonly PageContent[] = testDocumentPages, +): Buffer => generatePdf(pages); diff --git a/tests/hash-playwright/tests/shared/test-document.ts b/tests/hash-playwright/tests/shared/test-document.ts index ed3ccb171eb..ab6d3be4528 100644 --- a/tests/hash-playwright/tests/shared/test-document.ts +++ b/tests/hash-playwright/tests/shared/test-document.ts @@ -22,3 +22,16 @@ export const testDocumentPages = [ body: `Another ${testDocumentSearchWord} lives on this page.`, }, ] as const satisfies PageContent[]; + +/** + * The text of a second generated test document, one page long, that holds + * `otherTestDocumentSearchWord` twice and `testDocumentSearchWord` nowhere. + */ +export const otherTestDocumentSearchWord = "wombat"; + +export const otherTestDocumentPages = [ + { + heading: "Only page", + body: `A ${otherTestDocumentSearchWord} and another ${otherTestDocumentSearchWord} live here.`, + }, +] as const satisfies PageContent[]; From 06a70193eab18cd4ceb9ebe04a79d20fce1e440e Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:11:10 +0200 Subject: [PATCH 21/45] Split the file-preview spec by format and slim the File URL tests `file-preview.spec.ts` becomes `file-preview/pdf.spec.ts` and `file-preview/docx.spec.ts`. Their helpers, test document and generators move to `shared/file-preview/`. The File URL tests now check only that the new file shows after an edit made on a later page, the case where the PDF preview showed no page at all. Both formats share one test body, and `editFileUrl` focuses the value cell through the grid's accessibility table instead of clicking a pixel offset and walking rows with the arrow keys. --- .../tests/features/file-preview.spec.ts | 442 ------------------ .../tests/features/file-preview/docx.spec.ts | 226 +++++++++ .../tests/features/file-preview/pdf.spec.ts | 90 ++++ .../{ => file-preview}/generate-test-docx.ts | 0 .../{ => file-preview}/generate-test-pdf.ts | 0 .../{ => file-preview}/test-document.ts | 11 +- .../tests/shared/file-preview/utils.ts | 118 +++++ 7 files changed, 435 insertions(+), 452 deletions(-) delete mode 100644 tests/hash-playwright/tests/features/file-preview.spec.ts create mode 100644 tests/hash-playwright/tests/features/file-preview/docx.spec.ts create mode 100644 tests/hash-playwright/tests/features/file-preview/pdf.spec.ts rename tests/hash-playwright/tests/shared/{ => file-preview}/generate-test-docx.ts (100%) rename tests/hash-playwright/tests/shared/{ => file-preview}/generate-test-pdf.ts (100%) rename tests/hash-playwright/tests/shared/{ => file-preview}/test-document.ts (68%) create mode 100644 tests/hash-playwright/tests/shared/file-preview/utils.ts diff --git a/tests/hash-playwright/tests/features/file-preview.spec.ts b/tests/hash-playwright/tests/features/file-preview.spec.ts deleted file mode 100644 index fdf7a1e7968..00000000000 --- a/tests/hash-playwright/tests/features/file-preview.spec.ts +++ /dev/null @@ -1,442 +0,0 @@ -import { extractWebIdFromEntityId } from "@blockprotocol/type-system"; -import { gridRowHeight } from "@local/hash-isomorphic-utils/data-grid"; -import { generateEntityPath } from "@local/hash-isomorphic-utils/frontend-paths"; -import { simplifyProperties } from "@local/hash-isomorphic-utils/simplify-properties"; - -import { getUser, uploadFile } from "../shared/api-queries"; -import { - generateHostileTestDocx, - generateTestDocx, - generateUndrawableTestDocx, -} from "../shared/generate-test-docx"; -import { generateTestPdf } from "../shared/generate-test-pdf"; -import { expect, test } from "../shared/runtime"; -import { - otherTestDocumentPages, - otherTestDocumentSearchWord, - testDocumentPages, - testDocumentSearchWord, -} from "../shared/test-document"; - -import type { Page } from "../shared/runtime"; - -const [firstPage, secondPage, thirdPage] = testDocumentPages; -const [otherDocumentPage] = otherTestDocumentPages; - -const getPdfPage = (page: Page) => page.locator(".react-pdf-page"); - -const getDocxPage = (page: Page, heading: string) => - page.locator(".layout-page", { hasText: heading }); - -/** - * The page and zoom labels. The DOCX preview also announces the page in a - * status element, so the label is told apart by its role. - */ -const getLabel = (page: Page, text: string) => - page.getByRole("paragraph").and(page.getByText(text, { exact: true })); - -const docxMimeType = - "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; - -type TestFile = { contents: Buffer; mimeType: string; name: string }; - -const uploadTestFile = async (page: Page, file: TestFile) => { - const user = await getUser(page.request); - if (!user) { - throw new Error("Cannot upload a file without an authenticated user"); - } - - return uploadFile(page.request, { - ...file, - webId: extractWebIdFromEntityId(user.metadata.recordId.entityId), - }); -}; - -const uploadAndOpenFile = async (page: Page, file: TestFile) => { - const fileEntity = await uploadTestFile(page, file); - - await page.goto( - generateEntityPath({ - entityId: fileEntity.metadata.recordId.entityId, - includeDraftId: false, - shortname: "alice", - }), - ); -}; - -const uploadTestFileAndGetUrl = async (page: Page, file: TestFile) => { - const fileEntity = await uploadTestFile(page, file); - - return simplifyProperties(fileEntity.properties).fileUrl; -}; - -/** - * Changes the "File URL" value in the properties table without saving the - * entity. The table draws its cells on a canvas, so the cell is reached with - * the keyboard from the first row. - */ -const editFileUrl = async (page: Page, fileUrl: string) => { - const fileUrlLabelTestId = await page - .locator(".dvn-underlay > canvas:first-of-type") - .first() - .locator('[data-testid^="glide-cell-0-"]', { hasText: /^File URL$/ }) - .getAttribute("data-testid"); - const fileUrlRowIndex = Number(fileUrlLabelTestId?.split("-").at(-1)); - - await page - .locator(".dvn-scroller") - .first() - .click({ position: { x: 20, y: gridRowHeight * 1.5 } }); - for (let rowIndex = 0; rowIndex < fileUrlRowIndex; rowIndex++) { - await page.keyboard.press("ArrowDown"); - } - await page.keyboard.press("ArrowRight"); - await page.keyboard.press("Enter"); - - const valueInput = page.getByPlaceholder("Start typing..."); - await valueInput.fill(fileUrl); - await valueInput.press("Enter"); -}; - -/** - * Zooms in, searches for `testDocumentSearchWord` and moves to page 2, then - * changes the file URL to a one-page file that does not hold that word. - */ -const zoomSearchAndEditFileUrl = async (page: Page, otherFileUrl: string) => { - await page.getByRole("button", { name: "Zoom in" }).click(); - await expect(getLabel(page, "120%")).toBeVisible(); - - await page.getByRole("button", { name: "Show search" }).click(); - await page - .getByPlaceholder("Search the document...") - .fill(testDocumentSearchWord); - await expect(page.getByText("1 of 2", { exact: true })).toBeVisible(); - - await page.getByRole("button", { name: "Next page" }).click(); - await expect(getLabel(page, "2 of 3")).toBeVisible(); - - await editFileUrl(page, otherFileUrl); -}; - -const expectStateOfOtherFile = async (page: Page) => { - await expect(getLabel(page, "1 of 1")).toBeVisible(); - await expect(getLabel(page, "100%")).toBeVisible(); - - const searchInput = page.getByPlaceholder("Search the document..."); - await expect(searchInput).toHaveValue(""); - await expect(page.getByRole("button", { name: "Next result" })).toBeHidden(); - - await searchInput.fill(otherTestDocumentSearchWord); - await expect(page.getByText("1 of 2", { exact: true })).toBeVisible(); - await page.getByRole("button", { name: "Next result" }).click(); - await expect(page.getByText("2 of 2", { exact: true })).toBeVisible(); -}; - -test.describe("PDF preview", () => { - test.beforeEach(async ({ page }) => { - await uploadAndOpenFile(page, { - contents: generateTestPdf(), - mimeType: "application/pdf", - name: `file-preview-${Date.now()}.pdf`, - }); - - await expect( - getPdfPage(page).getByText(firstPage.heading, { exact: true }), - ).toBeVisible({ timeout: 15_000 }); - }); - - test("the next page button shows the next page", async ({ page }) => { - await page.getByRole("button", { name: "Next page" }).click(); - await expect( - getPdfPage(page).getByText(secondPage.heading, { exact: true }), - ).toBeVisible(); - }); - - test("a page thumbnail shows its page", async ({ page }) => { - await page.getByRole("group", { name: "Page 3", exact: true }).click(); - await expect( - getPdfPage(page).getByText(thirdPage.heading, { exact: true }), - ).toBeVisible(); - }); - - test("zooming in makes the page wider", async ({ page }) => { - const pdfPage = getPdfPage(page); - - const widthBeforeZoom = (await pdfPage.boundingBox())?.width ?? 0; - await page.getByRole("button", { name: "Zoom in" }).click(); - await expect - .poll(async () => (await pdfPage.boundingBox())?.width ?? 0) - .toBeGreaterThan(widthBeforeZoom); - }); - - test("search highlights a word and the next result moves to the next page holding it", async ({ - page, - }) => { - const pdfPage = getPdfPage(page); - - await page.getByRole("button", { name: "Show search" }).click(); - await page - .getByPlaceholder("Search the document...") - .fill(testDocumentSearchWord); - - const searchHighlight = pdfPage.locator('span[style*="border-bottom"]', { - hasText: testDocumentSearchWord, - }); - - await expect(searchHighlight).toBeVisible(); - - await page.getByRole("button", { name: "Next result" }).click(); - await expect( - pdfPage.getByText(thirdPage.heading, { exact: true }), - ).toBeVisible(); - await expect(searchHighlight).toBeVisible(); - }); - - test("text selected on a page can be copied and pasted", async ({ page }) => { - await getPdfPage(page) - .getByText(firstPage.heading, { exact: true }) - .selectText(); - await page.keyboard.press("ControlOrMeta+C"); - - await page.getByRole("button", { name: "Show search" }).click(); - const searchInput = page.getByPlaceholder("Search the document..."); - await searchInput.press("ControlOrMeta+V"); - await expect(searchInput).toHaveValue(firstPage.heading); - }); - - test("editing the file URL shows the new file's pages, zoom and search state", async ({ - page, - }) => { - const otherFileUrl = await uploadTestFileAndGetUrl(page, { - contents: generateTestPdf(otherTestDocumentPages), - mimeType: "application/pdf", - name: `file-preview-other-${Date.now()}.pdf`, - }); - - await zoomSearchAndEditFileUrl(page, otherFileUrl); - - await expect( - getPdfPage(page).getByText(otherDocumentPage.heading, { exact: true }), - ).toBeVisible(); - await expectStateOfOtherFile(page); - }); -}); - -test.describe("DOCX preview", () => { - test.beforeEach(async ({ page }) => { - await uploadAndOpenFile(page, { - contents: await generateTestDocx(), - mimeType: docxMimeType, - name: `file-preview-${Date.now()}.docx`, - }); - - await expect(getDocxPage(page, firstPage.heading)).toBeVisible({ - timeout: 15_000, - }); - }); - - test("the next and previous page buttons move between pages", async ({ - page, - }) => { - await page.getByRole("button", { name: "Next page" }).click(); - await expect(getDocxPage(page, secondPage.heading)).toBeInViewport(); - - await page.getByRole("button", { name: "Previous page" }).click(); - await expect(getDocxPage(page, firstPage.heading)).toBeInViewport(); - }); - - test("zooming in makes the page wider", async ({ page }) => { - const docxPage = getDocxPage(page, firstPage.heading); - - const widthBeforeZoom = (await docxPage.boundingBox())?.width ?? 0; - await page.getByRole("button", { name: "Zoom in" }).click(); - await expect - .poll(async () => (await docxPage.boundingBox())?.width ?? 0) - .toBeGreaterThan(widthBeforeZoom); - }); - - test("search selects a word and the next result moves to the next page holding it", async ({ - page, - }) => { - await page.getByRole("button", { name: "Show search" }).click(); - await page - .getByPlaceholder("Search the document...") - .fill(testDocumentSearchWord); - await expect(page.getByText("1 of 2", { exact: true })).toBeVisible(); - await expect(page.locator("[data-folio-selection-rect]")).toBeVisible(); - - await page.getByRole("button", { name: "Next result" }).click(); - await expect(page.getByText("2 of 2", { exact: true })).toBeVisible(); - await expect(getDocxPage(page, thirdPage.heading)).toBeInViewport(); - await expect(page.locator("[data-folio-selection-rect]")).toBeVisible(); - }); - - test("a search without results clears the previous hit", async ({ page }) => { - await page.getByRole("button", { name: "Show search" }).click(); - const searchInput = page.getByPlaceholder("Search the document..."); - - await searchInput.fill(testDocumentSearchWord); - await expect(page.locator("[data-folio-selection-rect]")).toBeVisible(); - - await searchInput.fill("nonexistentword"); - await expect(page.getByText("0 of 0", { exact: true })).toBeVisible(); - await expect(page.locator("[data-folio-selection-rect]")).toHaveCount(0); - }); - - test("the preview cannot be edited", async ({ page }) => { - const docxPage = getDocxPage(page, firstPage.heading); - const textBefore = (await docxPage.textContent()) ?? ""; - - await docxPage.getByText(firstPage.heading, { exact: true }).click(); - await page.keyboard.type("typed text"); - await page.keyboard.press("Enter"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Delete"); - - await expect(docxPage).toHaveText(textBefore); - }); - - test("editing the file URL shows the new file's pages, zoom and search state", async ({ - page, - }) => { - const otherFileUrl = await uploadTestFileAndGetUrl(page, { - contents: await generateTestDocx(otherTestDocumentPages), - mimeType: docxMimeType, - name: `file-preview-other-${Date.now()}.docx`, - }); - - await zoomSearchAndEditFileUrl(page, otherFileUrl); - - await expect(getDocxPage(page, otherDocumentPage.heading)).toBeVisible(); - await expectStateOfOtherFile(page); - }); - - test("text selected on a page copies exactly", async ({ page }) => { - await page - .context() - .grantPermissions(["clipboard-read", "clipboard-write"]); - - const heading = page - .locator(".layout-page") - .first() - .getByText(firstPage.heading, { exact: true }); - - const box = await heading.boundingBox(); - if (!box) { - throw new Error("The heading on page 1 has no bounding box"); - } - const lineY = box.y + box.height / 2; - await page.mouse.move(box.x + 1, lineY); - await page.mouse.down(); - await page.mouse.move(box.x + box.width - 1, lineY, { steps: 5 }); - await page.mouse.up(); - await page.keyboard.press("ControlOrMeta+C"); - - await expect - .poll(() => page.evaluate(() => navigator.clipboard.readText())) - .toBe(firstPage.heading); - }); -}); - -test("the DOCX preview runs and loads nothing from a hostile document", async ({ - page, -}) => { - const dialogs: string[] = []; - page.on("dialog", (dialog) => { - dialogs.push(dialog.message()); - void dialog.dismiss(); - }); - - const outsideRequests: string[] = []; - page.on("request", (request) => { - if (new URL(request.url()).hostname !== "localhost") { - outsideRequests.push(request.url()); - } - }); - - await uploadAndOpenFile(page, { - contents: await generateHostileTestDocx(), - mimeType: docxMimeType, - name: `file-preview-hostile-${Date.now()}.docx`, - }); - - await expect( - page.locator(".layout-page", { hasText: "Hostile document" }), - ).toBeVisible({ timeout: 15_000 }); - - const preview = page.locator("[data-folio-scroll]"); - await expect(preview.locator("script")).toHaveCount(0); - await expect(preview.locator("iframe")).toHaveCount(0); - await expect( - preview.locator('a[href^="javascript:"], a[href^="data:"]'), - ).toHaveCount(0); - await expect( - preview.locator("[onerror], [onload], [onclick], [onmouseover]"), - ).toHaveCount(0); - - await page.locator(".layout-page", { hasText: "javascript link" }).click(); - await page.locator(".layout-page", { hasText: "data link" }).click(); - - expect(dialogs).toEqual([]); - expect(outsideRequests).toEqual([]); -}); - -test("a .doc file keeps the icon view", async ({ page }) => { - const name = `file-preview-${Date.now()}.doc`; - - await uploadAndOpenFile(page, { - contents: Buffer.from("Not a Word 97-2003 document"), - mimeType: "application/msword", - name, - }); - - await expect( - page.getByRole("paragraph").filter({ hasText: name }), - ).toBeVisible(); - await expect(page.locator(".layout-page")).toHaveCount(0); -}); - -test("a corrupt .docx file falls back to the icon view and keeps the download", async ({ - page, -}) => { - const name = `file-preview-unavailable-${Date.now()}.docx`; - - await uploadAndOpenFile(page, { - contents: Buffer.from("Not a ZIP archive"), - mimeType: docxMimeType, - name, - }); - - await expect( - page.getByRole("paragraph").filter({ hasText: name }), - ).toBeVisible({ timeout: 15_000 }); - await expect(page.locator(".layout-page")).toHaveCount(0); - await expect(page.getByRole("button", { name: "Download" })).toBeVisible(); -}); - -test.describe("a .docx file folio cannot draw", () => { - // React logs the refusal that the preview's error boundary catches. - test.use({ - tolerableConsoleMessages: [ - /^ProseMirrorDocumentValidationError: Document conversion produced an invalid ProseMirror document:/, - ], - }); - - test("falls back to the icon view and keeps the download", async ({ - page, - }) => { - const name = `file-preview-unavailable-${Date.now()}.docx`; - - await uploadAndOpenFile(page, { - contents: await generateUndrawableTestDocx(), - mimeType: docxMimeType, - name, - }); - - await expect( - page.getByRole("paragraph").filter({ hasText: name }), - ).toBeVisible({ timeout: 15_000 }); - await expect(page.locator(".layout-page")).toHaveCount(0); - await expect(page.getByRole("button", { name: "Download" })).toBeVisible(); - }); -}); diff --git a/tests/hash-playwright/tests/features/file-preview/docx.spec.ts b/tests/hash-playwright/tests/features/file-preview/docx.spec.ts new file mode 100644 index 00000000000..b5d13112d17 --- /dev/null +++ b/tests/hash-playwright/tests/features/file-preview/docx.spec.ts @@ -0,0 +1,226 @@ +import { + generateHostileTestDocx, + generateTestDocx, + generateUndrawableTestDocx, +} from "../../shared/file-preview/generate-test-docx"; +import { + testDocumentPages, + testDocumentSearchWord, +} from "../../shared/file-preview/test-document"; +import { + docx, + docxMimeType, + expectFileUrlEditOnLaterPageShowsNewFile, + getDocxPage, + uploadAndOpenFile, +} from "../../shared/file-preview/utils"; +import { expect, test } from "../../shared/runtime"; + +const [firstPage, secondPage, thirdPage] = testDocumentPages; + +test.describe("DOCX preview", () => { + test.beforeEach(async ({ page }) => { + await uploadAndOpenFile(page, { + contents: await generateTestDocx(), + mimeType: docxMimeType, + name: `file-preview-${Date.now()}.docx`, + }); + + await expect(getDocxPage(page, firstPage.heading)).toBeVisible({ + timeout: 15_000, + }); + }); + + test("the next and previous page buttons move between pages", async ({ + page, + }) => { + await page.getByRole("button", { name: "Next page" }).click(); + await expect(getDocxPage(page, secondPage.heading)).toBeInViewport(); + + await page.getByRole("button", { name: "Previous page" }).click(); + await expect(getDocxPage(page, firstPage.heading)).toBeInViewport(); + }); + + test("zooming in makes the page wider", async ({ page }) => { + const docxPage = getDocxPage(page, firstPage.heading); + + const widthBeforeZoom = (await docxPage.boundingBox())?.width ?? 0; + await page.getByRole("button", { name: "Zoom in" }).click(); + await expect + .poll(async () => (await docxPage.boundingBox())?.width ?? 0) + .toBeGreaterThan(widthBeforeZoom); + }); + + test("search selects a word and the next result moves to the next page holding it", async ({ + page, + }) => { + await page.getByRole("button", { name: "Show search" }).click(); + await page + .getByPlaceholder("Search the document...") + .fill(testDocumentSearchWord); + await expect(page.getByText("1 of 2", { exact: true })).toBeVisible(); + await expect(page.locator("[data-folio-selection-rect]")).toBeVisible(); + + await page.getByRole("button", { name: "Next result" }).click(); + await expect(page.getByText("2 of 2", { exact: true })).toBeVisible(); + await expect(getDocxPage(page, thirdPage.heading)).toBeInViewport(); + await expect(page.locator("[data-folio-selection-rect]")).toBeVisible(); + }); + + test("a search without results clears the previous hit", async ({ page }) => { + await page.getByRole("button", { name: "Show search" }).click(); + const searchInput = page.getByPlaceholder("Search the document..."); + + await searchInput.fill(testDocumentSearchWord); + await expect(page.locator("[data-folio-selection-rect]")).toBeVisible(); + + await searchInput.fill("nonexistentword"); + await expect(page.getByText("0 of 0", { exact: true })).toBeVisible(); + await expect(page.locator("[data-folio-selection-rect]")).toHaveCount(0); + }); + + test("the preview cannot be edited", async ({ page }) => { + const docxPage = getDocxPage(page, firstPage.heading); + const textBefore = (await docxPage.textContent()) ?? ""; + + await docxPage.getByText(firstPage.heading, { exact: true }).click(); + await page.keyboard.type("typed text"); + await page.keyboard.press("Enter"); + await page.keyboard.press("Backspace"); + await page.keyboard.press("Delete"); + + await expect(docxPage).toHaveText(textBefore); + }); + + test("editing the file URL on a later page shows the new file", ({ page }) => + expectFileUrlEditOnLaterPageShowsNewFile(page, docx)); + + test("text selected on a page copies exactly", async ({ page }) => { + await page + .context() + .grantPermissions(["clipboard-read", "clipboard-write"]); + + const heading = page + .locator(".layout-page") + .first() + .getByText(firstPage.heading, { exact: true }); + + const box = await heading.boundingBox(); + if (!box) { + throw new Error("The heading on page 1 has no bounding box"); + } + const lineY = box.y + box.height / 2; + await page.mouse.move(box.x + 1, lineY); + await page.mouse.down(); + await page.mouse.move(box.x + box.width - 1, lineY, { steps: 5 }); + await page.mouse.up(); + await page.keyboard.press("ControlOrMeta+C"); + + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(firstPage.heading); + }); +}); + +test("the DOCX preview runs and loads nothing from a hostile document", async ({ + page, +}) => { + const dialogs: string[] = []; + page.on("dialog", (dialog) => { + dialogs.push(dialog.message()); + void dialog.dismiss(); + }); + + const outsideRequests: string[] = []; + page.on("request", (request) => { + if (new URL(request.url()).hostname !== "localhost") { + outsideRequests.push(request.url()); + } + }); + + await uploadAndOpenFile(page, { + contents: await generateHostileTestDocx(), + mimeType: docxMimeType, + name: `file-preview-hostile-${Date.now()}.docx`, + }); + + await expect( + page.locator(".layout-page", { hasText: "Hostile document" }), + ).toBeVisible({ timeout: 15_000 }); + + const preview = page.locator("[data-folio-scroll]"); + await expect(preview.locator("script")).toHaveCount(0); + await expect(preview.locator("iframe")).toHaveCount(0); + await expect( + preview.locator('a[href^="javascript:"], a[href^="data:"]'), + ).toHaveCount(0); + await expect( + preview.locator("[onerror], [onload], [onclick], [onmouseover]"), + ).toHaveCount(0); + + await page.locator(".layout-page", { hasText: "javascript link" }).click(); + await page.locator(".layout-page", { hasText: "data link" }).click(); + + expect(dialogs).toEqual([]); + expect(outsideRequests).toEqual([]); +}); + +test("a .doc file keeps the icon view", async ({ page }) => { + const name = `file-preview-${Date.now()}.doc`; + + await uploadAndOpenFile(page, { + contents: Buffer.from("Not a Word 97-2003 document"), + mimeType: "application/msword", + name, + }); + + await expect( + page.getByRole("paragraph").filter({ hasText: name }), + ).toBeVisible(); + await expect(page.locator(".layout-page")).toHaveCount(0); +}); + +test("a corrupt .docx file falls back to the icon view and keeps the download", async ({ + page, +}) => { + const name = `file-preview-unavailable-${Date.now()}.docx`; + + await uploadAndOpenFile(page, { + contents: Buffer.from("Not a ZIP archive"), + mimeType: docxMimeType, + name, + }); + + await expect( + page.getByRole("paragraph").filter({ hasText: name }), + ).toBeVisible({ timeout: 15_000 }); + await expect(page.locator(".layout-page")).toHaveCount(0); + await expect(page.getByRole("button", { name: "Download" })).toBeVisible(); +}); + +test.describe("a .docx file folio cannot draw", () => { + // React logs the refusal that the preview's error boundary catches. + test.use({ + tolerableConsoleMessages: [ + /^ProseMirrorDocumentValidationError: Document conversion produced an invalid ProseMirror document:/, + ], + }); + + test("falls back to the icon view and keeps the download", async ({ + page, + }) => { + const name = `file-preview-unavailable-${Date.now()}.docx`; + + await uploadAndOpenFile(page, { + contents: await generateUndrawableTestDocx(), + mimeType: docxMimeType, + name, + }); + + await expect( + page.getByRole("paragraph").filter({ hasText: name }), + ).toBeVisible({ timeout: 15_000 }); + await expect(page.locator(".layout-page")).toHaveCount(0); + await expect(page.getByRole("button", { name: "Download" })).toBeVisible(); + }); +}); diff --git a/tests/hash-playwright/tests/features/file-preview/pdf.spec.ts b/tests/hash-playwright/tests/features/file-preview/pdf.spec.ts new file mode 100644 index 00000000000..0552cac2cae --- /dev/null +++ b/tests/hash-playwright/tests/features/file-preview/pdf.spec.ts @@ -0,0 +1,90 @@ +import { generateTestPdf } from "../../shared/file-preview/generate-test-pdf"; +import { + testDocumentPages, + testDocumentSearchWord, +} from "../../shared/file-preview/test-document"; +import { + expectFileUrlEditOnLaterPageShowsNewFile, + getPdfPage, + pdf, + uploadAndOpenFile, +} from "../../shared/file-preview/utils"; +import { expect, test } from "../../shared/runtime"; + +const [firstPage, secondPage, thirdPage] = testDocumentPages; + +test.describe("PDF preview", () => { + test.beforeEach(async ({ page }) => { + await uploadAndOpenFile(page, { + contents: generateTestPdf(), + mimeType: "application/pdf", + name: `file-preview-${Date.now()}.pdf`, + }); + + await expect( + getPdfPage(page).getByText(firstPage.heading, { exact: true }), + ).toBeVisible({ timeout: 15_000 }); + }); + + test("the next page button shows the next page", async ({ page }) => { + await page.getByRole("button", { name: "Next page" }).click(); + await expect( + getPdfPage(page).getByText(secondPage.heading, { exact: true }), + ).toBeVisible(); + }); + + test("a page thumbnail shows its page", async ({ page }) => { + await page.getByRole("group", { name: "Page 3", exact: true }).click(); + await expect( + getPdfPage(page).getByText(thirdPage.heading, { exact: true }), + ).toBeVisible(); + }); + + test("zooming in makes the page wider", async ({ page }) => { + const pdfPage = getPdfPage(page); + + const widthBeforeZoom = (await pdfPage.boundingBox())?.width ?? 0; + await page.getByRole("button", { name: "Zoom in" }).click(); + await expect + .poll(async () => (await pdfPage.boundingBox())?.width ?? 0) + .toBeGreaterThan(widthBeforeZoom); + }); + + test("search highlights a word and the next result moves to the next page holding it", async ({ + page, + }) => { + const pdfPage = getPdfPage(page); + + await page.getByRole("button", { name: "Show search" }).click(); + await page + .getByPlaceholder("Search the document...") + .fill(testDocumentSearchWord); + + const searchHighlight = pdfPage.locator('span[style*="border-bottom"]', { + hasText: testDocumentSearchWord, + }); + + await expect(searchHighlight).toBeVisible(); + + await page.getByRole("button", { name: "Next result" }).click(); + await expect( + pdfPage.getByText(thirdPage.heading, { exact: true }), + ).toBeVisible(); + await expect(searchHighlight).toBeVisible(); + }); + + test("text selected on a page can be copied and pasted", async ({ page }) => { + await getPdfPage(page) + .getByText(firstPage.heading, { exact: true }) + .selectText(); + await page.keyboard.press("ControlOrMeta+C"); + + await page.getByRole("button", { name: "Show search" }).click(); + const searchInput = page.getByPlaceholder("Search the document..."); + await searchInput.press("ControlOrMeta+V"); + await expect(searchInput).toHaveValue(firstPage.heading); + }); + + test("editing the file URL on a later page shows the new file", ({ page }) => + expectFileUrlEditOnLaterPageShowsNewFile(page, pdf)); +}); diff --git a/tests/hash-playwright/tests/shared/generate-test-docx.ts b/tests/hash-playwright/tests/shared/file-preview/generate-test-docx.ts similarity index 100% rename from tests/hash-playwright/tests/shared/generate-test-docx.ts rename to tests/hash-playwright/tests/shared/file-preview/generate-test-docx.ts diff --git a/tests/hash-playwright/tests/shared/generate-test-pdf.ts b/tests/hash-playwright/tests/shared/file-preview/generate-test-pdf.ts similarity index 100% rename from tests/hash-playwright/tests/shared/generate-test-pdf.ts rename to tests/hash-playwright/tests/shared/file-preview/generate-test-pdf.ts diff --git a/tests/hash-playwright/tests/shared/test-document.ts b/tests/hash-playwright/tests/shared/file-preview/test-document.ts similarity index 68% rename from tests/hash-playwright/tests/shared/test-document.ts rename to tests/hash-playwright/tests/shared/file-preview/test-document.ts index ab6d3be4528..43cd1c82875 100644 --- a/tests/hash-playwright/tests/shared/test-document.ts +++ b/tests/hash-playwright/tests/shared/file-preview/test-document.ts @@ -23,15 +23,6 @@ export const testDocumentPages = [ }, ] as const satisfies PageContent[]; -/** - * The text of a second generated test document, one page long, that holds - * `otherTestDocumentSearchWord` twice and `testDocumentSearchWord` nowhere. - */ -export const otherTestDocumentSearchWord = "wombat"; - export const otherTestDocumentPages = [ - { - heading: "Only page", - body: `A ${otherTestDocumentSearchWord} and another ${otherTestDocumentSearchWord} live here.`, - }, + { heading: "Only page", body: "A different document." }, ] as const satisfies PageContent[]; diff --git a/tests/hash-playwright/tests/shared/file-preview/utils.ts b/tests/hash-playwright/tests/shared/file-preview/utils.ts new file mode 100644 index 00000000000..8d3d5972d21 --- /dev/null +++ b/tests/hash-playwright/tests/shared/file-preview/utils.ts @@ -0,0 +1,118 @@ +import { extractWebIdFromEntityId } from "@blockprotocol/type-system"; +import { generateEntityPath } from "@local/hash-isomorphic-utils/frontend-paths"; +import { simplifyProperties } from "@local/hash-isomorphic-utils/simplify-properties"; + +import { getUser, uploadFile } from "../api-queries"; +import { expect } from "../runtime"; +import { generateTestDocx } from "./generate-test-docx"; +import { generateTestPdf } from "./generate-test-pdf"; +import { otherTestDocumentPages, testDocumentPages } from "./test-document"; + +import type { Locator, Page } from "../runtime"; +import type { PageContent } from "./test-document"; + +const [, secondPage] = testDocumentPages; +const [otherDocumentPage] = otherTestDocumentPages; + +export const getPdfPage = (page: Page) => page.locator(".react-pdf-page"); + +export const getDocxPage = (page: Page, heading: string) => + page.locator(".layout-page", { hasText: heading }); + +export const docxMimeType = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + +type TestFile = { contents: Buffer; mimeType: string; name: string }; + +const uploadTestFile = async (page: Page, file: TestFile) => { + const user = await getUser(page.request); + if (!user) { + throw new Error("Cannot upload a file without an authenticated user"); + } + + return uploadFile(page.request, { + ...file, + webId: extractWebIdFromEntityId(user.metadata.recordId.entityId), + }); +}; + +export const uploadAndOpenFile = async (page: Page, file: TestFile) => { + const fileEntity = await uploadTestFile(page, file); + + await page.goto( + generateEntityPath({ + entityId: fileEntity.metadata.recordId.entityId, + includeDraftId: false, + shortname: "alice", + }), + ); +}; + +export const uploadTestFileAndGetUrl = async (page: Page, file: TestFile) => { + const fileEntity = await uploadTestFile(page, file); + + return simplifyProperties(fileEntity.properties).fileUrl; +}; + +/** + * Changes the "File URL" value in the properties table without saving the + * entity. The table draws its cells on a canvas, so the value cell is focused + * through the grid's accessibility table. + */ +export const editFileUrl = async (page: Page, fileUrl: string) => { + await page + .getByTestId("data-grid-canvas") + .first() + .locator("tr") + .filter({ has: page.locator("td", { hasText: /^File URL$/ }) }) + .locator("td") + .nth(1) + .focus(); + await page.keyboard.press("Enter"); + + const valueInput = page.getByPlaceholder("Start typing..."); + await valueInput.fill(fileUrl); + await valueInput.press("Enter"); +}; + +type FileFormat = { + extension: string; + generate: (pages?: readonly PageContent[]) => Buffer | Promise; + mimeType: string; + pageWithHeading: (page: Page, heading: string) => Locator; +}; + +export const pdf: FileFormat = { + extension: "pdf", + generate: generateTestPdf, + mimeType: "application/pdf", + pageWithHeading: (page, heading) => + getPdfPage(page).getByText(heading, { exact: true }), +}; + +export const docx: FileFormat = { + extension: "docx", + generate: generateTestDocx, + mimeType: docxMimeType, + pageWithHeading: getDocxPage, +}; + +export const expectFileUrlEditOnLaterPageShowsNewFile = async ( + page: Page, + format: FileFormat, +) => { + const otherFileUrl = await uploadTestFileAndGetUrl(page, { + contents: await format.generate(otherTestDocumentPages), + mimeType: format.mimeType, + name: `file-preview-other-${Date.now()}.${format.extension}`, + }); + + await page.getByRole("button", { name: "Next page" }).click(); + await expect(format.pageWithHeading(page, secondPage.heading)).toBeVisible(); + + await editFileUrl(page, otherFileUrl); + + await expect( + format.pageWithHeading(page, otherDocumentPage.heading), + ).toBeVisible(); +}; From 54dd479b0fa8bf785ab1317acc2f74b1240dc781 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:27:29 +0200 Subject: [PATCH 22/45] Focus the properties grid before the File URL cell in the file-preview tests Focusing the cell while the grid has no selection let the canvas's focus handler move the selection back to the first cell, so Enter opened no editor and both File URL tests timed out. --- tests/hash-playwright/tests/shared/file-preview/utils.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/hash-playwright/tests/shared/file-preview/utils.ts b/tests/hash-playwright/tests/shared/file-preview/utils.ts index 8d3d5972d21..6cbbfcbfe45 100644 --- a/tests/hash-playwright/tests/shared/file-preview/utils.ts +++ b/tests/hash-playwright/tests/shared/file-preview/utils.ts @@ -57,12 +57,13 @@ export const uploadTestFileAndGetUrl = async (page: Page, file: TestFile) => { /** * Changes the "File URL" value in the properties table without saving the * entity. The table draws its cells on a canvas, so the value cell is focused - * through the grid's accessibility table. + * through the grid's accessibility table. The canvas is focused first: its + * focus handler would otherwise move the selection back to the first cell. */ export const editFileUrl = async (page: Page, fileUrl: string) => { - await page - .getByTestId("data-grid-canvas") - .first() + const grid = page.getByTestId("data-grid-canvas").first(); + await grid.focus(); + await grid .locator("tr") .filter({ has: page.locator("td", { hasText: /^File URL$/ }) }) .locator("td") From dcf66638e34bb571620487d762205b1e669ec5bc Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:38:23 +0200 Subject: [PATCH 23/45] Let the file-preview tests pass the pages each generator renders `generatePdf` and `generateDocx` take the pages to render, and the specs pass `testDocumentPages` themselves. The `generateTestPdf` and `generateTestDocx` shorthands, whose pages defaulted to the test document, are gone, and `FileFormat.generate` points at the generators directly. --- .../tests/features/file-preview/docx.spec.ts | 4 ++-- .../tests/features/file-preview/pdf.spec.ts | 4 ++-- .../tests/shared/file-preview/generate-test-docx.ts | 8 +++----- .../tests/shared/file-preview/generate-test-pdf.ts | 9 ++------- .../hash-playwright/tests/shared/file-preview/utils.ts | 10 +++++----- 5 files changed, 14 insertions(+), 21 deletions(-) diff --git a/tests/hash-playwright/tests/features/file-preview/docx.spec.ts b/tests/hash-playwright/tests/features/file-preview/docx.spec.ts index b5d13112d17..c937e248a10 100644 --- a/tests/hash-playwright/tests/features/file-preview/docx.spec.ts +++ b/tests/hash-playwright/tests/features/file-preview/docx.spec.ts @@ -1,6 +1,6 @@ import { generateHostileTestDocx, - generateTestDocx, + generateDocx, generateUndrawableTestDocx, } from "../../shared/file-preview/generate-test-docx"; import { @@ -21,7 +21,7 @@ const [firstPage, secondPage, thirdPage] = testDocumentPages; test.describe("DOCX preview", () => { test.beforeEach(async ({ page }) => { await uploadAndOpenFile(page, { - contents: await generateTestDocx(), + contents: await generateDocx(testDocumentPages), mimeType: docxMimeType, name: `file-preview-${Date.now()}.docx`, }); diff --git a/tests/hash-playwright/tests/features/file-preview/pdf.spec.ts b/tests/hash-playwright/tests/features/file-preview/pdf.spec.ts index 0552cac2cae..1972a8175a9 100644 --- a/tests/hash-playwright/tests/features/file-preview/pdf.spec.ts +++ b/tests/hash-playwright/tests/features/file-preview/pdf.spec.ts @@ -1,4 +1,4 @@ -import { generateTestPdf } from "../../shared/file-preview/generate-test-pdf"; +import { generatePdf } from "../../shared/file-preview/generate-test-pdf"; import { testDocumentPages, testDocumentSearchWord, @@ -16,7 +16,7 @@ const [firstPage, secondPage, thirdPage] = testDocumentPages; test.describe("PDF preview", () => { test.beforeEach(async ({ page }) => { await uploadAndOpenFile(page, { - contents: generateTestPdf(), + contents: generatePdf(testDocumentPages), mimeType: "application/pdf", name: `file-preview-${Date.now()}.pdf`, }); diff --git a/tests/hash-playwright/tests/shared/file-preview/generate-test-docx.ts b/tests/hash-playwright/tests/shared/file-preview/generate-test-docx.ts index cb0bf6fab35..bc14f952500 100644 --- a/tests/hash-playwright/tests/shared/file-preview/generate-test-docx.ts +++ b/tests/hash-playwright/tests/shared/file-preview/generate-test-docx.ts @@ -1,6 +1,6 @@ import JSZip from "jszip"; -import { type PageContent, testDocumentPages } from "./test-document"; +import type { PageContent } from "./test-document"; type ZipEntry = { name: string; contents: string }; @@ -63,10 +63,8 @@ const paragraph = (text: string) => const pageBreak = ``; -/** Renders `pages`, by default `testDocumentPages`, as a DOCX, one page per entry. */ -export const generateTestDocx = ( - pages: readonly PageContent[] = testDocumentPages, -): Promise => +/** Renders `pages` as a DOCX, one page per entry. */ +export const generateDocx = (pages: readonly PageContent[]): Promise => writeDocx({ body: pages .map(({ heading, body }) => `${paragraph(heading)}${paragraph(body)}`) diff --git a/tests/hash-playwright/tests/shared/file-preview/generate-test-pdf.ts b/tests/hash-playwright/tests/shared/file-preview/generate-test-pdf.ts index 4f873beb353..5f266d3bcf6 100644 --- a/tests/hash-playwright/tests/shared/file-preview/generate-test-pdf.ts +++ b/tests/hash-playwright/tests/shared/file-preview/generate-test-pdf.ts @@ -1,4 +1,4 @@ -import { type PageContent, testDocumentPages } from "./test-document"; +import type { PageContent } from "./test-document"; const pageHeight = 842; @@ -39,7 +39,7 @@ const buildContentStream = ({ heading, body }: PageContent) => * * @see https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=708 */ -const generatePdf = (pages: readonly PageContent[]): Buffer => { +export const generatePdf = (pages: readonly PageContent[]): Buffer => { // After the catalog, page tree and font const firstPageObjectNumber = 4; @@ -82,8 +82,3 @@ const generatePdf = (pages: readonly PageContent[]): Buffer => { return Buffer.from(pdf); }; - -/** Renders `pages`, by default the test document's, as a PDF. */ -export const generateTestPdf = ( - pages: readonly PageContent[] = testDocumentPages, -): Buffer => generatePdf(pages); diff --git a/tests/hash-playwright/tests/shared/file-preview/utils.ts b/tests/hash-playwright/tests/shared/file-preview/utils.ts index 6cbbfcbfe45..5edd1538d9c 100644 --- a/tests/hash-playwright/tests/shared/file-preview/utils.ts +++ b/tests/hash-playwright/tests/shared/file-preview/utils.ts @@ -4,8 +4,8 @@ import { simplifyProperties } from "@local/hash-isomorphic-utils/simplify-proper import { getUser, uploadFile } from "../api-queries"; import { expect } from "../runtime"; -import { generateTestDocx } from "./generate-test-docx"; -import { generateTestPdf } from "./generate-test-pdf"; +import { generateDocx } from "./generate-test-docx"; +import { generatePdf } from "./generate-test-pdf"; import { otherTestDocumentPages, testDocumentPages } from "./test-document"; import type { Locator, Page } from "../runtime"; @@ -78,14 +78,14 @@ export const editFileUrl = async (page: Page, fileUrl: string) => { type FileFormat = { extension: string; - generate: (pages?: readonly PageContent[]) => Buffer | Promise; + generate: (pages: readonly PageContent[]) => Buffer | Promise; mimeType: string; pageWithHeading: (page: Page, heading: string) => Locator; }; export const pdf: FileFormat = { extension: "pdf", - generate: generateTestPdf, + generate: generatePdf, mimeType: "application/pdf", pageWithHeading: (page, heading) => getPdfPage(page).getByText(heading, { exact: true }), @@ -93,7 +93,7 @@ export const pdf: FileFormat = { export const docx: FileFormat = { extension: "docx", - generate: generateTestDocx, + generate: generateDocx, mimeType: docxMimeType, pageWithHeading: getDocxPage, }; From ff3dba6cef96fa40f0b9251a4b6a9e570bbc17aa Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:57:39 +0200 Subject: [PATCH 24/45] Drop the redundant credentials option from the DOCX download --- apps/hash-frontend/src/pages/shared/docx-preview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/hash-frontend/src/pages/shared/docx-preview.tsx b/apps/hash-frontend/src/pages/shared/docx-preview.tsx index 80aa1d5ee18..54908962a89 100644 --- a/apps/hash-frontend/src/pages/shared/docx-preview.tsx +++ b/apps/hash-frontend/src/pages/shared/docx-preview.tsx @@ -88,7 +88,7 @@ export const DocxPreview = ({ setData(null); fetchPresignedFileUrl(url) - .then((presignedUrl) => fetch(presignedUrl, { credentials: "omit" })) + .then((presignedUrl) => fetch(presignedUrl)) .then((response) => { if (!response.ok) { throw new Error( From 726bc56e5dc8efdd3df6945804082773e3c6b114 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:09:56 +0200 Subject: [PATCH 25/45] Keep HASH's ProseMirror commands, keymap, inputrules and dropcursor versions folio asks for newer versions of these four as peers, but the read-only preview runs on the versions HASH's editors already use. Keeping them leaves the supply-chain status editor on the versions it was tested with and stops this branch changing `hash-isomorphic-utils`. Yarn now warns about the four unmet peers. --- apps/hash-frontend/package.json | 7 ++- .../@local/hash-isomorphic-utils/package.json | 6 +-- yarn.lock | 51 +++++++++++-------- 3 files changed, 36 insertions(+), 28 deletions(-) diff --git a/apps/hash-frontend/package.json b/apps/hash-frontend/package.json index 758672be561..bab77358662 100644 --- a/apps/hash-frontend/package.json +++ b/apps/hash-frontend/package.json @@ -114,13 +114,12 @@ "pdfjs-dist": "4.8.69", "prismjs": "1.30.0", "prosemirror-collab": "1.3.0", - "prosemirror-commands": "1.7.2", + "prosemirror-commands": "1.3.1", "prosemirror-dev-tools": "4.3.0", - "prosemirror-dropcursor": "1.8.4", "prosemirror-gapcursor": "1.4.1", "prosemirror-history": "1.5.1", - "prosemirror-inputrules": "1.5.1", - "prosemirror-keymap": "1.2.3", + "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", diff --git a/libs/@local/hash-isomorphic-utils/package.json b/libs/@local/hash-isomorphic-utils/package.json index 0778c341eb0..af244e6a978 100644 --- a/libs/@local/hash-isomorphic-utils/package.json +++ b/libs/@local/hash-isomorphic-utils/package.json @@ -44,9 +44,9 @@ "jsonschema": "1.5.0", "lodash-es": "4.18.1", "pluralize": "8.0.0", - "prosemirror-commands": "1.7.2", - "prosemirror-dropcursor": "1.8.4", - "prosemirror-keymap": "1.2.3", + "prosemirror-commands": "1.3.1", + "prosemirror-dropcursor": "1.6.1", + "prosemirror-keymap": "1.2.0", "prosemirror-model": "1.25.12", "prosemirror-state": "1.4.4", "prosemirror-transform": "1.12.2", diff --git a/yarn.lock b/yarn.lock index 181c10b7155..1d5e371aef3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -796,13 +796,12 @@ __metadata: pdfjs-dist: "npm:4.8.69" prismjs: "npm:1.30.0" prosemirror-collab: "npm:1.3.0" - prosemirror-commands: "npm:1.7.2" + prosemirror-commands: "npm:1.3.1" prosemirror-dev-tools: "npm:4.3.0" - prosemirror-dropcursor: "npm:1.8.4" prosemirror-gapcursor: "npm:1.4.1" prosemirror-history: "npm:1.5.1" - prosemirror-inputrules: "npm:1.5.1" - prosemirror-keymap: "npm:1.2.3" + prosemirror-inputrules: "npm:1.2.0" + prosemirror-keymap: "npm:1.2.0" prosemirror-model: "npm:1.25.12" prosemirror-state: "npm:1.4.4" prosemirror-tables: "npm:1.8.5" @@ -9950,9 +9949,9 @@ __metadata: lodash-es: "npm:4.18.1" next: "npm:15.5.27" pluralize: "npm:8.0.0" - prosemirror-commands: "npm:1.7.2" - prosemirror-dropcursor: "npm:1.8.4" - prosemirror-keymap: "npm:1.2.3" + prosemirror-commands: "npm:1.3.1" + prosemirror-dropcursor: "npm:1.6.1" + prosemirror-keymap: "npm:1.2.0" prosemirror-model: "npm:1.25.12" prosemirror-state: "npm:1.4.4" prosemirror-transform: "npm:1.12.2" @@ -38421,14 +38420,14 @@ __metadata: languageName: node linkType: hard -"prosemirror-commands@npm:1.7.2": - version: 1.7.2 - resolution: "prosemirror-commands@npm:1.7.2" +"prosemirror-commands@npm:1.3.1": + version: 1.3.1 + resolution: "prosemirror-commands@npm:1.3.1" dependencies: prosemirror-model: "npm:^1.0.0" prosemirror-state: "npm:^1.0.0" - prosemirror-transform: "npm:^1.10.2" - checksum: 10c0/50f5b7ddcd9dc497a3d2e672da87fd457dae4ed4d5e2af326f71dad66dfa2b7937162d32e5e118703d2234e1cc2596b23fc5fd05a917e9f7d89d6602e0480051 + prosemirror-transform: "npm:^1.0.0" + checksum: 10c0/2de3360c9ecf03776943b776aae3a05106bca69cce68cd5be2970de88fbfd8e37a4c4a1657c9586203e76fc8f65de3737dfd42b7b4e084d3adecd7568503c724 languageName: node linkType: hard @@ -38453,14 +38452,14 @@ __metadata: languageName: node linkType: hard -"prosemirror-dropcursor@npm:1.8.4": - version: 1.8.4 - resolution: "prosemirror-dropcursor@npm:1.8.4" +"prosemirror-dropcursor@npm:1.6.1": + version: 1.6.1 + resolution: "prosemirror-dropcursor@npm:1.6.1" dependencies: prosemirror-state: "npm:^1.0.0" prosemirror-transform: "npm:^1.1.0" prosemirror-view: "npm:^1.1.0" - checksum: 10c0/1c3c708e2b9b04edfed7ec95691eddcd522ebc30ca3f89457a806691b5e3c75f720014302422ec865692c72a350ea869f5cb2a0ef0f8b9ec85608d054676e2c8 + checksum: 10c0/e4bc8bb4abd013a9aed7336c0f819aa5ecb0dd58434fc495437293e38b24d970909454ab7fa512fa4a0428919ea9473adca1b9d588619ff4948db50c1b47dc77 languageName: node linkType: hard @@ -38488,17 +38487,27 @@ __metadata: languageName: node linkType: hard -"prosemirror-inputrules@npm:1.5.1": - version: 1.5.1 - resolution: "prosemirror-inputrules@npm:1.5.1" +"prosemirror-inputrules@npm:1.2.0": + version: 1.2.0 + resolution: "prosemirror-inputrules@npm:1.2.0" dependencies: prosemirror-state: "npm:^1.0.0" prosemirror-transform: "npm:^1.0.0" - checksum: 10c0/cff1ff9f7e726bf324e6cddd2c48984e6b2970cc98cd5dc59e6dbe2e9df0e01e4a2d100c77c721067804170b9607769c0fee7c98f2cfb4f3cff9af919fce31b9 + checksum: 10c0/fd3df9077638d917d5c005afd305c104111e545f9988b7c5ecad39ecc06fd60ff4bf5b020dd7a175b6b7c984f426a63a10bf76fb7eb983bb4995af2245d72a12 + languageName: node + linkType: hard + +"prosemirror-keymap@npm:1.2.0": + version: 1.2.0 + resolution: "prosemirror-keymap@npm:1.2.0" + dependencies: + prosemirror-state: "npm:^1.0.0" + w3c-keyname: "npm:^2.2.0" + checksum: 10c0/6e5bf4c03efd8a529443da0279993893722fe797b7d7a124065effb6118d5a236233c6b4a25487c0a57e86987e9748b82c890e513d98a974bcde0d3d38aa6e96 languageName: node linkType: hard -"prosemirror-keymap@npm:1.2.3, prosemirror-keymap@npm:^1.0.0, prosemirror-keymap@npm:^1.2.3": +"prosemirror-keymap@npm:^1.0.0, prosemirror-keymap@npm:^1.2.3": version: 1.2.3 resolution: "prosemirror-keymap@npm:1.2.3" dependencies: From f460629c377c017b271102fb181d5d31de13d57f Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:42:57 +0200 Subject: [PATCH 26/45] Keep `@babel/runtime` and `reselect` on the versions HASH already uses folio's dependencies ask for `@babel/runtime@^7.29.7` and `reselect@^5.2.0`, which the locked 7.29.7 and 5.2.0 already satisfy, so the lockfile resolves them there instead of moving HASH to 7.29.10 and 5.3.0. --- yarn.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/yarn.lock b/yarn.lock index 1d5e371aef3..dd00058e220 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4108,9 +4108,9 @@ __metadata: linkType: hard "@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.16.3, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.23.8, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.5, @babel/runtime@npm:^7.26.0, @babel/runtime@npm:^7.26.10, @babel/runtime@npm:^7.28.4, @babel/runtime@npm:^7.28.6, @babel/runtime@npm:^7.29.7, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": - version: 7.29.10 - resolution: "@babel/runtime@npm:7.29.10" - checksum: 10c0/32f975da8e9c2cae6b33e1a477f8fe8bbff6722c982b5ce35f99affaf3a7a1b612c8272b43afa0e51d3000928d6f3ba38f9296301115db5640264907c027b0cd + version: 7.29.7 + resolution: "@babel/runtime@npm:7.29.7" + checksum: 10c0/ca11572f7146b21e0bde6a9ed4bb6a89eafbee5f0944c7eb54d0d8a2dac962c33638a1d611e14faa71dfbb92b4b5f9236232208568a6b7d5c6f3f39ddb91771e languageName: node linkType: hard @@ -40256,9 +40256,9 @@ __metadata: linkType: hard "reselect@npm:^5.1.0, reselect@npm:^5.2.0": - version: 5.3.0 - resolution: "reselect@npm:5.3.0" - checksum: 10c0/c79728e4f315152bfb2d82456d35075b705392d9e72c485d7d9b1061bd80af628416216d80f02a42f24dbb24d738aec7fec45806930aaa1d17c32e9875fa1bd6 + version: 5.2.0 + resolution: "reselect@npm:5.2.0" + checksum: 10c0/d0a8497e404b25a769fe792eacae88b9b576c30870450cd243d76ec9427d91a59c4a2cc00d824d283448b444c4c698a8f961d771c8ae39c759313afb31950e2e languageName: node linkType: hard From 9351473ef4be1da69e2bd3544b44789901eb3185 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:19:09 +0200 Subject: [PATCH 27/45] Click the hostile document's links in the DOCX preview test The test clicked the centre of the page, which is blank, so it could not catch a link that runs script. It now clicks each link's text, waits for a popup, and counts requests from every tab. --- .../tests/features/file-preview/docx.spec.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/hash-playwright/tests/features/file-preview/docx.spec.ts b/tests/hash-playwright/tests/features/file-preview/docx.spec.ts index c937e248a10..3419597592c 100644 --- a/tests/hash-playwright/tests/features/file-preview/docx.spec.ts +++ b/tests/hash-playwright/tests/features/file-preview/docx.spec.ts @@ -132,7 +132,7 @@ test("the DOCX preview runs and loads nothing from a hostile document", async ({ }); const outsideRequests: string[] = []; - page.on("request", (request) => { + page.context().on("request", (request) => { if (new URL(request.url()).hostname !== "localhost") { outsideRequests.push(request.url()); } @@ -158,10 +158,17 @@ test("the DOCX preview runs and loads nothing from a hostile document", async ({ preview.locator("[onerror], [onload], [onclick], [onmouseover]"), ).toHaveCount(0); - await page.locator(".layout-page", { hasText: "javascript link" }).click(); - await page.locator(".layout-page", { hasText: "data link" }).click(); + const popup = page + .context() + .waitForEvent("page", { timeout: 1_000 }) + .catch(() => null); + + const documentPage = page.locator(".layout-page"); + await documentPage.getByText("javascript link", { exact: true }).click(); + await documentPage.getByText("data link", { exact: true }).click(); expect(dialogs).toEqual([]); + expect(await popup).toBeNull(); expect(outsideRequests).toEqual([]); }); From 2f3124f299f0165fe63bb22476e7c93bb189ef81 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:38:46 +0200 Subject: [PATCH 28/45] Test that the DOCX page counter follows scrolling and zoom --- .../tests/features/file-preview/docx.spec.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/hash-playwright/tests/features/file-preview/docx.spec.ts b/tests/hash-playwright/tests/features/file-preview/docx.spec.ts index 3419597592c..b166d3e80e7 100644 --- a/tests/hash-playwright/tests/features/file-preview/docx.spec.ts +++ b/tests/hash-playwright/tests/features/file-preview/docx.spec.ts @@ -51,6 +51,31 @@ test.describe("DOCX preview", () => { .toBeGreaterThan(widthBeforeZoom); }); + test("the page counter follows scrolling and keeps its total after a zoom", async ({ + page, + }) => { + const pageCount = testDocumentPages.length; + // folio renders its own hidden page status, which is not a paragraph. + const pageCounter = page.locator("p", { + hasText: new RegExp(`^\\d+ of ${pageCount}$`), + }); + + await expect(pageCounter).toHaveText(`1 of ${pageCount}`); + + await getDocxPage(page, secondPage.heading).evaluate((element) => + element.scrollIntoView({ block: "start" }), + ); + await expect(pageCounter).toHaveText(`2 of ${pageCount}`); + + const docxPage = getDocxPage(page, secondPage.heading); + const widthBeforeZoom = (await docxPage.boundingBox())?.width ?? 0; + await page.getByRole("button", { name: "Zoom in" }).click(); + await expect + .poll(async () => (await docxPage.boundingBox())?.width ?? 0) + .toBeGreaterThan(widthBeforeZoom); + await expect(pageCounter).toBeVisible(); + }); + test("search selects a word and the next result moves to the next page holding it", async ({ page, }) => { From 640dfa51c7e5294a9d786e57ef7969a2132b649b Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:51:59 +0200 Subject: [PATCH 29/45] Move the DOCX page reporting into a useReportedPages hook --- .../shared/docx-preview/folio-document.tsx | 35 +++------------ .../folio-document/use-reported-pages.ts | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+), 29 deletions(-) create mode 100644 apps/hash-frontend/src/pages/shared/docx-preview/folio-document/use-reported-pages.ts diff --git a/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx b/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx index 1bb6539f47d..45e5c0729f5 100644 --- a/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx +++ b/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx @@ -3,9 +3,12 @@ import { Box, CircularProgress } from "@mui/material"; import { DocxEditor, hashFolioAIBlockText } from "@stll/folio-react"; import { getFolioMessages } from "@stll/folio-react/messages"; import escapeRegExp from "lodash/escapeRegExp"; -import { useCallback, useEffect, useImperativeHandle, useRef } from "react"; +import { useCallback, useImperativeHandle, useRef } from "react"; import { IntlProvider } from "use-intl"; +import { useReportedPages } from "./folio-document/use-reported-pages"; + +import type { DocxDocumentPages } from "./folio-document/use-reported-pages"; import type { DocxEditorRef, FolioAIEditSnapshot } from "@stll/folio-react"; import type { Ref } from "react"; @@ -23,7 +26,7 @@ export type DocxDocumentControls = { showHit: (index: number) => void; }; -export type DocxDocumentPages = { current: number; total: number }; +export type { DocxDocumentPages }; type SearchHit = Parameters[0]; @@ -46,38 +49,12 @@ export const FolioDocument = ({ }: FolioDocumentProps) => { const editorRef = useRef(null); const containerRef = useRef(null); - const reportedPages = useRef({ current: 0, total: 0 }); const searchResult = useRef<{ hits: SearchHit[]; snapshot: FolioAIEditSnapshot | null; }>({ hits: [], snapshot: null }); - const reportPages = useCallback(() => { - const editor = editorRef.current; - if (!editor) { - return; - } - const pages = { - current: editor.getCurrentPage(), - total: editor.getTotalPages(), - }; - if ( - pages.current !== reportedPages.current.current || - pages.total !== reportedPages.current.total - ) { - reportedPages.current = pages; - onPagesChange(pages); - } - }, [onPagesChange]); - - /** - * folio lays pages out in the background and has no event for a finished - * layout, so the page count is polled. - */ - useEffect(() => { - const interval = setInterval(reportPages, 500); - return () => clearInterval(interval); - }, [reportPages]); + const reportPages = useReportedPages(editorRef, onPagesChange); /** Collapses the selection `showHit` made, so no earlier hit stays marked. */ const clearShownHit = useCallback(() => { diff --git a/apps/hash-frontend/src/pages/shared/docx-preview/folio-document/use-reported-pages.ts b/apps/hash-frontend/src/pages/shared/docx-preview/folio-document/use-reported-pages.ts new file mode 100644 index 00000000000..986aae4e184 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/docx-preview/folio-document/use-reported-pages.ts @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useRef } from "react"; + +import type { DocxEditorRef } from "@stll/folio-react"; +import type { RefObject } from "react"; + +export type DocxDocumentPages = { current: number; total: number }; + +/** + * Reports folio's current page and page count through `onPagesChange` + * whenever either changes. Returns the reporter for `onScrollTopChange`. + */ +export const useReportedPages = ( + editorRef: RefObject, + onPagesChange: (pages: DocxDocumentPages) => void, +) => { + const reportedPages = useRef({ current: 0, total: 0 }); + + const reportPages = useCallback(() => { + const editor = editorRef.current; + if (!editor) { + return; + } + const pages = { + current: editor.getCurrentPage(), + total: editor.getTotalPages(), + }; + if ( + pages.current !== reportedPages.current.current || + pages.total !== reportedPages.current.total + ) { + reportedPages.current = pages; + onPagesChange(pages); + } + }, [editorRef, onPagesChange]); + + /** folio has no event for a page change, so the pages are polled. */ + useEffect(() => { + const interval = setInterval(reportPages, 500); + return () => clearInterval(interval); + }, [reportPages]); + + return reportPages; +}; From a6db75c793f34bc88f5710360bdd13a1f074850b Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:49:26 +0200 Subject: [PATCH 30/45] Move the DOCX search into a useSearchHits hook The page polling now goes through useEffectEvent instead of useCallback, so the hooks need no manual memoization. --- .../shared/docx-preview/folio-document.tsx | 73 ++------------- .../folio-document/use-reported-pages.ts | 12 ++- .../folio-document/use-search-hits.test.ts | 57 ++++++++++++ .../folio-document/use-search-hits.ts | 91 +++++++++++++++++++ 4 files changed, 163 insertions(+), 70 deletions(-) create mode 100644 apps/hash-frontend/src/pages/shared/docx-preview/folio-document/use-search-hits.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/docx-preview/folio-document/use-search-hits.ts diff --git a/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx b/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx index 45e5c0729f5..786e66d00a0 100644 --- a/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx +++ b/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx @@ -1,15 +1,15 @@ import "@stll/folio-react/standalone.css"; import { Box, CircularProgress } from "@mui/material"; -import { DocxEditor, hashFolioAIBlockText } from "@stll/folio-react"; +import { DocxEditor } from "@stll/folio-react"; import { getFolioMessages } from "@stll/folio-react/messages"; -import escapeRegExp from "lodash/escapeRegExp"; -import { useCallback, useImperativeHandle, useRef } from "react"; +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, FolioAIEditSnapshot } from "@stll/folio-react"; +import type { DocxEditorRef } from "@stll/folio-react"; import type { Ref } from "react"; /** @@ -28,8 +28,6 @@ export type DocxDocumentControls = { export type { DocxDocumentPages }; -type SearchHit = Parameters[0]; - type FolioDocumentProps = { controlsRef: Ref; data: ArrayBuffer; @@ -49,21 +47,9 @@ export const FolioDocument = ({ }: FolioDocumentProps) => { const editorRef = useRef(null); const containerRef = useRef(null); - const searchResult = useRef<{ - hits: SearchHit[]; - snapshot: FolioAIEditSnapshot | null; - }>({ hits: [], snapshot: null }); const reportPages = useReportedPages(editorRef, onPagesChange); - - /** Collapses the selection `showHit` made, so no earlier hit stays marked. */ - const clearShownHit = useCallback(() => { - const pagedEditor = editorRef.current?.getEditorRef(); - const selection = pagedEditor?.getState()?.selection; - if (pagedEditor && selection && !selection.empty) { - pagedEditor.setSelection(selection.head); - } - }, []); + const { search, showHit } = useSearchHits(editorRef); useImperativeHandle( controlsRef, @@ -82,53 +68,10 @@ export const FolioDocument = ({ editor.setZoom(editor.getZoom() + step); onZoomChange(editor.getZoom()); }, - search: (text, ignoreCase) => { - const editor = editorRef.current; - if (!editor || !text) { - searchResult.current = { hits: [], snapshot: null }; - clearShownHit(); - return 0; - } - - /** folio creates its editor view lazily and has no snapshot before it. */ - editor.ensureEditorView({ focus: false }); - const snapshot = editor.createAIEditSnapshot(); - const pattern = new RegExp(escapeRegExp(text), ignoreCase ? "gi" : "g"); - - const hits = (snapshot?.blocks ?? []) - .filter((block) => block.kind !== "diagnostic") - .flatMap((block) => - [...block.text.matchAll(pattern)].map((match): SearchHit => { - const startOffset = match.index; - const endOffset = startOffset + match[0].length; - return { - type: "textRange", - story: "main", - blockId: block.id, - startOffset, - endOffset, - selectedTextHash: hashFolioAIBlockText( - block.text.slice(startOffset, endOffset), - ), - }; - }), - ); - - searchResult.current = { hits, snapshot }; - if (hits.length === 0) { - clearShownHit(); - } - return hits.length; - }, - showHit: (index) => { - const { hits, snapshot } = searchResult.current; - const hit = hits[index]; - if (hit) { - editorRef.current?.showInDocument(hit, snapshot ?? undefined); - } - }, + search, + showHit, }), - [clearShownHit, onZoomChange], + [onZoomChange, search, showHit], ); return ( diff --git a/apps/hash-frontend/src/pages/shared/docx-preview/folio-document/use-reported-pages.ts b/apps/hash-frontend/src/pages/shared/docx-preview/folio-document/use-reported-pages.ts index 986aae4e184..d25b367d0f3 100644 --- a/apps/hash-frontend/src/pages/shared/docx-preview/folio-document/use-reported-pages.ts +++ b/apps/hash-frontend/src/pages/shared/docx-preview/folio-document/use-reported-pages.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useEffect, useEffectEvent, useRef } from "react"; import type { DocxEditorRef } from "@stll/folio-react"; import type { RefObject } from "react"; @@ -15,7 +15,7 @@ export const useReportedPages = ( ) => { const reportedPages = useRef({ current: 0, total: 0 }); - const reportPages = useCallback(() => { + const reportPages = () => { const editor = editorRef.current; if (!editor) { return; @@ -31,13 +31,15 @@ export const useReportedPages = ( reportedPages.current = pages; onPagesChange(pages); } - }, [editorRef, onPagesChange]); + }; + + const pollPages = useEffectEvent(reportPages); /** folio has no event for a page change, so the pages are polled. */ useEffect(() => { - const interval = setInterval(reportPages, 500); + const interval = setInterval(() => pollPages(), 500); return () => clearInterval(interval); - }, [reportPages]); + }, []); return reportPages; }; diff --git a/apps/hash-frontend/src/pages/shared/docx-preview/folio-document/use-search-hits.test.ts b/apps/hash-frontend/src/pages/shared/docx-preview/folio-document/use-search-hits.test.ts new file mode 100644 index 00000000000..53e51a8a642 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/docx-preview/folio-document/use-search-hits.test.ts @@ -0,0 +1,57 @@ +import { hashFolioAIBlockText } from "@stll/folio-react"; +import { describe, expect, it } from "vitest"; + +import { findSearchHits } from "./use-search-hits"; + +const textRange = (blockId: string, startOffset: number, text: string) => ({ + type: "textRange", + story: "main", + blockId, + startOffset, + endOffset: startOffset + text.length, + selectedTextHash: hashFolioAIBlockText(text), +}); + +describe("findSearchHits", () => { + it("finds every occurrence in every block", () => { + const blocks = [ + { id: "first", kind: "heading", text: "Lease and lease terms" }, + { id: "second", kind: "paragraph", text: "The lease ends" }, + ] as const; + + expect(findSearchHits(blocks, "lease", false)).toEqual([ + textRange("first", 10, "lease"), + textRange("second", 4, "lease"), + ]); + }); + + it("matches other cases only when told to ignore case", () => { + const blocks = [{ id: "only", kind: "paragraph", text: "Lease" }] as const; + + expect(findSearchHits(blocks, "lease", false)).toEqual([]); + expect(findSearchHits(blocks, "lease", true)).toEqual([ + textRange("only", 0, "Lease"), + ]); + }); + + it("treats the search text literally", () => { + const blocks = [ + { id: "only", kind: "paragraph", text: "abc a.c" }, + ] as const; + + expect(findSearchHits(blocks, "a.c", false)).toEqual([ + textRange("only", 4, "a.c"), + ]); + }); + + it("skips diagnostic blocks", () => { + const blocks = [ + { id: "note", kind: "diagnostic", text: "lease" }, + { id: "body", kind: "listItem", text: "lease" }, + ] as const; + + expect(findSearchHits(blocks, "lease", false)).toEqual([ + textRange("body", 0, "lease"), + ]); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/docx-preview/folio-document/use-search-hits.ts b/apps/hash-frontend/src/pages/shared/docx-preview/folio-document/use-search-hits.ts new file mode 100644 index 00000000000..58e8045dfbb --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/docx-preview/folio-document/use-search-hits.ts @@ -0,0 +1,91 @@ +import { hashFolioAIBlockText } from "@stll/folio-react"; +import escapeRegExp from "lodash/escapeRegExp"; +import { useRef } from "react"; + +import type { DocxEditorRef, FolioAIEditSnapshot } from "@stll/folio-react"; +import type { RefObject } from "react"; + +type SearchHit = Parameters[0]; + +type SnapshotBlock = Pick< + FolioAIEditSnapshot["blocks"][number], + "id" | "kind" | "text" +>; + +/** Finds every occurrence of `text` in the document's text blocks. */ +export const findSearchHits = ( + blocks: readonly SnapshotBlock[], + text: string, + ignoreCase: boolean, +): SearchHit[] => { + const pattern = new RegExp(escapeRegExp(text), ignoreCase ? "gi" : "g"); + + return blocks + .filter((block) => block.kind !== "diagnostic") + .flatMap((block) => + [...block.text.matchAll(pattern)].map((match): SearchHit => { + const startOffset = match.index; + const endOffset = startOffset + match[0].length; + return { + type: "textRange", + story: "main", + blockId: block.id, + startOffset, + endOffset, + selectedTextHash: hashFolioAIBlockText( + block.text.slice(startOffset, endOffset), + ), + }; + }), + ); +}; + +/** + * Searches the document folio shows and selects a hit in it. `search` + * returns how many hits there are, and `showHit` scrolls to one of them. + */ +export const useSearchHits = (editorRef: RefObject) => { + const searchResult = useRef<{ + hits: SearchHit[]; + snapshot: FolioAIEditSnapshot | null; + }>({ hits: [], snapshot: null }); + + /** Collapses the selection `showHit` made, so no earlier hit stays marked. */ + const clearShownHit = () => { + const pagedEditor = editorRef.current?.getEditorRef(); + const selection = pagedEditor?.getState()?.selection; + if (pagedEditor && selection && !selection.empty) { + pagedEditor.setSelection(selection.head); + } + }; + + const search = (text: string, ignoreCase: boolean) => { + const editor = editorRef.current; + if (!editor || !text) { + searchResult.current = { hits: [], snapshot: null }; + clearShownHit(); + return 0; + } + + /** folio creates its editor view lazily and has no snapshot before it. */ + editor.ensureEditorView({ focus: false }); + const snapshot = editor.createAIEditSnapshot(); + const hits = findSearchHits(snapshot?.blocks ?? [], text, ignoreCase); + + searchResult.current = { hits, snapshot }; + if (hits.length === 0) { + clearShownHit(); + } + return hits.length; + }; + + const showHit = (index: number) => { + const { hits, snapshot } = searchResult.current; + const hit = hits[index]; + if (hit) { + editorRef.current?.showInDocument(hit, snapshot ?? undefined); + } + }; + + return { search, showHit }; +}; From 93ccc128e25cde627ac24ca5a71f40f29bac8ce1 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:23:24 +0200 Subject: [PATCH 31/45] Leave the DOCX zoom to the zoom buttons folio's ctrl+wheel zoom changes the zoom without telling HASH, so the zoom label went stale. With it off, ctrl+wheel zooms the browser, as in the PDF preview. --- .../src/pages/shared/docx-preview/folio-document.tsx | 1 + .../tests/features/file-preview/docx.spec.ts | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx b/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx index 786e66d00a0..c6d01f36ece 100644 --- a/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx +++ b/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx @@ -81,6 +81,7 @@ export const FolioDocument = ({ ref={editorRef} autoOpenReviewSidebar={false} documentBuffer={data} + enableWheelZoom={false} keyboardShortcuts="none" loadingIndicator={} onError={onError} diff --git a/tests/hash-playwright/tests/features/file-preview/docx.spec.ts b/tests/hash-playwright/tests/features/file-preview/docx.spec.ts index b166d3e80e7..9ed2b32cc68 100644 --- a/tests/hash-playwright/tests/features/file-preview/docx.spec.ts +++ b/tests/hash-playwright/tests/features/file-preview/docx.spec.ts @@ -51,6 +51,16 @@ test.describe("DOCX preview", () => { .toBeGreaterThan(widthBeforeZoom); }); + test("ctrl+wheel leaves the zoom to the zoom buttons", async ({ page }) => { + await getDocxPage(page, firstPage.heading).hover(); + await page.keyboard.down("Control"); + await page.mouse.wheel(0, -500); + await page.keyboard.up("Control"); + + await page.getByRole("button", { name: "Zoom in" }).click(); + await expect(page.getByText("120%", { exact: true })).toBeVisible(); + }); + test("the page counter follows scrolling and keeps its total after a zoom", async ({ page, }) => { From a193e97c0f50f939308f309f413dd7fbd57fa232 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:28:47 +0200 Subject: [PATCH 32/45] Keep the DOCX zoom between 25% and 400% Zooming out had no floor, so the fifth click took the pages to 0%. The bounds are folio's own. --- .../src/pages/shared/docx-preview/folio-document.tsx | 6 +++++- .../tests/features/file-preview/docx.spec.ts | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx b/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx index c6d01f36ece..ba3640290ac 100644 --- a/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx +++ b/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx @@ -2,6 +2,7 @@ import "@stll/folio-react/standalone.css"; import { Box, CircularProgress } from "@mui/material"; import { DocxEditor } from "@stll/folio-react"; import { getFolioMessages } from "@stll/folio-react/messages"; +import clamp from "lodash/clamp"; import { useImperativeHandle, useRef } from "react"; import { IntlProvider } from "use-intl"; @@ -38,6 +39,9 @@ type FolioDocumentProps = { const folioMessages = getFolioMessages("en"); +const minZoom = 0.25; +const maxZoom = 4; + export const FolioDocument = ({ controlsRef, data, @@ -65,7 +69,7 @@ export const FolioDocument = ({ if (!editor) { return; } - editor.setZoom(editor.getZoom() + step); + editor.setZoom(clamp(editor.getZoom() + step, minZoom, maxZoom)); onZoomChange(editor.getZoom()); }, search, diff --git a/tests/hash-playwright/tests/features/file-preview/docx.spec.ts b/tests/hash-playwright/tests/features/file-preview/docx.spec.ts index 9ed2b32cc68..a5705b6839a 100644 --- a/tests/hash-playwright/tests/features/file-preview/docx.spec.ts +++ b/tests/hash-playwright/tests/features/file-preview/docx.spec.ts @@ -51,6 +51,15 @@ test.describe("DOCX preview", () => { .toBeGreaterThan(widthBeforeZoom); }); + test("zooming out stops at 25%", async ({ page }) => { + const zoomOut = page.getByRole("button", { name: "Zoom out" }); + + for (const label of ["80%", "60%", "40%", "25%", "25%"]) { + await zoomOut.click(); + await expect(page.getByText(label, { exact: true })).toBeVisible(); + } + }); + test("ctrl+wheel leaves the zoom to the zoom buttons", async ({ page }) => { await getDocxPage(page, firstPage.heading).hover(); await page.keyboard.down("Control"); From c36b1f7a26af6e1f4772b1d78ebb279ab995ab13 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:38:45 +0200 Subject: [PATCH 33/45] Load only the English folio messages getFolioMessages bundles all 17 locales. Importing the English catalog alone takes about 83 KB gzip off the DOCX preview chunk. --- .../src/pages/shared/docx-preview/folio-document.tsx | 4 +--- .../shared/docx-preview/stll-folio-react-en-messages.d.ts | 8 ++++++++ .../shared/docx-preview/stll-folio-react-messages.d.ts | 8 -------- 3 files changed, 9 insertions(+), 11 deletions(-) create mode 100644 apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-react-en-messages.d.ts delete mode 100644 apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-react-messages.d.ts diff --git a/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx b/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx index ba3640290ac..71477d102bd 100644 --- a/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx +++ b/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx @@ -1,7 +1,7 @@ import "@stll/folio-react/standalone.css"; import { Box, CircularProgress } from "@mui/material"; import { DocxEditor } from "@stll/folio-react"; -import { getFolioMessages } from "@stll/folio-react/messages"; +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"; @@ -37,8 +37,6 @@ type FolioDocumentProps = { onZoomChange: (zoom: number) => void; }; -const folioMessages = getFolioMessages("en"); - const minZoom = 0.25; const maxZoom = 4; diff --git a/apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-react-en-messages.d.ts b/apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-react-en-messages.d.ts new file mode 100644 index 00000000000..681ce1182c9 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-react-en-messages.d.ts @@ -0,0 +1,8 @@ +/** + * The frontend's `moduleResolution: "node"` ignores package `exports`, so + * TypeScript cannot find this subpath's own types. Webpack resolves it at + * runtime. The subpath 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"; +} diff --git a/apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-react-messages.d.ts b/apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-react-messages.d.ts deleted file mode 100644 index 7d541303ba4..00000000000 --- a/apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-react-messages.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * The frontend's `moduleResolution: "node"` ignores package `exports`, so - * TypeScript cannot find this subpath's own types. Webpack resolves it at - * runtime. The subpath re-exports `@stll/folio-core`'s messages. - */ -declare module "@stll/folio-react/messages" { - export * from "@stll/folio-core/dist/i18n/messages"; -} From 10dc8dec4746a211c0c8ccfb74b5b68405aaca73 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:39:03 +0200 Subject: [PATCH 34/45] Return a plain function from useFindSearchHits Nothing depends on the search function's identity, so it needs no useCallback with React Compiler on. --- .../src/pages/shared/pdf-preview/pdf-search.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/pdf-preview/pdf-search.tsx b/apps/hash-frontend/src/pages/shared/pdf-preview/pdf-search.tsx index 1013ec3cae6..a7b68363515 100644 --- a/apps/hash-frontend/src/pages/shared/pdf-preview/pdf-search.tsx +++ b/apps/hash-frontend/src/pages/shared/pdf-preview/pdf-search.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import type { TextItem } from "pdfjs-dist/types/src/display/api"; import type { DocumentCallback } from "react-pdf/dist/cjs/shared/types"; @@ -176,9 +176,6 @@ export const useFindSearchHits = (document: DocumentCallback | null) => { void getPages(); }, [document]); - return useCallback( - (searchString: string, ignoreCase: boolean) => - findStringInPages({ pages, searchString, ignoreCase }), - [pages], - ); + return (searchString: string, ignoreCase: boolean) => + findStringInPages({ pages, searchString, ignoreCase }); }; From e11359a20d7ac49ba657dd7fded953a1e7418d31 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:39:31 +0200 Subject: [PATCH 35/45] Report DOCX download errors through useEffectEvent handleError becomes a plain function, so the download effect only depends on the URL and needs no useCallback with React Compiler on. --- .../src/pages/shared/docx-preview.tsx | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/docx-preview.tsx b/apps/hash-frontend/src/pages/shared/docx-preview.tsx index 54908962a89..544e4aeb0e5 100644 --- a/apps/hash-frontend/src/pages/shared/docx-preview.tsx +++ b/apps/hash-frontend/src/pages/shared/docx-preview.tsx @@ -1,6 +1,6 @@ import { Box, CircularProgress, Stack, Typography } from "@mui/material"; import * as Sentry from "@sentry/nextjs"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; import { FullScreen, useFullScreenHandle } from "react-full-screen"; import { @@ -72,15 +72,14 @@ export const DocxPreview = ({ * 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 = useCallback( - (error: unknown) => { - Sentry.captureException(error); - if (!controlsRef.current?.hasPages()) { - onUnavailable(); - } - }, - [onUnavailable], - ); + const handleError = (error: unknown) => { + Sentry.captureException(error); + if (!controlsRef.current?.hasPages()) { + onUnavailable(); + } + }; + + const handleDownloadError = useEffectEvent(handleError); useEffect(() => { let cancelled = false; @@ -104,14 +103,14 @@ export const DocxPreview = ({ }) .catch((error: unknown) => { if (!cancelled) { - handleError(error); + handleDownloadError(error); } }); return () => { cancelled = true; }; - }, [url, handleError]); + }, [url]); const selectSearchHit = (index: number | null) => { setSelectedSearchHit(index); From 3a7d679f182c96b8a4a62941cc38a932f1a6e4ec Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:39:50 +0200 Subject: [PATCH 36/45] Inline the DOCX onUnavailable handler The DOCX download effect no longer depends on it, so it needs no useCallback with React Compiler on. --- .../shared/entity/entity-editor/file-preview-section.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/file-preview-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/file-preview-section.tsx index 47c81fcbdbc..4ded7021181 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/file-preview-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/file-preview-section.tsx @@ -6,7 +6,7 @@ import { Typography, } from "@mui/material"; import dynamic from "next/dynamic"; -import { useCallback, useState } from "react"; +import { useState } from "react"; import { FullScreen, useFullScreenHandle } from "react-full-screen"; import { extractWebIdFromEntityId } from "@blockprotocol/type-system"; @@ -203,11 +203,6 @@ export const FilePreviewSection = () => { /** The file the DOCX preview could not show, which then gets the icon view. */ const [unavailableDocxUrl, setUnavailableDocxUrl] = useState(); - const markDocxUnavailable = useCallback( - () => setUnavailableDocxUrl(fileUrl), - [fileUrl], - ); - if (!fileUrl) { return null; } @@ -375,7 +370,7 @@ export const FilePreviewSection = () => { ) : isDocx ? ( setUnavailableDocxUrl(fileUrl)} setShowSearch={setShowSearch} showSearch={showSearch} url={fileUrl} From e4dae3105dfc39bb5c4e1e69bc351f9eb431d3d0 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:23:23 +0200 Subject: [PATCH 37/45] Move PDF and DOCX file detection into getFileProperties --- .../entity-editor/file-preview-section.tsx | 19 ++++++++----------- .../src/pages/shared/get-file-properties.ts | 10 ++++++++++ 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/file-preview-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/file-preview-section.tsx index 4ded7021181..e45fbecdc43 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/file-preview-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/file-preview-section.tsx @@ -53,9 +53,6 @@ const DocxPreview = dynamic( }, ); -const docxMimeType = - "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; - const ActionButtonsContainer = ({ children }: PropsWithChildren) => ( { const { isDirty, readonly, closedMultiEntityType, entity } = useEntityEditor(); - const { isImage, fileUrl } = getFileProperties(entity.properties); + const { + isImage, + isPdf, + isDocx: isDocxFile, + fileUrl, + } = getFileProperties(entity.properties); const fullScreenHandle = useFullScreenHandle(); @@ -207,7 +209,7 @@ export const FilePreviewSection = () => { return null; } - const { description, displayName, fileName, mimeType } = simplifyProperties( + const { description, displayName, fileName } = simplifyProperties( entity.properties as FileProperties, ); @@ -216,12 +218,7 @@ export const FilePreviewSection = () => { const alt = description ?? title; - const isPdf = mimeType === "application/pdf" || fileName?.endsWith(".pdf"); - - const isDocx = - (mimeType === docxMimeType || - !!fileName?.toLowerCase().endsWith(".docx")) && - unavailableDocxUrl !== fileUrl; + const isDocx = isDocxFile && unavailableDocxUrl !== fileUrl; return ( { const { description, displayName, fileUrl, fileName, mimeType, fileSize } = simplifyProperties(properties as FileProperties); const isImage = mimeType?.startsWith("image/") ?? null; + const isPdf = mimeType === "application/pdf" || !!fileName?.endsWith(".pdf"); + + const isDocx = + mimeType === docxMimeType || !!fileName?.toLowerCase().endsWith(".docx"); + return { description, displayName, fileName, fileSize, fileUrl, + isDocx, isImage, + isPdf, mimeType, }; }; From febe846c8c6aab7f3a8abdf3386f2222f7aa5adb Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:25:17 +0200 Subject: [PATCH 38/45] Name the previous and next search hit in FilePreviewSearch --- .../src/pages/shared/file-preview-search.tsx | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/file-preview-search.tsx b/apps/hash-frontend/src/pages/shared/file-preview-search.tsx index 4f591d3c082..cee4bfe47bb 100644 --- a/apps/hash-frontend/src/pages/shared/file-preview-search.tsx +++ b/apps/hash-frontend/src/pages/shared/file-preview-search.tsx @@ -49,6 +49,14 @@ export const FilePreviewSearch = ({ const debouncedSearch = debounce(onSearch, 300); + const previousHitIndex = + selectedIndex !== null && selectedIndex > 0 ? selectedIndex - 1 : null; + + const nextHitIndex = + selectedIndex !== null && selectedIndex < total - 1 + ? selectedIndex + 1 + : null; + useEffect(() => { if (showSearch) { inputRef.current?.focus(); @@ -80,10 +88,8 @@ export const FilePreviewSearch = ({ ), }} onKeyDown={(event) => { - if (event.key === "Enter") { - if (selectedIndex !== null && selectedIndex < total - 1) { - onSelectHit(selectedIndex + 1); - } + if (event.key === "Enter" && nextHitIndex !== null) { + onSelectHit(nextHitIndex); } }} onChange={(event) => { @@ -129,10 +135,10 @@ export const FilePreviewSearch = ({ > { - if (selectedIndex !== null && selectedIndex > 0) { - onSelectHit(selectedIndex - 1); + if (previousHitIndex !== null) { + onSelectHit(previousHitIndex); } }} sx={{ "& svg": { fontSize: 10 }, p: 0.6 }} @@ -151,10 +157,10 @@ export const FilePreviewSearch = ({ { - if (selectedIndex !== null && selectedIndex < total - 1) { - onSelectHit(selectedIndex + 1); + if (nextHitIndex !== null) { + onSelectHit(nextHitIndex); } }} sx={{ "& svg": { fontSize: 10 }, p: 0.6 }} From bda1d378e4f9d6994f4ec78fc62fb7546c9e3b15 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:27:10 +0200 Subject: [PATCH 39/45] Share page navigation between the PDF and DOCX previews --- .../src/pages/shared/docx-preview.tsx | 33 ++++---------- .../shared/file-preview-page-navigation.tsx | 44 +++++++++++++++++++ .../src/pages/shared/pdf-preview.tsx | 37 +++------------- 3 files changed, 58 insertions(+), 56 deletions(-) create mode 100644 apps/hash-frontend/src/pages/shared/file-preview-page-navigation.tsx diff --git a/apps/hash-frontend/src/pages/shared/docx-preview.tsx b/apps/hash-frontend/src/pages/shared/docx-preview.tsx index 544e4aeb0e5..a5d7751e9d5 100644 --- a/apps/hash-frontend/src/pages/shared/docx-preview.tsx +++ b/apps/hash-frontend/src/pages/shared/docx-preview.tsx @@ -5,7 +5,6 @@ import { FullScreen, useFullScreenHandle } from "react-full-screen"; import { ArrowDownLeftAndArrowUpRightToCenterIcon, - ArrowRightRegularIcon, ArrowUpRightAndArrowDownLeftFromCenterIcon, MagnifyingGlassMinusLightIcon, MagnifyingGlassPlusLightIcon, @@ -13,6 +12,7 @@ import { 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"; @@ -185,30 +185,13 @@ export const DocxPreview = ({ zIndex: 20, }} > - controlsRef.current?.goToPage(pages.current - 1)} - > - - - palette.gray[70], - textAlign: "center", - width: (pages.total.toString().length * 2 + 4) * 7, - }} - > - {`${pages.current} of ${pages.total}`} - - = pages.total} - onClick={() => controlsRef.current?.goToPage(pages.current + 1)} - > - - + + controlsRef.current?.goToPage(pageNumber) + } + totalPages={pages.total} + /> void; + totalPages: number; +}; + +export const FilePreviewPageNavigation = ({ + currentPage, + onSelectPage, + totalPages, +}: FilePreviewPageNavigationProps) => ( + <> + onSelectPage(currentPage - 1)} + > + + + palette.gray[70], + textAlign: "center", + width: (totalPages.toString().length * 2 + 4) * 7, + }} + > + {`${currentPage} of ${totalPages}`} + + = totalPages} + onClick={() => onSelectPage(currentPage + 1)} + > + + + +); diff --git a/apps/hash-frontend/src/pages/shared/pdf-preview.tsx b/apps/hash-frontend/src/pages/shared/pdf-preview.tsx index b1621b5734e..9225ad418c7 100644 --- a/apps/hash-frontend/src/pages/shared/pdf-preview.tsx +++ b/apps/hash-frontend/src/pages/shared/pdf-preview.tsx @@ -8,7 +8,6 @@ import { Document, Page, pdfjs } from "react-pdf"; import { ArrowDownLeftAndArrowUpRightToCenterIcon, - ArrowRightRegularIcon, ArrowUpRightAndArrowDownLeftFromCenterIcon, MagnifyingGlassMinusLightIcon, MagnifyingGlassPlusLightIcon, @@ -16,6 +15,7 @@ import { import { typedKeys } from "@local/advanced-types/typed-entries"; 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 { @@ -330,36 +330,11 @@ export const PdfPreview = ({ }} > - - setSelectedPageNumber(selectedPageNumber - 1) - } - > - - - palette.gray[70], - textAlign: "center", - width: (totalPages.toString().length * 2 + 4) * 7, - }} - > - {`${selectedPageNumber} of ${totalPages}`} - - - setSelectedPageNumber(selectedPageNumber + 1) - } - > - - + From 1d71ea62d457eb737bbeca7df85776b418c2a54b Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:28:13 +0200 Subject: [PATCH 40/45] Share the file preview's maximum height between the PDF and DOCX previews --- .../src/pages/shared/docx-preview.tsx | 18 ++------------- .../src/pages/shared/pdf-preview.tsx | 22 +++---------------- .../shared/use-file-preview-max-height.ts | 22 +++++++++++++++++++ 3 files changed, 27 insertions(+), 35 deletions(-) create mode 100644 apps/hash-frontend/src/pages/shared/use-file-preview-max-height.ts diff --git a/apps/hash-frontend/src/pages/shared/docx-preview.tsx b/apps/hash-frontend/src/pages/shared/docx-preview.tsx index a5d7751e9d5..87d94f455b9 100644 --- a/apps/hash-frontend/src/pages/shared/docx-preview.tsx +++ b/apps/hash-frontend/src/pages/shared/docx-preview.tsx @@ -15,6 +15,7 @@ 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, @@ -55,18 +56,7 @@ export const DocxPreview = ({ const fullScreenHandle = useFullScreenHandle(); - const [viewportHeight, setViewportHeight] = useState( - document.documentElement.clientHeight, - ); - - useEffect(() => { - const handleResize = () => { - setViewportHeight(document.documentElement.clientHeight); - }; - - window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); - }, []); + const height = useFilePreviewMaxHeight(fullScreenHandle.active); /** * An error before any page is laid out means the document cannot be shown. @@ -126,10 +116,6 @@ export const DocxPreview = ({ selectSearchHit(hitCount > 0 ? 0 : null); }; - const height = fullScreenHandle.active - ? viewportHeight - : viewportHeight - 120; - return ( { - const handleResize = () => { - setViewportHeight(document.documentElement.clientHeight); - }; - - window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); - }, []); - const fullScreenHandle = useFullScreenHandle(); - const viewportVerticalPadding = fullScreenHandle.active ? 0 : 120; + const maxHeight = useFilePreviewMaxHeight(fullScreenHandle.active); - const pageContainerHeight = Math.ceil( - fullPageHeight > viewportHeight - viewportVerticalPadding - ? viewportHeight - viewportVerticalPadding - : fullPageHeight, - ); + const pageContainerHeight = Math.ceil(Math.min(fullPageHeight, maxHeight)); const pageWidth = Math.ceil(pageContainerHeight / pageWidthHeightRatio); diff --git a/apps/hash-frontend/src/pages/shared/use-file-preview-max-height.ts b/apps/hash-frontend/src/pages/shared/use-file-preview-max-height.ts new file mode 100644 index 00000000000..3eeb2489f40 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/use-file-preview-max-height.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from "react"; + +/** + * The tallest a file preview may be: the viewport's height, less room for the + * page around the preview unless it is full screen. + */ +export const useFilePreviewMaxHeight = (isFullScreen: boolean) => { + const [viewportHeight, setViewportHeight] = useState( + document.documentElement.clientHeight, + ); + + useEffect(() => { + const handleResize = () => { + setViewportHeight(document.documentElement.clientHeight); + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + return isFullScreen ? viewportHeight : viewportHeight - 120; +}; From c495fb87696120246a42c914b88bd81b22813ead Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:26:19 +0200 Subject: [PATCH 41/45] Move the folio module declarations to the frontend's `folio.d.ts` --- apps/hash-frontend/folio.d.ts | 15 +++++++++++++++ .../docx-preview/stll-folio-core-ai-edits.d.ts | 7 ------- .../stll-folio-react-en-messages.d.ts | 8 -------- 3 files changed, 15 insertions(+), 15 deletions(-) create mode 100644 apps/hash-frontend/folio.d.ts delete mode 100644 apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-core-ai-edits.d.ts delete mode 100644 apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-react-en-messages.d.ts diff --git a/apps/hash-frontend/folio.d.ts b/apps/hash-frontend/folio.d.ts new file mode 100644 index 00000000000..31aade4f536 --- /dev/null +++ b/apps/hash-frontend/folio.d.ts @@ -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"; +} diff --git a/apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-core-ai-edits.d.ts b/apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-core-ai-edits.d.ts deleted file mode 100644 index 50f5bed294d..00000000000 --- a/apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-core-ai-edits.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * `@stll/folio-react`'s types import this subpath, which the frontend's - * `moduleResolution: "node"` cannot find because it ignores package `exports`. - */ -declare module "@stll/folio-core/ai-edits" { - export * from "@stll/folio-core/dist/ai-edits/index"; -} diff --git a/apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-react-en-messages.d.ts b/apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-react-en-messages.d.ts deleted file mode 100644 index 681ce1182c9..00000000000 --- a/apps/hash-frontend/src/pages/shared/docx-preview/stll-folio-react-en-messages.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * The frontend's `moduleResolution: "node"` ignores package `exports`, so - * TypeScript cannot find this subpath's own types. Webpack resolves it at - * runtime. The subpath 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"; -} From b97f668665bc73bd48ea8426af1f3aaa8649e925 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:22:52 +0200 Subject: [PATCH 42/45] Label the file preview's match-case button and show its pressed state --- .../src/pages/shared/file-preview-search.tsx | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/file-preview-search.tsx b/apps/hash-frontend/src/pages/shared/file-preview-search.tsx index cee4bfe47bb..f157516daf9 100644 --- a/apps/hash-frontend/src/pages/shared/file-preview-search.tsx +++ b/apps/hash-frontend/src/pages/shared/file-preview-search.tsx @@ -109,23 +109,26 @@ export const FilePreviewSearch = ({ value={searchText} /> - { - debouncedSearch(searchText, !ignoreCase); - setIgnoreCase(!ignoreCase); - inputRef.current?.focus(); - }} - sx={{ "& svg": { fontSize: 13 }, p: 0.5 }} - > - - ignoreCase ? undefined : palette.blue[70], - transition: ({ transitions }) => transitions.create("fill"), + + { + debouncedSearch(searchText, !ignoreCase); + setIgnoreCase(!ignoreCase); + inputRef.current?.focus(); }} - /> - + sx={{ "& svg": { fontSize: 13 }, p: 0.5 }} + > + + ignoreCase ? undefined : palette.blue[70], + transition: ({ transitions }) => transitions.create("fill"), + }} + /> + + Date: Fri, 16 Oct 2026 18:29:43 +0200 Subject: [PATCH 43/45] Round the page navigation's background to match its buttons --- apps/hash-frontend/src/pages/shared/docx-preview.tsx | 1 + apps/hash-frontend/src/pages/shared/pdf-preview.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/hash-frontend/src/pages/shared/docx-preview.tsx b/apps/hash-frontend/src/pages/shared/docx-preview.tsx index 87d94f455b9..0ef607c05fd 100644 --- a/apps/hash-frontend/src/pages/shared/docx-preview.tsx +++ b/apps/hash-frontend/src/pages/shared/docx-preview.tsx @@ -165,6 +165,7 @@ export const DocxPreview = ({ gap={1} sx={{ background: "rgba(255, 255, 255, 0.9)", + borderRadius: "4px", position: "absolute", bottom: 20, left: 20, diff --git a/apps/hash-frontend/src/pages/shared/pdf-preview.tsx b/apps/hash-frontend/src/pages/shared/pdf-preview.tsx index acca961e0bb..6923d18ef97 100644 --- a/apps/hash-frontend/src/pages/shared/pdf-preview.tsx +++ b/apps/hash-frontend/src/pages/shared/pdf-preview.tsx @@ -307,6 +307,7 @@ export const PdfPreview = ({ alignItems="center" sx={{ background: "rgba(255, 255, 255, 0.9)", + borderRadius: "4px", position: "sticky", bottom: 0, margin: "0 auto", From 546de2221ce63a369f7ac1f3dfc33b424423d037 Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:10:36 +0200 Subject: [PATCH 44/45] Hide the file preview's "Open in new tab" button for DOCX files --- .../entity-editor/file-preview-section.tsx | 30 ++++++++++--------- .../tests/features/file-preview/docx.spec.ts | 10 +++++++ 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/file-preview-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/file-preview-section.tsx index e45fbecdc43..cb358117d71 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/file-preview-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/file-preview-section.tsx @@ -301,20 +301,22 @@ export const FilePreviewSection = () => {
- - - - - - - + {!isDocxFile && ( + + + + + + + + )}
} > diff --git a/tests/hash-playwright/tests/features/file-preview/docx.spec.ts b/tests/hash-playwright/tests/features/file-preview/docx.spec.ts index a5705b6839a..978901b544b 100644 --- a/tests/hash-playwright/tests/features/file-preview/docx.spec.ts +++ b/tests/hash-playwright/tests/features/file-preview/docx.spec.ts @@ -123,6 +123,13 @@ test.describe("DOCX preview", () => { await expect(page.locator("[data-folio-selection-rect]")).toHaveCount(0); }); + test("offers no Open in new tab button", async ({ page }) => { + await expect(page.getByRole("button", { name: "Download" })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Open in new tab" }), + ).toHaveCount(0); + }); + test("the preview cannot be edited", async ({ page }) => { const docxPage = getDocxPage(page, firstPage.heading); const textBefore = (await docxPage.textContent()) ?? ""; @@ -247,6 +254,9 @@ test("a corrupt .docx file falls back to the icon view and keeps the download", ).toBeVisible({ timeout: 15_000 }); await expect(page.locator(".layout-page")).toHaveCount(0); await expect(page.getByRole("button", { name: "Download" })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Open in new tab" }), + ).toHaveCount(0); }); test.describe("a .docx file folio cannot draw", () => { From 2a3521226bd4c7d63541fc46b027cb2474cb1cfe Mon Sep 17 00:00:00 2001 From: Reza Banazadeh <92025027+0xRezaa@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:23:13 +0200 Subject: [PATCH 45/45] Throttle the file preview search instead of debouncing it --- .../src/pages/shared/file-preview-search.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/file-preview-search.tsx b/apps/hash-frontend/src/pages/shared/file-preview-search.tsx index f157516daf9..a6be798aa0e 100644 --- a/apps/hash-frontend/src/pages/shared/file-preview-search.tsx +++ b/apps/hash-frontend/src/pages/shared/file-preview-search.tsx @@ -7,7 +7,7 @@ import { Tooltip, Typography, } from "@mui/material"; -import debounce from "lodash/debounce"; +import throttle from "lodash/throttle"; import { useEffect, useRef, useState } from "react"; import { @@ -24,7 +24,7 @@ import { GrayToBlueIconButton } from "./gray-to-blue-icon-button"; type FilePreviewSearchProps = { closeSearch: () => void; /** - * Called, debounced, when the search text or the case setting changes, + * Called, throttled, when the search text or the case setting changes, * and at once with an empty `text` when the search is cleared. */ onSearch: (text: string, ignoreCase: boolean) => void; @@ -47,7 +47,7 @@ export const FilePreviewSearch = ({ const inputRef = useRef(null); - const debouncedSearch = debounce(onSearch, 300); + const throttledSearch = throttle(onSearch, 300); const previousHitIndex = selectedIndex !== null && selectedIndex > 0 ? selectedIndex - 1 : null; @@ -74,6 +74,7 @@ export const FilePreviewSearch = ({ { + throttledSearch.cancel(); setSearchText(""); onSearch("", ignoreCase); closeSearch(); @@ -95,7 +96,7 @@ export const FilePreviewSearch = ({ onChange={(event) => { const text = event.target.value; setSearchText(text); - debouncedSearch(text, ignoreCase); + throttledSearch(text, ignoreCase); }} placeholder="Search the document..." sx={{ @@ -114,7 +115,7 @@ export const FilePreviewSearch = ({ aria-label="Match case" aria-pressed={!ignoreCase} onClick={() => { - debouncedSearch(searchText, !ignoreCase); + throttledSearch(searchText, !ignoreCase); setIgnoreCase(!ignoreCase); inputRef.current?.focus(); }}