Files
Stirling-PDF/frontend/src/core/pages/HomePage.tsx
T
3fe8adc5cb Switch key areas to lazily import to improve Vite chunk size (#6278)
# Description of Changes
Vite currently warns that when it's bundling our code that the chunk
size is way too high because most of the imports are static so it can't
split them into smaller chunks. This PR changes a few key areas to use
lazy imports to try and make the chunks as small as possible with
minimal code changes.

Vite's warnings kick in at minified chunks being >500kB, and we've got a
little way to go still to reach that, but we can keep chipping away at
this and I'd rather get the biggest wins done now. I've also included
Lighthouse scores because there's been discussion about improving ours
recently. It's not the aim of this PR to improve it, but it's nice that
it makes it a little better.

## Current main chunks

Build split into 12 chunks. Largest chunk in build is:

```
[frontend:build] dist/assets/index-B6JiWDxZ.js               5,175.51 kB │ gzip: 1,495.85 kB
```

<img width="1442" height="775" alt="image"
src="https://github.com/user-attachments/assets/b0e8a3fa-4ef3-4ccd-8c1d-bfed2d99bd27"
/>

Lighthouse score:

<img width="423" height="146" alt="before"
src="https://github.com/user-attachments/assets/c62056e8-2e77-49a6-a1ae-f08ec8021fb3"
/>

## This PR's chunks

Build split into 176 chunks. Largest chunk in build is:

```
[frontend:build] dist/assets/index-qCgeCY4B.js                              2,878.54 kB │ gzip:   861.03 kB
```

<img width="1447" height="776" alt="image"
src="https://github.com/user-attachments/assets/8d0c3cf0-cc25-41c3-b114-4940d3e99349"
/>

Lighthouse score:

<img width="402" height="145" alt="after"
src="https://github.com/user-attachments/assets/99a26eb3-bd15-4b92-bf22-82b58b458f52"
/>

---------

Co-authored-by: EthanHealy01 <[email protected]>
2026-05-01 15:21:06 +00:00

379 lines
13 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { Group } from "@mantine/core";
import { useSidebarContext } from "@app/contexts/SidebarContext";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import { useBaseUrl } from "@app/hooks/useBaseUrl";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { LogoIcon } from "@app/components/shared/LogoIcon";
import { Wordmark } from "@app/components/shared/Wordmark";
import { useFileContext } from "@app/contexts/file/fileHooks";
import {
useNavigationState,
useNavigationActions,
} from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
import AppsIcon from "@mui/icons-material/AppsRounded";
import ToolPanel from "@app/components/tools/ToolPanel";
import Workbench from "@app/components/layout/Workbench";
import QuickAccessBar from "@app/components/shared/QuickAccessBar";
import RightRail from "@app/components/shared/RightRail";
import FileManager from "@app/components/FileManager";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import AppConfigModal from "@app/components/shared/AppConfigModalLazy";
import { getStartupNavigationAction } from "@app/utils/homePageNavigation";
import { HomePageExtensions } from "@app/components/home/HomePageExtensions";
import "@app/pages/HomePage.css";
type MobileView = "tools" | "workbench";
export default function HomePage() {
const { t } = useTranslation();
const { sidebarRefs } = useSidebarContext();
const { quickAccessRef } = sidebarRefs;
const {
selectedTool,
selectedToolKey,
handleToolSelect,
handleBackToTools,
readerMode,
setLeftPanelView,
toolAvailability,
customWorkbenchViews,
} = useToolWorkflow();
const { openFilesModal } = useFilesModalContext();
const { config } = useAppConfig();
const isMobile = useIsMobile();
const sliderRef = useRef<HTMLDivElement | null>(null);
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
const isProgrammaticScroll = useRef(false);
const [configModalOpen, setConfigModalOpen] = useState(false);
const { activeFiles } = useFileContext();
const navigationState = useNavigationState();
const { actions } = useNavigationActions();
const { setActiveFileIndex } = useViewer();
const prevFileCountRef = useRef(activeFiles.length);
// Startup/open transition behavior:
// - opening exactly 1 file from empty -> viewer (unless already in fileEditor)
// - opening 2+ files from empty -> fileEditor
useEffect(() => {
const prevCount = prevFileCountRef.current;
const currentCount = activeFiles.length;
console.log("[HomePage] Navigation effect triggered:", {
prevCount,
currentCount,
currentWorkbench: navigationState.workbench,
selectedToolKey,
});
const action = getStartupNavigationAction(
prevCount,
currentCount,
selectedToolKey,
navigationState.workbench,
);
console.log("[HomePage] Navigation action returned:", action);
if (action) {
console.log("[HomePage] Applying navigation:", action);
actions.setWorkbench(action.workbench);
if (typeof action.activeFileIndex === "number") {
setActiveFileIndex(action.activeFileIndex);
}
} else {
console.log("[HomePage] No navigation - staying in current workbench");
}
prevFileCountRef.current = currentCount;
}, [
activeFiles.length,
actions,
setActiveFileIndex,
selectedToolKey,
navigationState.workbench,
]);
const hideToolPanel =
customWorkbenchViews.find(
(v) => v.workbenchId === navigationState.workbench,
)?.hideToolPanel ?? false;
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const handleSelectMobileView = useCallback((view: MobileView) => {
setActiveMobileView(view);
}, []);
useEffect(() => {
if (isMobile) {
const container = sliderRef.current;
if (container) {
isProgrammaticScroll.current = true;
const offset = activeMobileView === "tools" ? 0 : container.offsetWidth;
container.scrollTo({ left: offset, behavior: "smooth" });
// Re-enable scroll listener after animation completes
setTimeout(() => {
isProgrammaticScroll.current = false;
}, 500);
}
return;
}
setActiveMobileView("tools");
const container = sliderRef.current;
if (container) {
container.scrollTo({ left: 0, behavior: "auto" });
}
}, [activeMobileView, isMobile]);
useEffect(() => {
if (!isMobile) return;
const container = sliderRef.current;
if (!container) return;
let animationFrame = 0;
const handleScroll = () => {
if (isProgrammaticScroll.current) {
return;
}
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
animationFrame = window.requestAnimationFrame(() => {
const { scrollLeft, offsetWidth } = container;
const threshold = offsetWidth / 2;
const nextView: MobileView =
scrollLeft >= threshold ? "workbench" : "tools";
setActiveMobileView((current) =>
current === nextView ? current : nextView,
);
});
};
container.addEventListener("scroll", handleScroll, { passive: true });
return () => {
container.removeEventListener("scroll", handleScroll);
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
};
}, [isMobile]);
// Automatically switch to workbench when read mode or multiTool is activated in mobile
useEffect(() => {
if (isMobile && (readerMode || selectedToolKey === "multiTool")) {
setActiveMobileView("workbench");
}
}, [isMobile, readerMode, selectedToolKey]);
// Automatically switch to workbench slide when a custom workbench (e.g. signing) is active on mobile.
// hideToolPanel is true for all custom workbenches that take over the full screen.
useEffect(() => {
if (isMobile && hideToolPanel) {
setActiveMobileView("workbench");
}
}, [isMobile, hideToolPanel]);
// When navigating back to tools view in mobile with a workbench-only tool, show tool picker
useEffect(() => {
if (isMobile && activeMobileView === "tools" && selectedTool) {
// Check if this is a workbench-only tool (has workbench but no component)
if (selectedTool.workbench && !selectedTool.component) {
setLeftPanelView("toolPicker");
}
}
}, [isMobile, activeMobileView, selectedTool, setLeftPanelView]);
const baseUrl = useBaseUrl();
// Update document meta when tool changes
const appName = config?.appNameNavbar || "Stirling PDF";
useDocumentMeta({
title: selectedTool ? `${selectedTool.name} - ${appName}` : appName,
description:
selectedTool?.description ||
t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
ogTitle: selectedTool ? `${selectedTool.name} - ${appName}` : appName,
ogDescription:
selectedTool?.description ||
t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
ogImage: selectedToolKey
? `${baseUrl}/og_images/${selectedToolKey}.png`
: `${baseUrl}/og_images/home.png`,
ogUrl: selectedTool ? `${baseUrl}${window.location.pathname}` : baseUrl,
});
// Note: File selection limits are now handled directly by individual tools
return (
<div className="h-screen overflow-hidden">
<HomePageExtensions />
{isMobile ? (
<div className="mobile-layout">
<div className="mobile-toggle">
<div className="mobile-header">
<div className="mobile-brand">
<LogoIcon className="mobile-brand-icon" />
<Wordmark alt={brandAltText} className="mobile-brand-text" />
</div>
</div>
<div
className="mobile-toggle-buttons"
role="tablist"
aria-label={t(
"home.mobile.viewSwitcher",
"Switch workspace view",
)}
>
<button
type="button"
role="tab"
aria-selected={activeMobileView === "tools"}
className={`mobile-toggle-button ${activeMobileView === "tools" ? "active" : ""}`}
onClick={() => handleSelectMobileView("tools")}
>
{t("home.mobile.tools", "Tools")}
</button>
<button
type="button"
role="tab"
aria-selected={activeMobileView === "workbench"}
className={`mobile-toggle-button ${activeMobileView === "workbench" ? "active" : ""}`}
onClick={() => handleSelectMobileView("workbench")}
>
{t("home.mobile.workspace", "Workspace")}
</button>
</div>
<span className="mobile-toggle-hint">
{t(
"home.mobile.swipeHint",
"Swipe left or right to switch views",
)}
</span>
</div>
<div ref={sliderRef} className="mobile-slider">
<div
className="mobile-slide"
aria-label={t("home.mobile.toolsSlide", "Tool selection panel")}
>
<div className="mobile-slide-content">
<ToolPanel />
</div>
</div>
<div
className="mobile-slide"
aria-label={t("home.mobile.workbenchSlide", "Workspace panel")}
>
<div className="mobile-slide-content">
<div className="flex-1 min-h-0 flex">
<Workbench />
<RightRail />
</div>
</div>
</div>
</div>
<div className="mobile-bottom-bar">
<button
className="mobile-bottom-button"
aria-label={t("quickAccess.allTools", "Tools")}
onClick={() => {
handleBackToTools();
if (isMobile) {
setActiveMobileView("tools");
}
}}
>
<AppsIcon sx={{ fontSize: "1.5rem" }} />
<span className="mobile-bottom-button-label">
{t("quickAccess.allTools", "Tools")}
</span>
</button>
{toolAvailability["automate"]?.available !== false && (
<button
className="mobile-bottom-button"
aria-label={t("quickAccess.automate", "Automate")}
onClick={() => {
handleToolSelect("automate");
if (isMobile) {
setActiveMobileView("tools");
}
}}
>
<LocalIcon
icon="automation-outline"
width="1.5rem"
height="1.5rem"
/>
<span className="mobile-bottom-button-label">
{t("quickAccess.automate", "Automate")}
</span>
</button>
)}
<button
className="mobile-bottom-button"
aria-label={t("home.mobile.openFiles", "Open files")}
onClick={() => openFilesModal()}
>
<LocalIcon icon="folder-rounded" width="1.5rem" height="1.5rem" />
<span className="mobile-bottom-button-label">
{t("quickAccess.files", "Files")}
</span>
</button>
<button
className="mobile-bottom-button"
aria-label={t("quickAccess.config", "Config")}
onClick={() => setConfigModalOpen(true)}
>
<LocalIcon
icon="settings-rounded"
width="1.5rem"
height="1.5rem"
/>
<span className="mobile-bottom-button-label">
{t("quickAccess.config", "Config")}
</span>
</button>
</div>
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
<AppConfigModal
opened={configModalOpen}
onClose={() => setConfigModalOpen(false)}
/>
</div>
) : (
<Group align="flex-start" gap={0} h="100%" className="flex-nowrap flex">
<QuickAccessBar ref={quickAccessRef} />
{!hideToolPanel && <ToolPanel />}
<Workbench />
<RightRail />
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
</Group>
)}
</div>
);
}