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/package.json b/apps/hash-frontend/package.json index f6c6fbcd389..bab77358662 100644 --- a/apps/hash-frontend/package.json +++ b/apps/hash-frontend/package.json @@ -69,6 +69,8 @@ "@sigma/edge-curve": "patch:@sigma/edge-curve@npm%3A3.0.0-beta.16#~/.yarn/patches/@sigma-edge-curve-npm-3.0.0-beta.16-3d8b985284.patch", "@sigma/node-border": "3.0.0", "@sigma/node-image": "3.0.0", + "@stll/folio-core": "0.60.0", + "@stll/folio-react": "0.28.0", "@svgr/webpack": "8.1.0", "@tanstack/react-table": "8.21.3", "@tldraw/editor": "patch:@tldraw/editor@npm%3A2.0.0-alpha.12#~/.yarn/patches/@tldraw-editor-npm-2.0.0-alpha.12-ba59bf001c.patch", @@ -114,10 +116,13 @@ "prosemirror-collab": "1.3.0", "prosemirror-commands": "1.3.1", "prosemirror-dev-tools": "4.3.0", + "prosemirror-gapcursor": "1.4.1", + "prosemirror-history": "1.5.1", "prosemirror-inputrules": "1.2.0", "prosemirror-keymap": "1.2.0", "prosemirror-model": "1.25.12", "prosemirror-state": "1.4.4", + "prosemirror-tables": "1.8.5", "prosemirror-transform": "1.12.2", "prosemirror-view": "1.42.6", "react": "19.2.6", @@ -144,8 +149,12 @@ "signia-react": "0.1.5", "url-regex-safe": "4.0.0", "use-font-face-observer": "1.3.0", + "use-intl": "4.14.9", "uuid": "14.0.0", "web-worker": "1.4.1", + "y-prosemirror": "1.3.7", + "y-protocols": "1.0.7", + "yjs": "13.6.33", "zod": "4.4.3" }, "devDependencies": { 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..0ef607c05fd --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/docx-preview.tsx @@ -0,0 +1,236 @@ +import { Box, CircularProgress, Stack, Typography } from "@mui/material"; +import * as Sentry from "@sentry/nextjs"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { FullScreen, useFullScreenHandle } from "react-full-screen"; + +import { + ArrowDownLeftAndArrowUpRightToCenterIcon, + ArrowUpRightAndArrowDownLeftFromCenterIcon, + MagnifyingGlassMinusLightIcon, + MagnifyingGlassPlusLightIcon, +} from "@hashintel/design-system"; + +import { FolioDocument } from "./docx-preview/folio-document"; +import { fetchPresignedFileUrl } from "./fetch-presigned-file-url"; +import { FilePreviewPageNavigation } from "./file-preview-page-navigation"; +import { FilePreviewSearch } from "./file-preview-search"; +import { GrayToBlueIconButton } from "./gray-to-blue-icon-button"; +import { useFilePreviewMaxHeight } from "./use-file-preview-max-height"; + +import type { + DocxDocumentControls, + DocxDocumentPages, +} from "./docx-preview/folio-document"; + +const zoomStep = 0.2; + +type DocxPreviewProps = { + /** + * Called when the document cannot be shown at all, so the caller can show + * the file's icon instead. + */ + onUnavailable: () => void; + showSearch: boolean; + setShowSearch: (showSearch: boolean) => void; + url: string; +}; + +export const DocxPreview = ({ + onUnavailable, + showSearch, + setShowSearch, + url, +}: DocxPreviewProps) => { + const [data, setData] = useState(null); + const [pages, setPages] = useState({ + current: 1, + total: 0, + }); + const [zoom, setZoom] = useState(1); + const [searchHitCount, setSearchHitCount] = useState(0); + const [selectedSearchHit, setSelectedSearchHit] = useState( + null, + ); + + const controlsRef = useRef(null); + + const fullScreenHandle = useFullScreenHandle(); + + const height = useFilePreviewMaxHeight(fullScreenHandle.active); + + /** + * An error before any page is laid out means the document cannot be shown. + * A later one, such as a part folio cannot draw, leaves the pages in place. + */ + const handleError = (error: unknown) => { + Sentry.captureException(error); + if (!controlsRef.current?.hasPages()) { + onUnavailable(); + } + }; + + const handleDownloadError = useEffectEvent(handleError); + + useEffect(() => { + let cancelled = false; + + setData(null); + + fetchPresignedFileUrl(url) + .then((presignedUrl) => fetch(presignedUrl)) + .then((response) => { + if (!response.ok) { + throw new Error( + `Downloading ${url} failed with HTTP ${response.status}`, + ); + } + return response.arrayBuffer(); + }) + .then((buffer) => { + if (!cancelled) { + setData(buffer); + } + }) + .catch((error: unknown) => { + if (!cancelled) { + handleDownloadError(error); + } + }); + + return () => { + cancelled = true; + }; + }, [url]); + + const selectSearchHit = (index: number | null) => { + setSelectedSearchHit(index); + + if (index !== null) { + controlsRef.current?.showHit(index); + } + }; + + const search = (text: string, ignoreCase: boolean) => { + const hitCount = controlsRef.current?.search(text, ignoreCase) ?? 0; + setSearchHitCount(hitCount); + selectSearchHit(hitCount > 0 ? 0 : null); + }; + + return ( + + ({ + background: palette.blue[10], + border: `1px solid ${palette.gray[30]}`, + borderRadius: 2, + height, + overflow: "hidden", + position: "relative", + width: "100%", + })} + > + {data ? ( + + + + ) : ( + + + + )} + + setShowSearch(false)} + onSearch={search} + onSelectHit={selectSearchHit} + selectedIndex={selectedSearchHit} + showSearch={showSearch} + total={searchHitCount} + /> + + {pages.total > 0 && ( + <> + + + controlsRef.current?.goToPage(pageNumber) + } + totalPages={pages.total} + /> + + + + 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..71477d102bd --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx @@ -0,0 +1,97 @@ +import "@stll/folio-react/standalone.css"; +import { Box, CircularProgress } from "@mui/material"; +import { DocxEditor } from "@stll/folio-react"; +import { messages as folioMessages } from "@stll/folio-react/messages/en"; +import clamp from "lodash/clamp"; +import { useImperativeHandle, useRef } from "react"; +import { IntlProvider } from "use-intl"; + +import { useReportedPages } from "./folio-document/use-reported-pages"; +import { useSearchHits } from "./folio-document/use-search-hits"; + +import type { DocxDocumentPages } from "./folio-document/use-reported-pages"; +import type { DocxEditorRef } from "@stll/folio-react"; +import type { Ref } from "react"; + +/** + * What `DocxPreview` can ask of the engine that draws the document. Nothing + * outside this file depends on the engine itself. + */ +export type DocxDocumentControls = { + /** Whether the engine shows at least one page. */ + hasPages: () => boolean; + goToPage: (pageNumber: number) => void; + zoomBy: (step: number) => void; + /** Finds every hit of `text` and returns how many there are. */ + search: (text: string, ignoreCase: boolean) => number; + showHit: (index: number) => void; +}; + +export type { DocxDocumentPages }; + +type FolioDocumentProps = { + controlsRef: Ref; + data: ArrayBuffer; + onError: (error: Error) => void; + onPagesChange: (pages: DocxDocumentPages) => void; + onZoomChange: (zoom: number) => void; +}; + +const minZoom = 0.25; +const maxZoom = 4; + +export const FolioDocument = ({ + controlsRef, + data, + onError, + onPagesChange, + onZoomChange, +}: FolioDocumentProps) => { + const editorRef = useRef(null); + const containerRef = useRef(null); + + const reportPages = useReportedPages(editorRef, onPagesChange); + const { search, showHit } = useSearchHits(editorRef); + + useImperativeHandle( + controlsRef, + () => ({ + /** + * folio reports one page before it has laid out any, so this looks for + * a drawn page instead. + */ + hasPages: () => !!containerRef.current?.querySelector(".layout-page"), + goToPage: (pageNumber) => editorRef.current?.scrollToPage(pageNumber), + zoomBy: (step) => { + const editor = editorRef.current; + if (!editor) { + return; + } + editor.setZoom(clamp(editor.getZoom() + step, minZoom, maxZoom)); + onZoomChange(editor.getZoom()); + }, + search, + showHit, + }), + [onZoomChange, search, showHit], + ); + + return ( + + + } + onError={onError} + onScrollTopChange={reportPages} + readOnly + style={{ height: "100%" }} + /> + + + ); +}; 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..d25b367d0f3 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/docx-preview/folio-document/use-reported-pages.ts @@ -0,0 +1,45 @@ +import { useEffect, useEffectEvent, 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 = () => { + 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); + } + }; + + const pollPages = useEffectEvent(reportPages); + + /** folio has no event for a page change, so the pages are polled. */ + useEffect(() => { + const interval = setInterval(() => pollPages(), 500); + return () => clearInterval(interval); + }, []); + + 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 }; +}; 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..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 @@ -45,6 +45,14 @@ const PdfPreview = dynamic( { ssr: false }, ); +const DocxPreview = dynamic( + () => import("../../docx-preview").then((module) => module.DocxPreview), + { + ssr: false, + loading: () => , + }, +); + 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(); const isFullScreen = fullScreenHandle.active; + /** The file the DOCX preview could not show, which then gets the icon view. */ + const [unavailableDocxUrl, setUnavailableDocxUrl] = useState(); + if (!fileUrl) { return null; } - const { description, displayName, fileName, mimeType } = simplifyProperties( + const { description, displayName, fileName } = simplifyProperties( entity.properties as FileProperties, ); @@ -202,27 +218,29 @@ export const FilePreviewSection = () => { const alt = description ?? title; - const isPdf = mimeType === "application/pdf" || fileName?.endsWith(".pdf"); + const isDocx = isDocxFile && unavailableDocxUrl !== fileUrl; return ( - setShowThumbnails(!showThumbnails)} - > - - showThumbnails ? palette.blue[70] : undefined, - }} - /> - + {isPdf && ( + setShowThumbnails(!showThumbnails)} + > + + showThumbnails ? palette.blue[70] : undefined, + }} + /> + + )} setShowSearch(!showSearch)} @@ -283,20 +301,22 @@ export const FilePreviewSection = () => { - - - - - - - + {!isDocxFile && ( + + + + + + + + )} } > @@ -306,7 +326,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", })} > @@ -339,11 +360,20 @@ export const FilePreviewSection = () => { ) : isPdf ? ( + ) : isDocx ? ( + setUnavailableDocxUrl(fileUrl)} + setShowSearch={setShowSearch} + showSearch={showSearch} + url={fileUrl} + /> ) : ( => { + 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/file-preview-page-navigation.tsx b/apps/hash-frontend/src/pages/shared/file-preview-page-navigation.tsx new file mode 100644 index 00000000000..97403e8331d --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/file-preview-page-navigation.tsx @@ -0,0 +1,44 @@ +import { Typography } from "@mui/material"; + +import { ArrowRightRegularIcon } from "@hashintel/design-system"; + +import { GrayToBlueIconButton } from "./gray-to-blue-icon-button"; + +type FilePreviewPageNavigationProps = { + currentPage: number; + onSelectPage: (pageNumber: number) => 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/file-preview-search.tsx b/apps/hash-frontend/src/pages/shared/file-preview-search.tsx new file mode 100644 index 00000000000..a6be798aa0e --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/file-preview-search.tsx @@ -0,0 +1,180 @@ +import { faXmark } from "@fortawesome/free-solid-svg-icons"; +import { + Box, + Collapse, + outlinedInputClasses, + Stack, + Tooltip, + Typography, +} from "@mui/material"; +import throttle from "lodash/throttle"; +import { useEffect, useRef, useState } from "react"; + +import { + ArrowDownRegularIcon, + ArrowUpRegularIcon, + FontAwesomeIcon, + IconButton, + TextField, +} from "@hashintel/design-system"; + +import { FontCaseRegularIcon } from "../../shared/icons/font-case-regular-icon"; +import { GrayToBlueIconButton } from "./gray-to-blue-icon-button"; + +type FilePreviewSearchProps = { + closeSearch: () => void; + /** + * 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; + onSelectHit: (index: number) => void; + selectedIndex: number | null; + showSearch: boolean; + total: number; +}; + +export const FilePreviewSearch = ({ + closeSearch, + onSearch, + onSelectHit, + selectedIndex, + showSearch, + total, +}: FilePreviewSearchProps) => { + const [ignoreCase, setIgnoreCase] = useState(true); + const [searchText, setSearchText] = useState(""); + + const inputRef = useRef(null); + + const throttledSearch = throttle(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(); + } + }, [showSearch]); + + return ( + + + + { + throttledSearch.cancel(); + setSearchText(""); + onSearch("", ignoreCase); + closeSearch(); + }} + sx={{ mr: 0.5 }} + size="small" + unpadded + > + + + + ), + }} + onKeyDown={(event) => { + if (event.key === "Enter" && nextHitIndex !== null) { + onSelectHit(nextHitIndex); + } + }} + onChange={(event) => { + const text = event.target.value; + setSearchText(text); + throttledSearch(text, ignoreCase); + }} + placeholder="Search the document..." + sx={{ + width: 200, + [`.${outlinedInputClasses.root} input`]: { + fontSize: 13, + py: 0.8, + px: 1.5, + }, + }} + value={searchText} + /> + + + { + throttledSearch(searchText, !ignoreCase); + setIgnoreCase(!ignoreCase); + inputRef.current?.focus(); + }} + sx={{ "& svg": { fontSize: 13 }, p: 0.5 }} + > + + ignoreCase ? undefined : palette.blue[70], + transition: ({ transitions }) => transitions.create("fill"), + }} + /> + + + + + { + if (previousHitIndex !== null) { + onSelectHit(previousHitIndex); + } + }} + sx={{ "& svg": { fontSize: 10 }, p: 0.6 }} + > + + + palette.gray[90], + whiteSpace: "nowrap", + }} + > + {`${(selectedIndex ?? -1) + 1} of ${total}`} + + { + if (nextHitIndex !== null) { + onSelectHit(nextHitIndex); + } + }} + sx={{ "& svg": { fontSize: 10 }, p: 0.6 }} + > + + + + + + + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/get-file-properties.ts b/apps/hash-frontend/src/pages/shared/get-file-properties.ts index c4b4d5f9414..f94f36bab1e 100644 --- a/apps/hash-frontend/src/pages/shared/get-file-properties.ts +++ b/apps/hash-frontend/src/pages/shared/get-file-properties.ts @@ -3,19 +3,29 @@ import { simplifyProperties } from "@local/hash-isomorphic-utils/simplify-proper import type { PropertyObject } from "@blockprotocol/type-system"; import type { FileProperties } from "@local/hash-isomorphic-utils/system-types/shared"; +const docxMimeType = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + export const getFileProperties = (properties: PropertyObject) => { 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, }; }; diff --git a/apps/hash-frontend/src/pages/shared/pdf-preview.tsx b/apps/hash-frontend/src/pages/shared/pdf-preview.tsx index 63b09024801..6923d18ef97 100644 --- a/apps/hash-frontend/src/pages/shared/pdf-preview.tsx +++ b/apps/hash-frontend/src/pages/shared/pdf-preview.tsx @@ -8,12 +8,15 @@ import { Document, Page, pdfjs } from "react-pdf"; import { ArrowDownLeftAndArrowUpRightToCenterIcon, - ArrowRightRegularIcon, ArrowUpRightAndArrowDownLeftFromCenterIcon, MagnifyingGlassMinusLightIcon, MagnifyingGlassPlusLightIcon, } from "@hashintel/design-system"; +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 { a4Ratio, @@ -24,11 +27,12 @@ import { import { PageThumbnail } from "./pdf-preview/page-thumbnail"; import { PdfPreviewSkeleton } from "./pdf-preview/pdf-preview-skeleton"; import { - PdfSearch, type SearchHit, type SearchHits, + useFindSearchHits, } from "./pdf-preview/pdf-search"; import { useElementBorderBoxSize } from "./use-element-dimensions"; +import { useFilePreviewMaxHeight } from "./use-file-preview-max-height"; import type { CustomTextRenderer, @@ -113,21 +117,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( @@ -144,6 +134,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); @@ -187,28 +207,11 @@ export const PdfPreview = ({ const fullPageHeight = pageContainerWidth * pageWidthHeightRatio; - const [viewportHeight, setViewportHeight] = useState( - document.documentElement.clientHeight, - ); - - useEffect(() => { - 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); @@ -257,20 +260,13 @@ export const PdfPreview = ({ zIndex: 100, }} > - 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 ? ( @@ -311,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", @@ -318,36 +315,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) - } - > - - + 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 5cff75c31a2..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,26 +1,4 @@ -import { faXmark } from "@fortawesome/free-solid-svg-icons"; -import { - Box, - Collapse, - outlinedInputClasses, - Stack, - Tooltip, - Typography, -} from "@mui/material"; -import debounce from "lodash/debounce"; -import { useEffect, useRef, useState } from "react"; - -import { - ArrowDownRegularIcon, - ArrowUpRegularIcon, - FontAwesomeIcon, - 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 { useEffect, useState } from "react"; import type { TextItem } from "pdfjs-dist/types/src/display/api"; import type { DocumentCallback } from "react-pdf/dist/cjs/shared/types"; @@ -144,32 +122,12 @@ const findStringInPages = ({ return results; }; -type PdfSearchProps = { - closeSearch: () => void; - document: DocumentCallback | null; - searchHits: SearchHits; - setSearchHits: (hits: SearchHits) => void; - selectedSearchHit: SearchHit | null; - setSelectedSearchHit: (hit: SearchHit | null) => void; - showSearch: boolean; -}; - -export const PdfSearch = ({ - closeSearch, - document, - searchHits, - setSearchHits, - selectedSearchHit, - setSelectedSearchHit, - showSearch, -}: PdfSearchProps) => { - const [ignoreCase, setIgnoreCase] = useState(true); - const [searchText, setSearchText] = useState(""); - +/** + * Returns a function that finds the occurrences of a string in the document. + */ +export const useFindSearchHits = (document: DocumentCallback | null) => { const [pages, setPages] = useState([]); - const inputRef = useRef(null); - /** * Build the information we need to identify search hits within the document. */ @@ -218,172 +176,6 @@ export const PdfSearch = ({ 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; - - useEffect(() => { - if (showSearch) { - inputRef.current?.focus(); - } - }, [showSearch]); - - const allHits = Object.values(hitsByPageNumber).flat(); - - return ( - - - - { - setSearchText(""); - setSelectedSearchHit(null); - setSearchHits({ hitsByPageNumber: {}, total: 0 }); - closeSearch(); - }} - sx={{ mr: 0.5 }} - size="small" - unpadded - > - - - - ), - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - if ( - selectedSearchHit && - selectedSearchHit.indexInAllHits < total - 1 - ) { - setSelectedSearchHit( - allHits[selectedSearchHit.indexInAllHits + 1]!, - ); - } - } - }} - onChange={(event) => { - const text = event.target.value; - setSearchText(text); - getSearchHits(text, ignoreCase); - }} - placeholder="Search the document..." - sx={{ - width: 200, - [`.${outlinedInputClasses.root} input`]: { - fontSize: 13, - py: 0.8, - px: 1.5, - }, - }} - value={searchText} - /> - - { - getSearchHits(searchText, !ignoreCase); - setIgnoreCase(!ignoreCase); - inputRef.current?.focus(); - }} - sx={{ "& svg": { fontSize: 13 }, p: 0.5 }} - > - - ignoreCase ? undefined : palette.blue[70], - transition: ({ transitions }) => transitions.create("fill"), - }} - /> - - - - { - if ( - selectedSearchHit && - selectedSearchHit.indexInAllHits > 0 - ) { - setSelectedSearchHit( - allHits[selectedSearchHit.indexInAllHits - 1]!, - ); - } - }} - sx={{ "& svg": { fontSize: 10 }, p: 0.6 }} - > - - - palette.gray[90], - whiteSpace: "nowrap", - }} - > - {`${(selectedSearchHit?.indexInAllHits ?? -1) + 1} of ${total}`} - - { - if ( - selectedSearchHit && - selectedSearchHit.indexInAllHits < total - 1 - ) { - setSelectedSearchHit( - allHits[selectedSearchHit.indexInAllHits + 1]!, - ); - } - }} - sx={{ "& svg": { fontSize: 10 }, p: 0.6 }} - > - - - - - - - - ); + return (searchString: string, ignoreCase: boolean) => + findStringInPages({ pages, searchString, ignoreCase }); }; 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; +}; 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 deleted file mode 100644 index 7caba0d8851..00000000000 --- a/tests/hash-playwright/tests/features/file-preview.spec.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { extractWebIdFromEntityId } from "@blockprotocol/type-system"; -import { generateEntityPath } from "@local/hash-isomorphic-utils/frontend-paths"; - -import { getUser, uploadFile } from "../shared/api-queries"; -import { generateTestPdf } from "../shared/generate-test-pdf"; -import { expect, test } from "../shared/runtime"; -import { - testDocumentPages, - testDocumentSearchWord, -} from "../shared/test-document"; - -import type { Page } from "../shared/runtime"; - -const [firstPage, secondPage, thirdPage] = testDocumentPages; - -const getPdfPage = (page: Page) => page.locator(".react-pdf-page"); - -test.beforeEach(async ({ page }) => { - 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`, - webId: extractWebIdFromEntityId(user.metadata.recordId.entityId), - }); - - await page.goto( - generateEntityPath({ - entityId: fileEntity.metadata.recordId.entityId, - includeDraftId: false, - shortname: "alice", - }), - ); - - 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); -}); 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..978901b544b --- /dev/null +++ b/tests/hash-playwright/tests/features/file-preview/docx.spec.ts @@ -0,0 +1,287 @@ +import { + generateHostileTestDocx, + generateDocx, + 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 generateDocx(testDocumentPages), + 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("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"); + 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, + }) => { + 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, + }) => { + 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("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()) ?? ""; + + 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.context().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); + + 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([]); +}); + +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(); + await expect( + page.getByRole("button", { name: "Open in new tab" }), + ).toHaveCount(0); +}); + +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..1972a8175a9 --- /dev/null +++ b/tests/hash-playwright/tests/features/file-preview/pdf.spec.ts @@ -0,0 +1,90 @@ +import { generatePdf } 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: generatePdf(testDocumentPages), + 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/file-preview/generate-test-docx.ts b/tests/hash-playwright/tests/shared/file-preview/generate-test-docx.ts new file mode 100644 index 00000000000..bc14f952500 --- /dev/null +++ b/tests/hash-playwright/tests/shared/file-preview/generate-test-docx.ts @@ -0,0 +1,116 @@ +import JSZip from "jszip"; + +import type { PageContent } from "./test-document"; + +type ZipEntry = { name: string; contents: string }; + +const writeZip = (entries: ZipEntry[]): Promise => { + const zip = new JSZip(); + for (const { name, contents } of entries) { + zip.file(name, contents); + } + return zip.generateAsync({ type: "nodebuffer" }); +}; + +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"; + +/** + * 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 = [], + extraParts = [], + extraContentTypes = "", +}: { + body: string; + documentRelationships?: string[]; + extraParts?: ZipEntry[]; + extraContentTypes?: string; +}): Promise => + writeZip([ + { + 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}${sectionProperties}`, + }, + ...extraParts, + ]); + +const paragraph = (text: string) => + `${text}`; + +const pageBreak = ``; + +/** 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)}`) + .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 = (): Promise => + 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: ``, + }); + +/** + * Builds a DOCX that folio refuses to draw: a bookmark that ends between + * paragraphs without having started. + */ +export const generateUndrawableTestDocx = (): Promise => + writeDocx({ + body: [ + paragraph("Undrawable document"), + ``, + paragraph("After the broken bookmark"), + ].join(""), + }); 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 92% rename from tests/hash-playwright/tests/shared/generate-test-pdf.ts rename to tests/hash-playwright/tests/shared/file-preview/generate-test-pdf.ts index 69a7f594076..5f266d3bcf6 100644 --- a/tests/hash-playwright/tests/shared/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: PageContent[]): Buffer => { +export const generatePdf = (pages: readonly PageContent[]): Buffer => { // After the catalog, page tree and font const firstPageObjectNumber = 4; @@ -82,5 +82,3 @@ const generatePdf = (pages: PageContent[]): Buffer => { return Buffer.from(pdf); }; - -export const generateTestPdf = (): Buffer => generatePdf(testDocumentPages); diff --git a/tests/hash-playwright/tests/shared/test-document.ts b/tests/hash-playwright/tests/shared/file-preview/test-document.ts similarity index 83% rename from tests/hash-playwright/tests/shared/test-document.ts rename to tests/hash-playwright/tests/shared/file-preview/test-document.ts index ed3ccb171eb..43cd1c82875 100644 --- a/tests/hash-playwright/tests/shared/test-document.ts +++ b/tests/hash-playwright/tests/shared/file-preview/test-document.ts @@ -22,3 +22,7 @@ export const testDocumentPages = [ body: `Another ${testDocumentSearchWord} lives on this page.`, }, ] as const satisfies PageContent[]; + +export const otherTestDocumentPages = [ + { 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..5edd1538d9c --- /dev/null +++ b/tests/hash-playwright/tests/shared/file-preview/utils.ts @@ -0,0 +1,119 @@ +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 { generateDocx } from "./generate-test-docx"; +import { generatePdf } 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. 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) => { + 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") + .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: generatePdf, + mimeType: "application/pdf", + pageWithHeading: (page, heading) => + getPdfPage(page).getByText(heading, { exact: true }), +}; + +export const docx: FileFormat = { + extension: "docx", + generate: generateDocx, + 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(); +}; diff --git a/tests/hash-playwright/tests/shared/runtime.ts b/tests/hash-playwright/tests/shared/runtime.ts index 2f348305115..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/, @@ -52,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 @@ -82,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; } diff --git a/yarn.lock b/yarn.lock index de961ab3e1c..dd00058e220 100644 --- a/yarn.lock +++ b/yarn.lock @@ -729,6 +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.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,10 +798,13 @@ __metadata: prosemirror-collab: "npm:1.3.0" prosemirror-commands: "npm:1.3.1" prosemirror-dev-tools: "npm:4.3.0" + prosemirror-gapcursor: "npm:1.4.1" + prosemirror-history: "npm:1.5.1" 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" prosemirror-transform: "npm:1.12.2" prosemirror-view: "npm:1.42.6" react: "npm:19.2.6" @@ -829,11 +834,15 @@ __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" 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 @@ -4098,7 +4107,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": +"@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.7 resolution: "@babel/runtime@npm:7.29.7" checksum: 10c0/ca11572f7146b21e0bde6a9ed4bb6a89eafbee5f0944c7eb54d0d8a2dac962c33638a1d611e14faa71dfbb92b4b5f9236232208568a6b7d5c6f3f39ddb91771e @@ -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.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: @@ -6431,15 +6485,15 @@ __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" +"@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: - "@floating-ui/dom": "npm:^1.0.0" + "@floating-ui/dom": "npm:^1.8.0" peerDependencies: react: ">=16.8.0" react-dom: ">=16.8.0" - checksum: 10c0/e855131c74e68cab505f7f44f92cd4e2efab1c125796db3116c54c0859323adae4bf697bf292ee83ac77b9335a41ad67852193d7aeace90aa2e1c4a640cafa60 + checksum: 10c0/9fa9851069738cb9591a1ab2da16e257f193eae5fd2d6d48ad0b1e73f3c049929b6805c5ad43d4af3c1da63c60b215b0ee4651a1fe5caeb80e66d9d423744348 languageName: node linkType: hard @@ -6566,6 +6620,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 +11037,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 +15160,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 +16654,117 @@ __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.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" + 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/6978eabec0fbeec30e49a7976a6cefb8d5478c6e84409ed52af235870736f1dabd511dbd98fc849f323e462aa980cb27a4a89c02901b86145253e97ca7fb5256 + 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.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.37.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.16" + fast-xml-parser: "npm:^5.10.1" + hyphen: "npm:1.14.1" + jszip: "npm:3.10.2" + utif2: "npm:^4.1.0" + valibot: "npm:1.5.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" + "@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.60.0" + better-result: "npm:3.0.1" + lucide-react: "npm:1.24.0" + react-compiler-runtime: "npm:^1.0.0" + 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" + yjs: ^13.6.31 + checksum: 10c0/37361be518a0b1ddd8b20ee7f207a1a8a9fedf1b1656cba6f85c04c79f1766cc9a4cabef9a7154de683762b270f9996de9517dd1c1ada0922b2a8b9e61c0a1ca + 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" @@ -17418,6 +17692,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 @@ -22923,6 +23198,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 +30861,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 +30916,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 +30967,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 +31199,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 +32053,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 +32753,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 +33087,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 +33135,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 +33865,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 +34014,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 +37257,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" @@ -38082,6 +38463,30 @@ __metadata: 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.1": + 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" @@ -38102,6 +38507,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 +38537,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 +38974,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 +39543,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: @@ -39818,7 +40255,7 @@ __metadata: languageName: node linkType: hard -"reselect@npm:^5.1.0": +"reselect@npm:^5.1.0, reselect@npm:^5.2.0": version: 5.2.0 resolution: "reselect@npm:5.2.0" checksum: 10c0/d0a8497e404b25a769fe792eacae88b9b576c30870450cd243d76ec9427d91a59c4a2cc00d824d283448b444c4c698a8f961d771c8ae39c759313afb31950e2e @@ -40361,6 +40798,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 +41390,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 +41807,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 +42868,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 +44719,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 +44771,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" @@ -44344,7 +44846,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: @@ -44356,6 +44858,18 @@ __metadata: languageName: node linkType: hard +"valibot@npm:1.5.0, valibot@npm:^1.1.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 +46257,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 +46441,15 @@ __metadata: languageName: node linkType: hard +"yjs@npm:13.6.33": + 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"