From 3fba61f39bef95c4e5a9ab976373c26c5482359a Mon Sep 17 00:00:00 2001 From: hoyla Date: Fri, 13 Mar 2026 15:06:11 +0000 Subject: [PATCH 1/5] Integrate Combined view with Text/OCR/Preview views Rewrite PageViewerOrFallback as a view orchestrator that renders different content based on the active view mode (Combined, Text, OCR, Preview, Table) with a shared PreviewSwitcher footer toolbar. - Add Combined button to PreviewSwitcher when document has pages - Always land on Combined view for paged documents, overriding any view param carried from search URLs - Pin footer toolbar to bottom of viewport with scrollable content area - Remove the standalone 'View as Text' button from DocumentMetadata since the PreviewSwitcher now provides access to all view modes --- .../js/components/PageViewerOrFallback.tsx | 166 +++++++++++++++++- .../js/components/viewer/DocumentMetadata.js | 19 +- .../js/components/viewer/PreviewSwitcher.js | 13 ++ 3 files changed, 176 insertions(+), 22 deletions(-) diff --git a/frontend/src/js/components/PageViewerOrFallback.tsx b/frontend/src/js/components/PageViewerOrFallback.tsx index 261dbed3..b5b35658 100644 --- a/frontend/src/js/components/PageViewerOrFallback.tsx +++ b/frontend/src/js/components/PageViewerOrFallback.tsx @@ -1,14 +1,119 @@ -import { FC, useEffect, useState } from "react"; +import React, { FC, useEffect, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; import authFetch from "../util/auth/authFetch"; import { useParams } from "react-router-dom"; +import _ from "lodash"; import Viewer from "./viewer/Viewer"; import { PageViewer } from "./PageViewer/PageViewer"; -import React from "react"; +import { TextPreview } from "./viewer/TextPreview"; +import { Preview } from "./viewer/Preview"; +import { TablePreview } from "./viewer/TablePreview"; +import PreviewSwitcher from "./viewer/PreviewSwitcher"; +import DownloadButton from "./viewer/DownloadButton"; +import { GiantState } from "../types/redux/GiantState"; +import { Resource } from "../types/Resource"; +import { setResourceView } from "../actions/urlParams/setViews"; +import { getComments } from "../actions/resources/getComments"; +import { setSelection } from "../actions/resources/setSelection"; + +const COMBINED_VIEW = "combined"; + +function isCombinedOrUnset(view: string | undefined): boolean { + return !view || view === COMBINED_VIEW; +} + +function renderNoPreview() { + return ( +
+

+ Cannot display this document. It could still be processing or it could + be too large. +

+ +
+ ); +} + +const PageViewerContent: FC<{ + uri: string; + totalPages: number; + view: string | undefined; +}> = ({ uri, totalPages, view }) => { + const dispatch = useDispatch(); + const resource = useSelector( + (state) => state.resource, + ); + const auth = useSelector((state: GiantState) => state.auth); + const preferences = useSelector((state: GiantState) => state.app.preferences); + + if (isCombinedOrUnset(view)) { + return ; + } + + if (!resource) { + return null; + } + + if (view === "table") { + return ; + } else if (view === "preview") { + return ; + } else if ( + view!.startsWith("ocr") || + view!.startsWith("transcript") || + view!.startsWith("vttTranscript") + ) { + const highlightableText = _.get(resource, view!); + if (!highlightableText) { + return renderNoPreview(); + } + return ( + dispatch(getComments(u))} + setSelection={(s?: Selection) => dispatch(setSelection(s))} + /> + ); + } else if (view === "text") { + if (!resource.text) { + return renderNoPreview(); + } + return ( + dispatch(getComments(u))} + setSelection={(s?: Selection) => dispatch(setSelection(s))} + /> + ); + } + + return renderNoPreview(); +}; export const PageViewerOrFallback: FC<{}> = () => { const { uri } = useParams<{ uri: string }>(); - const [totalPages, setTotalPages] = useState(null); + const view = useSelector( + (state) => state.urlParams.view, + ); + const resource = useSelector( + (state) => state.resource, + ); + const dispatch = useDispatch(); useEffect(() => { authFetch(`/api/pages2/${uri}/pageCount`) @@ -16,11 +121,64 @@ export const PageViewerOrFallback: FC<{}> = () => { .then((obj) => setTotalPages(obj.pageCount)); }, [uri]); + // Default to "combined" when we have pages. + // Search URLs may set view=ocr.english etc., but for paged documents + // the combined view should always be the landing view. + useEffect(() => { + if (totalPages && totalPages > 0 && view !== COMBINED_VIEW) { + dispatch(setResourceView(COMBINED_VIEW)); + } + }, [totalPages, dispatch]); + if (totalPages === null) { return null; } else if (totalPages === 0) { return ; } else { - return ; + const showTextContent = !isCombinedOrUnset(view); + return ( +
+
+ {showTextContent ? ( +
+ +
+ ) : ( + + )} +
+ {resource && ( +
+ + + + +
+ )} +
+ ); } }; diff --git a/frontend/src/js/components/viewer/DocumentMetadata.js b/frontend/src/js/components/viewer/DocumentMetadata.js index 6135cf9d..e87d6d63 100644 --- a/frontend/src/js/components/viewer/DocumentMetadata.js +++ b/frontend/src/js/components/viewer/DocumentMetadata.js @@ -112,24 +112,7 @@ export class DocumentMetadata extends React.Component { }; renderTextViewLink() { - if (!window.location.href.includes("viewer/")) { - return null; - } - - const url = new URL(window.location); - url.href = url.href.replace("viewer", "viewer-old"); - url.searchParams.set("view", "text"); - - return ( - - View as text - - ); + return null; } render() { diff --git a/frontend/src/js/components/viewer/PreviewSwitcher.js b/frontend/src/js/components/viewer/PreviewSwitcher.js index 1f419e86..c373dc72 100644 --- a/frontend/src/js/components/viewer/PreviewSwitcher.js +++ b/frontend/src/js/components/viewer/PreviewSwitcher.js @@ -15,6 +15,7 @@ class PreviewSwitcher extends React.Component { static propTypes = { resource: resourcePropType, view: PropTypes.string, + totalPages: PropTypes.number, setResourceView: PropTypes.func.isRequired, }; @@ -23,6 +24,10 @@ class PreviewSwitcher extends React.Component { return false; } + if (this.props.view === "combined" && this.props.totalPages > 0) { + return true; + } + if ( this.props.view === "table" && (!resource.parents || @@ -136,6 +141,14 @@ class PreviewSwitcher extends React.Component { shortcut={keyboardShortcuts.showPreview} func={this.showPreview} /> + {this.props.totalPages > 0 && ( + + )} {this.props.resource.text && !this.props.resource.transcript ? ( Date: Fri, 13 Mar 2026 15:06:40 +0000 Subject: [PATCH 2/5] Use consistent dark background for all document views Apply the same dark background (previewDark) to the paged document viewer wrapper so that all view modes (Combined, Text, OCR, Preview) have a consistent appearance and the footer toolbar does not appear visually detached from the content area. --- frontend/src/js/components/PageViewerOrFallback.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/js/components/PageViewerOrFallback.tsx b/frontend/src/js/components/PageViewerOrFallback.tsx index b5b35658..eae2a6f9 100644 --- a/frontend/src/js/components/PageViewerOrFallback.tsx +++ b/frontend/src/js/components/PageViewerOrFallback.tsx @@ -144,6 +144,7 @@ export const PageViewerOrFallback: FC<{}> = () => { flexGrow: 1, height: "calc(100vh - 50px)", overflow: "hidden", + backgroundColor: "rgb(63, 63, 63)", }} >
Date: Fri, 13 Mar 2026 15:07:11 +0000 Subject: [PATCH 3/5] Improve sidebar text flow and button wrapping - Allow action buttons to wrap within the sidebar by adding flex-wrap and gap to btn-group, preventing overflow cropping - Remove white-space: nowrap from resource breadcrumb segments so location paths can use the full sidebar width - Increase breadcrumb segment truncation threshold from 15 to 26 characters to show more useful path information --- frontend/src/js/components/PageViewerOrFallback.tsx | 3 +++ .../src/js/components/ResourceBreadcrumbs/ResourceTrail.tsx | 4 ++-- frontend/src/stylesheets/components/_buttons.scss | 4 +++- frontend/src/stylesheets/components/_resource-browser.scss | 1 - 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/js/components/PageViewerOrFallback.tsx b/frontend/src/js/components/PageViewerOrFallback.tsx index eae2a6f9..de8b3ac7 100644 --- a/frontend/src/js/components/PageViewerOrFallback.tsx +++ b/frontend/src/js/components/PageViewerOrFallback.tsx @@ -124,10 +124,13 @@ export const PageViewerOrFallback: FC<{}> = () => { // Default to "combined" when we have pages. // Search URLs may set view=ocr.english etc., but for paged documents // the combined view should always be the landing view. + // `view` is intentionally omitted from deps — this should only run + // when totalPages first loads, not when the user switches views. useEffect(() => { if (totalPages && totalPages > 0 && view !== COMBINED_VIEW) { dispatch(setResourceView(COMBINED_VIEW)); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [totalPages, dispatch]); if (totalPages === null) { diff --git a/frontend/src/js/components/ResourceBreadcrumbs/ResourceTrail.tsx b/frontend/src/js/components/ResourceBreadcrumbs/ResourceTrail.tsx index c9a66fff..46d777ea 100644 --- a/frontend/src/js/components/ResourceBreadcrumbs/ResourceTrail.tsx +++ b/frontend/src/js/components/ResourceBreadcrumbs/ResourceTrail.tsx @@ -70,8 +70,8 @@ export function buildSegments(resource: BasicResource): PathSegment[] { } function shrinkDisplay(display: string): string { - if (display.length > 15) { - return display.substring(0, 15) + "..."; + if (display.length > 26) { + return display.substring(0, 26) + "..."; } return display; diff --git a/frontend/src/stylesheets/components/_buttons.scss b/frontend/src/stylesheets/components/_buttons.scss index be367c47..3c6de729 100644 --- a/frontend/src/stylesheets/components/_buttons.scss +++ b/frontend/src/stylesheets/components/_buttons.scss @@ -82,6 +82,8 @@ $btnBoxShadowHeight: 2px; display: flex; justify-content: space-between; align-items: center; + flex-wrap: wrap; + gap: calc($baseSpacing / 2); // Title with a button to the right &--title { @@ -94,7 +96,7 @@ $btnBoxShadowHeight: 2px; } & .btn:not(:last-child) { - margin-right: $baseSpacing; + margin-right: calc($baseSpacing / 2); } &--left { diff --git a/frontend/src/stylesheets/components/_resource-browser.scss b/frontend/src/stylesheets/components/_resource-browser.scss index 834366ce..085b179d 100644 --- a/frontend/src/stylesheets/components/_resource-browser.scss +++ b/frontend/src/stylesheets/components/_resource-browser.scss @@ -8,7 +8,6 @@ &__resource { padding-right: 3px; - white-space: nowrap; &:before { white-space: normal; From 59911bd53e0d2c8eb594d1e16ed4883ae067befb Mon Sep 17 00:00:00 2001 From: hoyla Date: Fri, 13 Mar 2026 15:57:22 +0000 Subject: [PATCH 4/5] Stop carrying view param across navigation links Remove the block in buildLink that propagated urlParams.view into generated links. The view param is document-specific (e.g. text, ocr, preview) and should not carry forward when navigating between pages. Previously this caused search result links to include the current view mode, so clicking a result while on an OCR view would land the next document on OCR instead of letting it choose its own default. View state is managed through Redux and the PreviewSwitcher component which sets an appropriate default when a document loads, so URL-based propagation is no longer needed. Also simplify the useEffect in PageViewerOrFallback to only set the combined view when no view is set, and include view in the dependency array (removing the eslint suppression). On refresh the user's chosen view is preserved since it remains in Redux. --- frontend/src/js/components/PageViewerOrFallback.tsx | 11 +++-------- frontend/src/js/util/buildLink.js | 4 ---- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/frontend/src/js/components/PageViewerOrFallback.tsx b/frontend/src/js/components/PageViewerOrFallback.tsx index de8b3ac7..909ebe5a 100644 --- a/frontend/src/js/components/PageViewerOrFallback.tsx +++ b/frontend/src/js/components/PageViewerOrFallback.tsx @@ -121,17 +121,12 @@ export const PageViewerOrFallback: FC<{}> = () => { .then((obj) => setTotalPages(obj.pageCount)); }, [uri]); - // Default to "combined" when we have pages. - // Search URLs may set view=ocr.english etc., but for paged documents - // the combined view should always be the landing view. - // `view` is intentionally omitted from deps — this should only run - // when totalPages first loads, not when the user switches views. + // Default to "combined" when we have pages and no view is set. useEffect(() => { - if (totalPages && totalPages > 0 && view !== COMBINED_VIEW) { + if (totalPages && totalPages > 0 && !view) { dispatch(setResourceView(COMBINED_VIEW)); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [totalPages, dispatch]); + }, [totalPages, view, dispatch]); if (totalPages === null) { return null; diff --git a/frontend/src/js/util/buildLink.js b/frontend/src/js/util/buildLink.js index 15dfd726..e11366ea 100644 --- a/frontend/src/js/util/buildLink.js +++ b/frontend/src/js/util/buildLink.js @@ -29,9 +29,5 @@ export default function buildLink(to, urlParams, overrides) { params.details = urlParams.details; } - if (!params.view && urlParams.view) { - params.view = urlParams.view; - } - return params ? `${encodedUri}?${objectToParamString(params)}` : encodedUri; } From ec9a9512894d0cbdd0e71926166a2d4963e1dcf3 Mon Sep 17 00:00:00 2001 From: hoyla Date: Sat, 14 Mar 2026 08:55:27 +0000 Subject: [PATCH 5/5] Use context-aware label for preview button based on mime type The PreviewSwitcher previously always labelled the preview tab 'Preview', which is misleading for audio and video files. The resource's mimeTypes are already available in the component, so we now derive the label from them: 'Video' for video/* types, 'Audio' for audio/* types, and 'Preview' as the default for PDFs, images, etc. The label logic is extracted into a pure exported function (previewLabelForMimeTypes) with a colocated test covering video, audio, non-media, and mixed mime type scenarios. --- .../js/components/viewer/PreviewSwitcher.js | 16 +++++++++- .../components/viewer/PreviewSwitcher.spec.ts | 29 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 frontend/src/js/components/viewer/PreviewSwitcher.spec.ts diff --git a/frontend/src/js/components/viewer/PreviewSwitcher.js b/frontend/src/js/components/viewer/PreviewSwitcher.js index c373dc72..d0f1bd1c 100644 --- a/frontend/src/js/components/viewer/PreviewSwitcher.js +++ b/frontend/src/js/components/viewer/PreviewSwitcher.js @@ -11,6 +11,16 @@ import { bindActionCreators } from "redux"; import { setResourceView } from "../../actions/urlParams/setViews"; +export function previewLabelForMimeTypes(mimeTypes) { + if (mimeTypes.some((m) => m.startsWith("video/"))) { + return "Video"; + } + if (mimeTypes.some((m) => m.startsWith("audio/"))) { + return "Audio"; + } + return "Preview"; +} + class PreviewSwitcher extends React.Component { static propTypes = { resource: resourcePropType, @@ -64,6 +74,10 @@ class PreviewSwitcher extends React.Component { return previewStatus !== "disabled"; } + previewLabel() { + return previewLabelForMimeTypes(this.props.resource?.mimeTypes ?? []); + } + componentDidUpdateOrMount() { if ( this.props.resource && @@ -174,7 +188,7 @@ class PreviewSwitcher extends React.Component { {this.canPreview(this.props.resource.previewStatus) ? ( diff --git a/frontend/src/js/components/viewer/PreviewSwitcher.spec.ts b/frontend/src/js/components/viewer/PreviewSwitcher.spec.ts new file mode 100644 index 00000000..bf948628 --- /dev/null +++ b/frontend/src/js/components/viewer/PreviewSwitcher.spec.ts @@ -0,0 +1,29 @@ +import { previewLabelForMimeTypes } from "./PreviewSwitcher"; + +describe("previewLabelForMimeTypes", () => { + test("returns 'Video' for video mime types", () => { + expect(previewLabelForMimeTypes(["video/mp4"])).toBe("Video"); + expect(previewLabelForMimeTypes(["video/webm"])).toBe("Video"); + expect(previewLabelForMimeTypes(["application/pdf", "video/mp4"])).toBe( + "Video", + ); + }); + + test("returns 'Audio' for audio mime types", () => { + expect(previewLabelForMimeTypes(["audio/mpeg"])).toBe("Audio"); + expect(previewLabelForMimeTypes(["audio/wav"])).toBe("Audio"); + expect(previewLabelForMimeTypes(["application/pdf", "audio/ogg"])).toBe( + "Audio", + ); + }); + + test("returns 'Preview' for non-media mime types", () => { + expect(previewLabelForMimeTypes(["application/pdf"])).toBe("Preview"); + expect(previewLabelForMimeTypes(["image/png"])).toBe("Preview"); + expect(previewLabelForMimeTypes([])).toBe("Preview"); + }); + + test("prefers 'Video' over 'Audio' when both are present", () => { + expect(previewLabelForMimeTypes(["audio/mpeg", "video/mp4"])).toBe("Video"); + }); +});