Repository navigation
UN-4505 [DEV] Add the v1 DocumentViewer with toolbar, zoom, keys and scroll handle #2408
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
muhammad-ali-e
wants to merge
21
commits into
UN-4504-v1-pdf-highlights
Choose a base branch
from
UN-4505-v1-pdf-toolbar
base: UN-4504-v1-pdf-highlights
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
b54e805
UN-4505 [FEAT] Add the v1 DocumentViewer with toolbar, zoom, keys and…
muhammad-ali-e 504a4b2
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 5f9ac1e
UN-4505 Merge UN-4504-v1-pdf-highlights into UN-4505-v1-pdf-toolbar
muhammad-ali-e fb2d766
UN-4505 Merge UN-4504-v1-pdf-highlights into UN-4505-v1-pdf-toolbar
muhammad-ali-e 69a8447
UN-4505 Merge UN-4504-v1-pdf-highlights into UN-4505-v1-pdf-toolbar
muhammad-ali-e 866abe1
UN-4505 Merge UN-4504-v1-pdf-highlights into UN-4505-v1-pdf-toolbar
muhammad-ali-e 420b195
UN-4505 Merge UN-4504-v1-pdf-highlights into UN-4505-v1-pdf-toolbar
muhammad-ali-e bc6150b
UN-4505 [FEAT] Keep fit-page steady and the page draft while scrolling
muhammad-ali-e 6ac77e3
UN-4505 [FEAT] Drop a stale comment about remounting the page box
muhammad-ali-e f1b475c
Merge branch 'UN-4504-v1-pdf-highlights' into UN-4505-v1-pdf-toolbar
muhammad-ali-e ddb153a
UN-4505 Read the current page from the layout and tie waiting scrolls…
muhammad-ali-e dc3a33c
UN-4505 Fix the end-of-document page and keep waiting scrolls made at…
muhammad-ali-e 8c9ce6a
UN-4505 Keep the page jumped to current while the view stays there
muhammad-ali-e 1976ec0
Merge branch 'UN-4504-v1-pdf-highlights' into UN-4505-v1-pdf-toolbar
muhammad-ali-e 35d64bc
Merge branch 'UN-4504-v1-pdf-highlights' into UN-4505-v1-pdf-toolbar
muhammad-ali-e 790ebdc
UN-4505 Track the current page by share shown, centre highlights exac…
muhammad-ali-e 0279776
Merge branch 'UN-4504-v1-pdf-highlights' into UN-4505-v1-pdf-toolbar
muhammad-ali-e 61e4749
UN-4505 [FIX] Address review: keys in fields, measured scrollbar, pag…
muhammad-ali-e 984c83c
UN-4505 [FIX] Drop a waiting active-highlight scroll once the highlig…
muhammad-ali-e 07ecdab
Merge branch 'UN-4504-v1-pdf-highlights' into UN-4505-v1-pdf-toolbar
muhammad-ali-e 9ca6a28
UN-4505 Drop a waiting scroll as soon as the source changes
muhammad-ali-e File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
510 changes: 510 additions & 0 deletions
510
frontend/src/v1/components/patterns/document-viewer/DocumentViewer.test.tsx
Large diffs are not rendered by default.
Oops, something went wrong.
340 changes: 340 additions & 0 deletions
340
frontend/src/v1/components/patterns/document-viewer/DocumentViewer.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,340 @@ | ||
| import { useElementSize } from "@v1/hooks/useElementSize"; | ||
| import { usePdfDocument } from "@v1/hooks/usePdfDocument"; | ||
| import { cn } from "@v1/lib/cn"; | ||
| import type { Highlight } from "@v1/lib/pdf/highlights"; | ||
| import { viewerKeyAction } from "@v1/lib/pdf/keyboard"; | ||
| import { objectKey, pdfSourceKey } from "@v1/lib/pdf/pdfjs"; | ||
| import type { PageSize, PdfSource } from "@v1/lib/pdf/types"; | ||
| import { | ||
| clampZoom, | ||
| fitZoom, | ||
| MAX_ZOOM, | ||
| MIN_ZOOM, | ||
| type ZoomMode, | ||
| zoomIn, | ||
| zoomOut, | ||
| } from "@v1/lib/pdf/zoom"; | ||
| import { scrollbarWidth } from "@v1/lib/scrollbar"; | ||
| import { | ||
| type KeyboardEvent, | ||
| type Ref, | ||
| useCallback, | ||
| useEffect, | ||
| useImperativeHandle, | ||
| useLayoutEffect, | ||
| useMemo, | ||
| useRef, | ||
| useState, | ||
| } from "react"; | ||
| import { PdfPageList, type PdfPageListHandle } from "./PdfPageList"; | ||
| import { ViewerStatus } from "./ViewerStatus"; | ||
| import { ViewerToolbar } from "./ViewerToolbar"; | ||
|
|
||
| export interface DocumentViewerHandle { | ||
| /** Scrolls a 0-based page to the top. Waits for the document if it is loading. */ | ||
| scrollToPage: (index: number) => void; | ||
| /** | ||
| * Centres a highlight. Waits for the document if it is loading, and for the | ||
| * highlight if it is not in `highlights` yet. | ||
| */ | ||
| scrollToHighlight: (id: string) => void; | ||
| } | ||
|
|
||
| export interface DocumentViewerProps { | ||
| source: PdfSource | null; | ||
| highlights?: readonly Highlight[]; | ||
| /** Shown in the active tone and scrolled into view when it changes. */ | ||
| activeHighlightId?: string | null; | ||
| onHighlightClick?: (highlight: Highlight) => void; | ||
| /** A zoom level (1 is 100%) or a fit; fit width by default. */ | ||
| initialZoom?: number | Exclude<ZoomMode, "custom">; | ||
| className?: string; | ||
| ref?: Ref<DocumentViewerHandle>; | ||
| } | ||
|
|
||
| type ScrollRequest = | ||
| | { kind: "page"; index: number } | ||
| | { kind: "highlight"; id: string }; | ||
|
|
||
| /** A request that could not run yet, and what it was made for. */ | ||
| interface PendingRequest { | ||
| request: ScrollRequest; | ||
| /** The source shown when it was made; another source drops it. */ | ||
| sourceKey: string; | ||
| /** Made for `activeHighlightId`, so clearing that id drops it. */ | ||
| fromActive: boolean; | ||
| } | ||
|
|
||
| interface ZoomState { | ||
| mode: ZoomMode; | ||
| /** The level in custom mode; ignored while fitting. */ | ||
| level: number; | ||
| } | ||
|
|
||
| /** The page list's padding, both sides together, in CSS px. */ | ||
| const LIST_PADDING = 32; | ||
|
|
||
| /** | ||
| * Room around a fitted page, in CSS px: the list's padding plus this | ||
| * platform's scrollbar, measured rather than guessed, so fitting never causes | ||
| * a horizontal scrollbar. | ||
| */ | ||
| function fitGutter(): number { | ||
| return LIST_PADDING + scrollbarWidth(); | ||
| } | ||
|
|
||
| function widest(pageSizes: readonly PageSize[]): PageSize | undefined { | ||
| let best: PageSize | undefined; | ||
| for (const size of pageSizes) { | ||
| if (!best || size.width > best.width) { | ||
| best = size; | ||
| } | ||
| } | ||
| return best; | ||
| } | ||
|
|
||
| function initialZoomState( | ||
| initial: DocumentViewerProps["initialZoom"], | ||
| ): ZoomState { | ||
| if (typeof initial === "number") { | ||
| return { mode: "custom", level: clampZoom(initial) }; | ||
| } | ||
| return { mode: initial ?? "fit-width", level: 1 }; | ||
| } | ||
|
|
||
| /** | ||
| * A PDF with a toolbar: loads it, shows its states, and renders its pages with | ||
| * highlights. Keys while the pages have focus: PageUp/PageDown, Home/End, | ||
| * + and −. | ||
| */ | ||
| export function DocumentViewer({ | ||
| source, | ||
| highlights, | ||
| activeHighlightId, | ||
| onHighlightClick, | ||
| initialZoom, | ||
| className, | ||
| ref, | ||
| }: DocumentViewerProps) { | ||
|
Check warning on line 118 in frontend/src/v1/components/patterns/document-viewer/DocumentViewer.tsx
|
||
| const { state, retry, submitPassword } = usePdfDocument(source); | ||
| const [zoomState, setZoomState] = useState(() => | ||
| initialZoomState(initialZoom), | ||
| ); | ||
| const [pageIndex, setPageIndex] = useState(0); | ||
| // A new document starts on its first page: reset while rendering, so the | ||
| // toolbar never shows the last document's page against this one's count. | ||
| const shownDocument = state.status === "ready" ? state.document : null; | ||
| const [pageIndexFor, setPageIndexFor] = useState(shownDocument); | ||
| if (pageIndexFor !== shownDocument) { | ||
| setPageIndexFor(shownDocument); | ||
| setPageIndex(0); | ||
| } | ||
| const [bodyRef, bodySize] = useElementSize<HTMLDivElement>(); | ||
| const listRef = useRef<PdfPageListHandle | null>(null); | ||
| const pendingRef = useRef<PendingRequest | null>(null); | ||
|
|
||
| const ready = state.status === "ready"; | ||
| // In a fit mode the zoom comes from the viewer's size, so the pages wait for | ||
| // that measurement instead of laying out at 100% first and again after. | ||
| const measured = zoomState.mode === "custom" || bodySize !== null; | ||
| const showPages = ready && measured; | ||
| const sourceKey = pdfSourceKey(source); | ||
| // Both kept in step at commit, before any parent's layout effect can make | ||
| // a request, so a request is always judged against the source it was made | ||
| // for. | ||
| const readyRef = useRef(showPages); | ||
| const sourceKeyRef = useRef(sourceKey); | ||
| useLayoutEffect(() => { | ||
| readyRef.current = showPages; | ||
| sourceKeyRef.current = sourceKey; | ||
| }, [showPages, sourceKey]); | ||
| const pageSizes = ready ? state.pageSizes : undefined; | ||
| const pageCount = pageSizes?.length ?? 0; | ||
|
|
||
| const zoom = useMemo(() => { | ||
| if (zoomState.mode === "custom" || !pageSizes || !bodySize) { | ||
| return zoomState.level; | ||
|
muhammad-ali-e marked this conversation as resolved.
Resolved
|
||
| } | ||
| if (zoomState.mode === "fit-width") { | ||
| const page = widest(pageSizes); | ||
| return page | ||
| ? fitZoom(page, bodySize.width, null, fitGutter()) | ||
| : zoomState.level; | ||
| } | ||
| // Fit page fits every page, not the current one: in a document that mixes | ||
| // sizes, a zoom that followed the current page would rescale the whole | ||
| // document each time scrolling crossed into a page of another size. | ||
| let level = Number.POSITIVE_INFINITY; | ||
| for (const page of pageSizes) { | ||
| level = Math.min( | ||
| level, | ||
| fitZoom(page, bodySize.width, bodySize.height, fitGutter()), | ||
| ); | ||
| } | ||
| return Number.isFinite(level) ? level : zoomState.level; | ||
| }, [zoomState, pageSizes, bodySize]); | ||
|
|
||
| // A request runs now if it can, or waits in pendingRef: for the pages while | ||
| // loading, and for its highlight while that is not in the list yet. A newer | ||
| // request replaces a waiting one. | ||
| const run = useCallback((request: ScrollRequest, fromActive = false) => { | ||
| const list = listRef.current; | ||
| const wait = () => { | ||
| pendingRef.current = { | ||
| request, | ||
| sourceKey: sourceKeyRef.current, | ||
| fromActive, | ||
| }; | ||
| }; | ||
| if (!list || !readyRef.current) { | ||
| wait(); | ||
| } else if (request.kind === "page") { | ||
| pendingRef.current = null; | ||
| list.scrollToPage(request.index); | ||
| } else if (list.scrollToHighlight(request.id)) { | ||
| pendingRef.current = null; | ||
| } else { | ||
| wait(); | ||
| } | ||
| }, []); | ||
|
|
||
| useImperativeHandle( | ||
| ref, | ||
| () => ({ | ||
| scrollToPage: (index) => run({ kind: "page", index }), | ||
| scrollToHighlight: (id) => run({ kind: "highlight", id }), | ||
| }), | ||
| [run], | ||
| ); | ||
|
|
||
| // A request waiting for one source is dropped as soon as the source changes, | ||
| // even while nothing is shown: switching away and back before either opens | ||
| // must not bring it back. | ||
| useEffect(() => { | ||
| if (pendingRef.current && pendingRef.current.sourceKey !== sourceKey) { | ||
| pendingRef.current = null; | ||
| } | ||
| }, [sourceKey]); | ||
|
|
||
| // A waiting request runs once the pages are laid out, and is tried again | ||
| // when the highlights change, in case its highlight has arrived. One made | ||
| // for another source is dropped rather than scrolling a document nobody | ||
| // asked about, and so is one made for an active highlight that is no longer | ||
| // active: this effect runs before the one below that would cancel it. | ||
| // biome-ignore lint/correctness/useExhaustiveDependencies: retried on each new highlights list | ||
| useEffect(() => { | ||
| const pending = pendingRef.current; | ||
| if (!showPages || !pending) { | ||
| return; | ||
|
muhammad-ali-e marked this conversation as resolved.
Resolved
|
||
| } | ||
| const stillActive = | ||
| !pending.fromActive || | ||
| (pending.request.kind === "highlight" && | ||
| pending.request.id === activeHighlightId); | ||
| if (pending.sourceKey === sourceKey && stillActive) { | ||
| run(pending.request, pending.fromActive); | ||
| } else { | ||
| pendingRef.current = null; | ||
| } | ||
| }, [showPages, sourceKey, highlights, activeHighlightId, run]); | ||
|
|
||
| // The active highlight is scrolled to when it changes, and again for each | ||
| // new source, so one chosen before its document arrived is still found. | ||
| // biome-ignore lint/correctness/useExhaustiveDependencies: re-armed on each new source | ||
| useEffect(() => { | ||
| if (activeHighlightId) { | ||
| run({ kind: "highlight", id: activeHighlightId }, true); | ||
| } else if (pendingRef.current?.fromActive) { | ||
| pendingRef.current = null; | ||
| } | ||
| }, [activeHighlightId, sourceKey, run]); | ||
|
|
||
| const goToPage = useCallback( | ||
| (index: number) => run({ kind: "page", index }), | ||
| [run], | ||
| ); | ||
| const zoomBy = useCallback( | ||
| (step: (level: number) => number) => | ||
| setZoomState({ mode: "custom", level: step(zoom) }), | ||
| [zoom], | ||
| ); | ||
|
|
||
| const onKeyDown = (event: KeyboardEvent<HTMLElement>) => { | ||
| const action = viewerKeyAction(event); | ||
| if (action === null) { | ||
| return; | ||
| } | ||
| event.preventDefault(); | ||
| switch (action) { | ||
| case "previous-page": | ||
| goToPage(pageIndex - 1); | ||
| break; | ||
| case "next-page": | ||
| goToPage(pageIndex + 1); | ||
| break; | ||
| case "first-page": | ||
| goToPage(0); | ||
| break; | ||
| case "last-page": | ||
| goToPage(pageCount - 1); | ||
| break; | ||
| case "zoom-in": | ||
| zoomBy(zoomIn); | ||
| break; | ||
| case "zoom-out": | ||
| zoomBy(zoomOut); | ||
| break; | ||
| } | ||
| }; | ||
|
|
||
| return ( | ||
| <div | ||
| className={cn( | ||
| "ui:flex ui:h-full ui:min-h-0 ui:flex-col ui:bg-background", | ||
| className, | ||
| )} | ||
| > | ||
| {ready && ( | ||
| <ViewerToolbar | ||
| pageIndex={pageIndex} | ||
| pageCount={pageCount} | ||
| zoom={zoom} | ||
| zoomMode={zoomState.mode} | ||
| canZoomIn={zoom < MAX_ZOOM} | ||
| canZoomOut={zoom > MIN_ZOOM} | ||
| onPageChange={goToPage} | ||
| onZoomIn={() => zoomBy(zoomIn)} | ||
| onZoomOut={() => zoomBy(zoomOut)} | ||
| onFitWidth={() => setZoomState({ mode: "fit-width", level: zoom })} | ||
| onFitPage={() => setZoomState({ mode: "fit-page", level: zoom })} | ||
| /> | ||
| )} | ||
| <div ref={bodyRef} className="ui:min-h-0 ui:flex-1"> | ||
| {ready ? ( | ||
| showPages && ( | ||
| <PdfPageList | ||
| // A new document gets a fresh list: back at the top, with no | ||
| // window or anchor left over from the last. | ||
| key={objectKey(state.document)} | ||
| ref={listRef} | ||
| document={state.document} | ||
| pageSizes={state.pageSizes} | ||
| zoom={zoom} | ||
| highlights={highlights} | ||
| activeHighlightId={activeHighlightId} | ||
| onHighlightClick={onHighlightClick} | ||
| onCurrentPageChange={setPageIndex} | ||
| onKeyDown={onKeyDown} | ||
| /> | ||
| ) | ||
| ) : ( | ||
| <ViewerStatus | ||
| state={state} | ||
| onRetry={retry} | ||
| onSubmitPassword={submitPassword} | ||
| /> | ||
| )} | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.