Skip to content

UN-4505 [DEV] Add the v1 DocumentViewer with toolbar, zoom, keys and scroll handle - #2408

Open
muhammad-ali-e wants to merge 21 commits into
UN-4504-v1-pdf-highlightsfrom
UN-4505-v1-pdf-toolbar
Open

muhammad-ali-e wants to merge 21 commits into
UN-4504-v1-pdf-highlightsfrom
UN-4505-v1-pdf-toolbar

Conversation

@muhammad-ali-e

@muhammad-ali-e muhammad-ali-e commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

What

  • DocumentViewer (components/patterns/document-viewer/) is the component page tasks will import. It combines the loader, the status screens, the toolbar and the page list.
  • ViewerToolbar: page navigation and zoom controls.
  • lib/pdf/zoom.ts: zoom steps and fit-to-width / fit-to-page maths.
  • lib/pdf/keyboard.ts: maps keys to viewer actions.
  • hooks/useElementSize.ts: tracks an element's size.
  • PdfPageList gains a ref handle, current-page reporting, zoom anchoring and a keyboard target.

Part of the v1 DocumentViewer (P1-T21, UN-4390).

Why

Six page tasks need one viewer with paging, zoom and programmatic scrolling. v0's viewers used setTimeout before jumping to a page. Here every scroll target is exact, because every page has its real size before it renders.

How

  • API: <DocumentViewer source highlights activeHighlightId onHighlightClick initialZoom ref />. The ref exposes scrollToPage(index) and scrollToHighlight(id).
    • A scroll requested while the document is loading is queued, and runs once the pages are laid out.
    • When activeHighlightId changes, that highlight is centred in view. This replaces v0's jump-to-highlight on change.
  • Scrolling: uses the pages' offsetTop / offsetHeight / offsetWidth, so it needs no timers and no waiting for a render.
    • A page lands at the top of the viewer.
    • A highlight is centred vertically. Word-level boxes are centred horizontally too.
  • Toolbar: role="toolbar", and every control is a labelled button reachable with Tab, in reading order.
    • Previous / next.
    • A page box showing n / N. Enter jumps to the typed page; an out-of-range value reverts.
    • Zoom out / in, with a live zoom-level readout.
    • Fit width / fit page, shown as pressed while active.
    • Icons are Lucide: ZoomIn/ZoomOut/ChevronLeft/ChevronRight match the Prompt Studio viewer frames in the P1-T22 inventory. Figma has no glyph for fit width or fit page, so I picked MoveHorizontal and Scan. Easy to swap.
  • Zoom:
    • Fit width is the default. The fit is computed from the viewer body's size via ResizeObserver, so it refits on resize.
    • Fit width uses the widest page; fit page fits every page (the smallest zoom that fits each one), so scrolling into a page of another size never rescales the document. Both leave room for the list padding and the platform scrollbar, measured once, not guessed. In a fit mode the pages wait for the viewer's size before laying out.
    • −/+ steps through 25 %–400 %, starting from wherever a fit left the zoom (e.g. 112 % → 125 %).
    • A zoom change keeps the same spot of the same page at the top. The anchor is recorded on scroll and restored in a layout effect after the pages resize.
  • Keyboard: works while the pages region has focus. The region is focusable, as scrollable regions should be.
    • PageUp / PageDown move to the previous / next page.
    • Home / End go to the first / last page.
    • + / = zoom in, and - zooms out.
    • Keys pressed with Ctrl, Meta or Alt are left to the browser, so browser zoom still works.
  • Current page: the page with the largest visible share, read from the layout on scroll and resize. A tie goes to the lower page. A page jumped to stays current while the view stays where the jump landed.
  • New document: the page list is keyed per document, so opening another document starts fresh at the top.

Can this PR break any existing features. If yes, please list possible items. If no, please explain why. (PS: Admins do not merge the PR without this section filled)

  • No. All the code is new and lives under src/v1/. Nothing in v0 or the v1 entry imports it yet, and lucide-react is already a dependency.

Database Migrations

  • None

Env Config

  • None

Relevant Docs

  • P1-T21 implementation plan; P1-T22 icon inventory.

Related Issues or PRs

Dependencies Versions

  • None

Notes on Testing

  • bun run typecheck:v1, lint:v1 and test:v1 pass: 125 unit tests and 56 boundary fixtures. biome ci src/ reports no errors.
  • Zoom and keyboard maths: stepping from fitted zoom values, both fit modes, clamping and formatting; key mapping, including modified keys.
  • Toolbar: n / N, prev/next disabled at the ends, typed jump and revert, zoom and fit buttons, the pressed state, and the full Tab order.
  • Page list, with a stubbed layout:
    • scrollToPage is exact and clamped.
    • scrollToHighlight centres both ways, and returns false for an unknown id.
    • The zoom anchor holds the same spot across 1× → 2×.
    • The current page is the most visible one.
  • DocumentViewer:
    • Loading, then toolbar; failure, then Retry.
    • Fit width shows 100 % for a Letter page in an 864 px body, refits to 50 % on resize, and + steps to 125 % and unpresses the fit.
    • + / - keys work, and Ctrl + + is ignored.
    • End / Home move between pages.
    • A scroll requested while loading lands after the document opens. A mutation check confirms this test fails if the queued request is dropped.
  • Browser-level checks come in UN-4507.

Screenshots

  • None yet. The gallery comes in UN-4507.

Checklist

I have read and understood the Contribution Guidelines.


Generated by Claude Code

muhammad-ali-e and others added 2 commits October 29, 2026 08:02
… scroll handle

- DocumentViewer composes the loader, status screens, toolbar and page
  list: <DocumentViewer source highlights activeHighlightId
  onHighlightClick initialZoom ref />, with scrollToPage and
  scrollToHighlight on the ref. A scroll asked for while loading runs
  once the pages are laid out, and the active highlight is scrolled into
  view when it changes.
- ViewerToolbar: previous/next, a page box (n / N, Enter to jump), zoom
  -/+, the zoom level, fit width and fit page, each a labelled button
  reachable with Tab.
- Zoom: fit width by default, fitted from the viewer's size with a
  ResizeObserver; -/+ step through 25%-400% from wherever a fit left
  it. A zoom change keeps the same spot of the same page in view.
- Keys while the pages have focus: PageUp/PageDown, Home/End, + and -;
  modified keys are left to the browser.
- PdfPageList scrolls by the pages' laid-out offsets, which are exact
  before any page renders, and reports the page showing most of itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm

Copy link
Copy Markdown
Contributor Author

pre-commit.ci - pr is red because of the base branch, not this PR.

The failing hook is check-shebang-scripts-are-executable, on frontend/scripts/check-v1-styles.mjs. That file has a #! line but no executable bit. It comes from the tokens PR #2399 (UN-4299-design-tokens-themes); this PR doesn't touch it. The hook doesn't fail on #2399 itself only because pre-commit.ci doesn't run on that PR. Running pre-commit run --from-ref $(git merge-base origin/main HEAD) locally reproduces it, and this PR's own files pass every hook.

There's no fix to port yet. The fix is a one-line change on #2399: git add --chmod=+x frontend/scripts/check-v1-styles.mjs (or drop the shebang). Once #2399 has it, this check should go green here without any change to this PR.


Generated by Claude Code

muhammad-ali-e and others added 11 commits October 29, 2026 08:43
# Conflicts:
#	frontend/src/v1/components/patterns/document-viewer/PdfPageList.test.tsx
- Fit page fits every page instead of the current one. With mixed page
  sizes, following the current page rescaled the whole document each time
  scrolling crossed into a page of another size.
- The page box keeps a half-typed number and its focus when the current
  page changes; it follows the current page only while not being edited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm
… to their source

The current page came from intersection ratios reported at quarter steps,
so for a page more than four times the viewer's height it was arbitrary,
and after a jump it lagged a frame: the toolbar showed the wrong page,
PageDown could skip one, and the zoom anchor was recorded against the old
page. Both now come from the laid-out page offsets, on scroll and straight
after a jump.

A scroll waiting for a document is dropped when another source is shown,
and a highlight scroll whose highlight is not in the list yet waits and is
tried again when the highlights change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm
… commit

Round two of the review:
- the last page is current once the end is reached, so Next and PageDown
  no longer stick when a short last page or two whole pages show
- the page in view is measured in the content's own coordinates, with one
  query per scroll, and again when the viewer changes height
- a waiting scroll is stamped with its source and dropped only at flush
  time, so one made in a parent's layout effect on mount survives
- the active highlight is re-armed for each new source, and clearing it
  drops only a request it made

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm
When the last few pages fit together, a jump to one of them is clamped at
the end of the scroll and the end rule reported the last page again, so
Previous and PageUp could not leave it. The page jumped to now stays
current until the reader scrolls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm
@greptile-apps

greptile-apps Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

via Greptile

RetriggerView in GreptileConfidence Score: 5/5

[Low impact] The PR appears safe to merge; no blocking issues remain.

Summary

Adds the v1 DocumentViewer with page controls, zoom, keyboard shortcuts, and scrolling to pages or highlights.

  • One v1 viewer brings PDF pages, document states, and scroll requests together.
  • The page list tracks the reader’s position and jumps to exact page or highlight targets.
  • The viewer toolbar lets readers move between pages and choose zoom controls.
  • Fit zoom scales PDF pages to the measured viewer size and available space.
  • The focused page list responds to page and zoom keys.

Reviews (6) · Last reviewed commit: "UN-4505 Drop a waiting scroll as soon as..." · Reviewed by Greptile

…tly, clamp a starting zoom

- The current page is the one showing the largest share of itself, a page
  taller than the view counting the view as whole, so part of a tall page
  no longer beats a short page shown whole.
- scrollToHighlight centres on the page's own top, padding included; page
  jumps keep the gap above the page.
- A numeric initialZoom goes through clampZoom like a fitted one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm
greptile-apps[bot]
greptile-apps Bot previously approved these changes Nov 2, 2026

@praveen-formido praveen-formido left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DocumentViewer, toolbar, zoom and keys. Two items I would fix first: viewerKeyAction does not check for an editable target, so any input rendered in an overlay loses -, =, Home, End and the page keys; and fit-width assumes a 16px scrollbar, which gives a permanent horizontal scrollbar on 17px classic scrollbars. The rest are smaller.

Also: the PR body says fit page uses the current page, but the code (and its comment at DocumentViewer.tsx:158) deliberately fits every page. The code reads as the intended behaviour; the body should say so.

Comment thread frontend/src/v1/lib/pdf/keyboard.ts Resolved
Comment thread frontend/src/v1/hooks/useElementSize.ts Resolved
muhammad-ali-e and others added 2 commits November 3, 2026 10:05
# Conflicts:
#	frontend/src/v1/components/patterns/document-viewer/PdfPageList.tsx
…e reset, deferred fit layout

- viewerKeyAction leaves keys typed into an input, textarea, select or
  contenteditable to that field.
- Fit modes measure the platform scrollbar once (lib/scrollbar) instead of
  assuming 16 px, so a 17 px classic scrollbar no longer forces a
  horizontal scrollbar.
- The page index resets while rendering when the document changes, so the
  toolbar never shows the last document's page against the new count.
- In a fit mode the page list waits for the viewer's size, so pages are not
  laid out at 100% first and again at the fitted zoom.
- sync() reads the page elements once per list, not on every scroll.
- Document identity uses objectKey from lib/pdf/pdfjs, shared with
  pdfSourceKey.
- useElementSize measures once where ResizeObserver is missing, matching
  the guard in PdfPageList.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm
@greptile-apps
greptile-apps Bot dismissed their stale review November 3, 2026 10:09

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

greptile-apps[bot]
greptile-apps Bot previously approved these changes Nov 3, 2026
…ht is no longer active

The retry effect runs before the effect that cancels a cleared selection,
so a render that both delivers the highlight and clears it scrolled to a
selection the reader had just cleared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm
@greptile-apps
greptile-apps Bot dismissed their stale review November 3, 2026 10:21

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

greptile-apps[bot]
greptile-apps Bot previously approved these changes Nov 3, 2026
A scroll queued for document A survived a switch to B and back to A while
neither had opened, because the retry effect only clears a stale request once
pages are shown; by then the key matched A again. It is now dropped on the
source change itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm
@greptile-apps
greptile-apps Bot dismissed their stale review November 3, 2026 11:25

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

@sonarqubecloud

sonarqubecloud Bot commented Nov 3, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants