V2 Replace Google Fonts icons with locally bundled Iconify icons (#4283)

# Description of Changes

This PR refactors the frontend icon system to remove reliance on
@mui/icons-material and the Google Material Symbols webfont.

🔄 Changes

Introduced a new LocalIcon component powered by Iconify.
Added scripts/generate-icons.js to:
Scan the codebase for used icons.
Extract only required Material Symbols from
@iconify-json/material-symbols.
Generate a minimized JSON bundle and TypeScript types.
Updated .gitignore to exclude generated icon files.
Replaced all <span className="material-symbols-rounded"> and MUI icon
imports with <LocalIcon> usage.
Removed material-symbols CSS import and related font dependency.
Updated tsconfig.json to support JSON imports.
Added prebuild/predev hooks to auto-generate the icons.

 Benefits

No more 5MB+ Google webfont download → reduces initial page load size.
Smaller install footprint → no giant @mui/icons-material dependency.
Only ships the icons we actually use, cutting bundle size further.
Type-safe icons via auto-generated MaterialSymbolIcon union type.

Note most MUI not included in this update since they are low priority
due to small SVG sizing (don't grab whole bundle)


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: a <a>
This commit is contained in:
Anthony Stirling
2025-08-25 16:07:55 +01:00
committed by GitHub
parent 55ebf9ebd0
commit 73deece29e
19 changed files with 535 additions and 155 deletions

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { Container, Text, Button, Checkbox, Group, useMantineColorScheme } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import AddIcon from '@mui/icons-material/Add';
import LocalIcon from './LocalIcon';
import { useTranslation } from 'react-i18next';
import { useFileHandler } from '../../hooks/useFileHandler';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
@@ -138,7 +138,7 @@ const LandingPage = () => {
onClick={handleOpenFilesModal}
onMouseEnter={() => setIsUploadHover(false)}
>
<AddIcon className="text-[var(--accent-interactive)]" />
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
{!isUploadHover && (
<span>
{t('landing.addFiles', 'Add Files')}
@@ -165,7 +165,7 @@ const LandingPage = () => {
onClick={handleNativeUploadClick}
onMouseEnter={() => setIsUploadHover(true)}
>
<span className="material-symbols-rounded" style={{ fontSize: '1.25rem', color: 'var(--accent-interactive)' }}>upload</span>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
{isUploadHover && (
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Menu, Button, ScrollArea, ActionIcon } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { supportedLanguages } from '../../i18n';
import LanguageIcon from '@mui/icons-material/Language';
import LocalIcon from './LocalIcon';
import styles from './LanguageSelector.module.css';
interface LanguageSelectorProps {
@@ -105,13 +105,13 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
}
}}
>
<span className="material-symbols-rounded">language</span>
<LocalIcon icon="language" width="1.5rem" height="1.5rem" />
</ActionIcon>
) : (
<Button
variant="subtle"
size="sm"
leftSection={<LanguageIcon style={{ fontSize: 18 }} />}
leftSection={<LocalIcon icon="language" width="1.5rem" height="1.5rem" />}
styles={{
root: {
border: 'none',

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { addCollection, Icon } from '@iconify/react';
import iconSet from '../../assets/material-symbols-icons.json';
// Load icons synchronously at import time - guaranteed to be ready on first render
let iconsLoaded = false;
let localIconCount = 0;
try {
if (iconSet) {
addCollection(iconSet);
iconsLoaded = true;
localIconCount = Object.keys(iconSet.icons || {}).length;
console.info(`✅ Local icons loaded: ${localIconCount} icons (${Math.round(JSON.stringify(iconSet).length / 1024)}KB)`);
}
} catch (error) {
console.info(' Local icons not available - using CDN fallback');
}
interface LocalIconProps {
icon: string;
width?: string | number;
height?: string | number;
style?: React.CSSProperties;
className?: string;
}
/**
* LocalIcon component that uses our locally bundled Material Symbols icons
* instead of loading from CDN
*/
export const LocalIcon: React.FC<LocalIconProps> = ({ icon, ...props }) => {
// Convert our icon naming convention to the local collection format
const iconName = icon.startsWith('material-symbols:')
? icon
: `material-symbols:${icon}`;
// Development logging (only in dev mode)
if (process.env.NODE_ENV === 'development') {
const logKey = `icon-${iconName}`;
if (!sessionStorage.getItem(logKey)) {
const source = iconsLoaded ? 'local' : 'CDN';
console.debug(`🎯 Icon: ${iconName} (${source})`);
sessionStorage.setItem(logKey, 'logged');
}
}
// Always render the icon - Iconify will use local if available, CDN if not
return <Icon icon={iconName} {...props} />;
};
export default LocalIcon;

View File

@@ -1,9 +1,7 @@
import React, { useState, useRef, forwardRef, useEffect } from "react";
import { ActionIcon, Stack, Divider } from "@mantine/core";
import { useTranslation } from 'react-i18next';
import MenuBookIcon from "@mui/icons-material/MenuBookRounded";
import SettingsIcon from "@mui/icons-material/SettingsRounded";
import FolderIcon from "@mui/icons-material/FolderRounded";
import LocalIcon from './LocalIcon';
import { useRainbowThemeContext } from "./RainbowThemeProvider";
import AppConfigModal from './AppConfigModal';
import { useIsOverflowing } from '../../hooks/useIsOverflowing';
@@ -44,7 +42,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
{
id: 'read',
name: t("quickAccess.read", "Read"),
icon: <MenuBookIcon sx={{ fontSize: "1.5rem" }} />,
icon: <LocalIcon icon="menu-book-rounded" width="1.5rem" height="1.5rem" />,
size: 'lg',
isRound: false,
type: 'navigation',
@@ -54,29 +52,23 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
handleReaderToggle();
}
},
// TODO: Add sign tool
// {
// id: 'sign',
// name: t("quickAccess.sign", "Sign"),
// icon:
// <span className="material-symbols-rounded font-size-20">
// signature
// </span>,
// size: 'lg',
// isRound: false,
// type: 'navigation',
// onClick: () => {
// setActiveButton('sign');
// handleToolSelect('sign');
// }
// },
// TODO: Add sign
//{
// id: 'sign',
// name: t("quickAccess.sign", "Sign"),
// icon: <LocalIcon icon="signature-rounded" width="1.25rem" height="1.25rem" />,
// size: 'lg',
// isRound: false,
// type: 'navigation',
// onClick: () => {
// setActiveButton('sign');
// handleToolSelect('sign');
// }
//},
{
id: 'automate',
name: t("quickAccess.automate", "Automate"),
icon:
<span className="material-symbols-rounded font-size-20">
automation
</span>,
icon: <LocalIcon icon="automation-outline" width="1.25rem" height="1.25rem" />,
size: 'lg',
isRound: false,
type: 'navigation',
@@ -88,28 +80,26 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
{
id: 'files',
name: t("quickAccess.files", "Files"),
icon: <FolderIcon sx={{ fontSize: "1.25rem" }} />,
icon: <LocalIcon icon="folder-rounded" width="1.25rem" height="1.25rem" />,
isRound: true,
size: 'lg',
type: 'modal',
onClick: handleFilesButtonClick
},
{
id: 'activity',
name: t("quickAccess.activity", "Activity"),
icon:
<span className="material-symbols-rounded font-size-20">
vital_signs
</span>,
isRound: true,
size: 'lg',
type: 'navigation',
onClick: () => setActiveButton('activity')
},
//TODO: Activity
//{
// id: 'activity',
// name: t("quickAccess.activity", "Activity"),
// icon: <LocalIcon icon="vital-signs-rounded" width="1.25rem" height="1.25rem" />,
// isRound: true,
// size: 'lg',
// type: 'navigation',
// onClick: () => setActiveButton('activity')
//},
{
id: 'config',
name: t("quickAccess.config", "Config"),
icon: <SettingsIcon sx={{ fontSize: "1rem" }} />,
icon: <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
size: 'lg',
type: 'modal',
onClick: () => {
@@ -180,8 +170,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
</div>
{/* Add divider after Automate button (index 2) */}
{index === 2 && (
{/* Add divider after Automate button (index 1) and Files button (index 2) */}
{index === 1 && (
<Divider
size="xs"
className="content-divider"

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useState, useEffect, useMemo } from 'react';
import { ActionIcon, Divider, Popover } from '@mantine/core';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import LocalIcon from './LocalIcon';
import './rightRail/RightRail.css';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { useRightRail } from '../../contexts/RightRailContext';
@@ -234,9 +234,7 @@ export default function RightRail() {
onClick={handleSelectAll}
disabled={currentView === 'viewer' || totalItems === 0 || selectedCount === totalItems}
>
<span className="material-symbols-rounded">
select_all
</span>
<LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</Tooltip>
@@ -251,9 +249,7 @@ export default function RightRail() {
onClick={handleDeselectAll}
disabled={currentView === 'viewer' || selectedCount === 0}
>
<span className="material-symbols-rounded">
crop_square
</span>
<LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</Tooltip>
@@ -273,9 +269,7 @@ export default function RightRail() {
disabled={!pageControlsVisible || totalItems === 0}
aria-label={typeof t === 'function' ? t('rightRail.selectByNumber', 'Select by Page Numbers') : 'Select by Page Numbers'}
>
<span className="material-symbols-rounded">
pin_end
</span>
<LocalIcon icon="pin-end" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</Popover.Target>
@@ -309,7 +303,7 @@ export default function RightRail() {
disabled={!pageControlsVisible || (Array.isArray(selectedPageNumbers) ? selectedPageNumbers.length === 0 : true)}
aria-label={typeof t === 'function' ? t('rightRail.deleteSelected', 'Delete Selected Pages') : 'Delete Selected Pages'}
>
<span className="material-symbols-rounded">delete</span>
<LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</div>
@@ -331,7 +325,7 @@ export default function RightRail() {
(currentView === 'pageEditor' && (activeFiles.length === 0 || !pageEditorFunctions?.closePdf))
}
>
<CloseRoundedIcon />
<LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</Tooltip>
@@ -349,7 +343,7 @@ export default function RightRail() {
className="right-rail-icon"
onClick={toggleTheme}
>
<span className="material-symbols-rounded">contrast</span>
<LocalIcon icon="contrast" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
@@ -368,9 +362,7 @@ export default function RightRail() {
onClick={handleExportAll}
disabled={currentView === 'viewer' || totalItems === 0}
>
<span className="material-symbols-rounded">
download
</span>
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</Tooltip>

View File

@@ -1,5 +1,6 @@
import React, { forwardRef } from 'react';
import { useMantineColorScheme } from '@mantine/core';
import LocalIcon from './LocalIcon';
import styles from './textInput/TextInput.module.css';
/**
@@ -96,7 +97,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
style={{ color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382' }}
aria-label="Clear input"
>
<span className="material-symbols-rounded">close</span>
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
</button>
)}
</div>

View File

@@ -1,5 +1,6 @@
import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import LocalIcon from './LocalIcon';
import { isClickOutside, addEventListenerWithCleanup } from '../../utils/genericUtils';
import { useTooltipPosition } from '../../hooks/useTooltipPosition';
import { TooltipTip } from '../../types/tips';
@@ -171,9 +172,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
}}
title="Close tooltip"
>
<span className="material-symbols-rounded">
close
</span>
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
</button>
)}
{arrow && getArrowClass() && (