mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
Feature/v2/all tools sidebar (#4151)
# Description of Changes - Added the all tools sidebar - Added a TextFit component that shrinks text to fit containers - Added a TopToolIcon on the nav, that animates down to give users feedback on what tool is selected - Added the baseToolRegistry, to replace the old pattern of listing tools, allowing us to clean up the ToolRegistry code - Fixed Mantine light/dark theme race condition - General styling tweaks --- ## 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.
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* ActiveToolButton - Shows the currently selected tool at the top of the Quick Access Bar
|
||||
*
|
||||
* When a user selects a tool from the All Tools list, this component displays the tool's
|
||||
* icon and name at the top of the navigation bar. It provides a quick way to see which
|
||||
* tool is currently active and offers a back button to return to the All Tools list.
|
||||
*
|
||||
* Features:
|
||||
* - Shows tool icon and name when a tool is selected
|
||||
* - Hover to reveal back arrow for returning to All Tools
|
||||
* - Smooth slide-down/slide-up animations
|
||||
* - Only appears for tools that don't have dedicated nav buttons (read, sign, automate)
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { ActionIcon } from '@mantine/core';
|
||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||
import { useToolWorkflow } from '../../../contexts/ToolWorkflowContext';
|
||||
import FitText from '../FitText';
|
||||
import { Tooltip } from '../Tooltip';
|
||||
|
||||
interface ActiveToolButtonProps {
|
||||
activeButton: string;
|
||||
setActiveButton: (id: string) => void;
|
||||
}
|
||||
|
||||
const NAV_IDS = ['read', 'sign', 'automate'];
|
||||
|
||||
const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setActiveButton }) => {
|
||||
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow();
|
||||
|
||||
// Determine if the indicator should be visible (do not require selectedTool to be resolved yet)
|
||||
const indicatorShouldShow = Boolean(
|
||||
selectedToolKey && leftPanelView === 'toolContent' && !NAV_IDS.includes(selectedToolKey)
|
||||
);
|
||||
|
||||
// Local animation and hover state
|
||||
const [indicatorTool, setIndicatorTool] = useState<typeof selectedTool | null>(null);
|
||||
const [indicatorVisible, setIndicatorVisible] = useState<boolean>(false);
|
||||
const [replayAnim, setReplayAnim] = useState<boolean>(false);
|
||||
const [isAnimating, setIsAnimating] = useState<boolean>(false);
|
||||
const [isBackHover, setIsBackHover] = useState<boolean>(false);
|
||||
const prevKeyRef = useRef<string | null>(null);
|
||||
const collapseTimeoutRef = useRef<number | null>(null);
|
||||
const animTimeoutRef = useRef<number | null>(null);
|
||||
const replayRafRef = useRef<number | null>(null);
|
||||
|
||||
const isSwitchingToNewTool = () => { return prevKeyRef.current && prevKeyRef.current !== selectedToolKey };
|
||||
|
||||
const clearTimers = () => {
|
||||
if (collapseTimeoutRef.current) {
|
||||
window.clearTimeout(collapseTimeoutRef.current);
|
||||
collapseTimeoutRef.current = null;
|
||||
}
|
||||
if (animTimeoutRef.current) {
|
||||
window.clearTimeout(animTimeoutRef.current);
|
||||
animTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const playGrowDown = () => {
|
||||
clearTimers();
|
||||
setIndicatorTool(selectedTool);
|
||||
setIndicatorVisible(true);
|
||||
// Force a replay even if the class is already applied
|
||||
setReplayAnim(false);
|
||||
if (replayRafRef.current) {
|
||||
cancelAnimationFrame(replayRafRef.current);
|
||||
replayRafRef.current = null;
|
||||
}
|
||||
replayRafRef.current = requestAnimationFrame(() => {
|
||||
setReplayAnim(true);
|
||||
});
|
||||
setIsAnimating(true);
|
||||
prevKeyRef.current = (selectedToolKey as string) || null;
|
||||
animTimeoutRef.current = window.setTimeout(() => {
|
||||
setReplayAnim(false);
|
||||
setIsAnimating(false);
|
||||
animTimeoutRef.current = null;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
const firstShow = () => {
|
||||
clearTimers();
|
||||
setIndicatorTool(selectedTool);
|
||||
setIndicatorVisible(true);
|
||||
setIsAnimating(true);
|
||||
prevKeyRef.current = (selectedToolKey as string) || null;
|
||||
animTimeoutRef.current = window.setTimeout(() => {
|
||||
setIsAnimating(false);
|
||||
animTimeoutRef.current = null;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
const triggerCollapse = () => {
|
||||
clearTimers();
|
||||
setIndicatorVisible(false);
|
||||
setIsAnimating(true);
|
||||
collapseTimeoutRef.current = window.setTimeout(() => {
|
||||
setIndicatorTool(null);
|
||||
prevKeyRef.current = null;
|
||||
setIsAnimating(false);
|
||||
collapseTimeoutRef.current = null;
|
||||
}, 500); // match CSS transition duration
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (indicatorShouldShow) {
|
||||
clearTimers();
|
||||
if (!indicatorVisible) {
|
||||
firstShow();
|
||||
return;
|
||||
}
|
||||
if (!indicatorTool) {
|
||||
firstShow();
|
||||
} else if (isSwitchingToNewTool()) {
|
||||
playGrowDown();
|
||||
} else {
|
||||
// keep reference in sync
|
||||
prevKeyRef.current = (selectedToolKey as string) || null;
|
||||
}
|
||||
} else if (indicatorTool || indicatorVisible) {
|
||||
triggerCollapse();
|
||||
}
|
||||
}, [indicatorShouldShow, selectedTool, selectedToolKey]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimers();
|
||||
if (replayRafRef.current) {
|
||||
cancelAnimationFrame(replayRafRef.current);
|
||||
replayRafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ overflow: 'visible' }} className={`current-tool-slot ${indicatorVisible ? 'visible' : ''} ${replayAnim ? 'replay' : ''}`}>
|
||||
{indicatorTool && (
|
||||
<div className="current-tool-content">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Tooltip content={isBackHover ? 'Back to all tools' : indicatorTool.name} position="right" arrow maxWidth={140}>
|
||||
<ActionIcon
|
||||
size={'xl'}
|
||||
variant="subtle"
|
||||
onMouseEnter={() => setIsBackHover(true)}
|
||||
onMouseLeave={() => setIsBackHover(false)}
|
||||
onClick={() => {
|
||||
setActiveButton('tools');
|
||||
handleBackToTools();
|
||||
}}
|
||||
aria-label={isBackHover ? 'Back to all tools' : indicatorTool.name}
|
||||
style={{
|
||||
backgroundColor: isBackHover ? 'var(--color-gray-300)' : 'var(--icon-tools-bg)',
|
||||
color: isBackHover ? '#fff' : 'var(--icon-tools-color)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<span className="iconContainer">
|
||||
{isBackHover ? (
|
||||
<ArrowBackRoundedIcon sx={{ fontSize: '1.5rem' }} />
|
||||
) : (
|
||||
indicatorTool.icon
|
||||
)}
|
||||
</span>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<FitText
|
||||
as="span"
|
||||
text={indicatorTool.name}
|
||||
lines={3}
|
||||
minimumFontScale={0.4}
|
||||
className="button-text active current-tool-label"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActiveToolButton;
|
||||
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
.activeIconScale {
|
||||
transform: scale(1.3);
|
||||
transition: transform 0.2s;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.iconContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
/* Action icon styles */
|
||||
.action-icon-style {
|
||||
background-color: var(--icon-user-bg);
|
||||
color: var(--icon-user-color);
|
||||
border-radius: 50%;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
/* Main container styles */
|
||||
.quick-access-bar-main {
|
||||
background-color: var(--bg-muted);
|
||||
width: 5rem;
|
||||
min-width: 5rem;
|
||||
max-width: 5rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Rainbow mode container */
|
||||
.quick-access-bar-main.rainbow-mode {
|
||||
background-color: var(--bg-muted);
|
||||
width: 5rem;
|
||||
min-width: 5rem;
|
||||
max-width: 5rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Header padding */
|
||||
.quick-access-header {
|
||||
padding: 1rem 0.5rem 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
.nav-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Nav header divider */
|
||||
.nav-header-divider {
|
||||
width: 3.75rem;
|
||||
border-color: var(--color-gray-300);
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* All tools text styles */
|
||||
.all-tools-text {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-synthesis: none;
|
||||
text-align: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.all-tools-text.active {
|
||||
color: var(--text-primary);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.all-tools-text.inactive {
|
||||
color: var(--color-gray-700);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* Overflow divider */
|
||||
.overflow-divider {
|
||||
width: 3.75rem;
|
||||
border-color: var(--color-gray-300);
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
/* Scrollable content area */
|
||||
.quick-access-bar {
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 0 0.5rem 1rem 0.5rem;
|
||||
}
|
||||
|
||||
/* Scrollable content container */
|
||||
.scrollable-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* Button text styles */
|
||||
.button-text {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-synthesis: none;
|
||||
text-align: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Allow wrapping under the active top indicator; constrain to two lines */
|
||||
.current-tool-label {
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2; /* show up to two lines */
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: normal;
|
||||
hyphens: manual;
|
||||
}
|
||||
|
||||
.button-text.active {
|
||||
color: var(--text-primary);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.button-text.inactive {
|
||||
color: var(--color-gray-700);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* Content divider */
|
||||
.content-divider {
|
||||
width: 3.75rem;
|
||||
border-color: var(--color-gray-300);
|
||||
}
|
||||
|
||||
/* Spacer */
|
||||
.spacer {
|
||||
flex: 1;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Config button text */
|
||||
.config-button-text {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-gray-700);
|
||||
font-weight: normal;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
/* Font size utility */
|
||||
.font-size-20 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* Hide scrollbar by default, show on scroll (Webkit browsers - Chrome, Safari, Edge) */
|
||||
.quick-access-bar::-webkit-scrollbar {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.quick-access-bar:hover::-webkit-scrollbar,
|
||||
.quick-access-bar:active::-webkit-scrollbar,
|
||||
.quick-access-bar:focus::-webkit-scrollbar {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.quick-access-bar::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.quick-access-bar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Firefox scrollbar styling */
|
||||
.quick-access-bar {
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||
}
|
||||
|
||||
/* Animated current tool indicator that slides in from the top and pushes content down */
|
||||
/* Container grows down so it pushes items below during animation */
|
||||
.current-tool-slot {
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
transition: max-height 450ms ease-out, opacity 300ms ease-out;
|
||||
}
|
||||
|
||||
.current-tool-enter {
|
||||
animation: currentToolGrowDown 450ms ease-out;
|
||||
}
|
||||
|
||||
.current-tool-slot.visible {
|
||||
max-height: 8.25rem; /* icon + up to 3-line label + divider (132px) */
|
||||
opacity: 1;
|
||||
border-bottom: 1px solid var(--color-gray-300);
|
||||
padding-bottom: 0.75rem; /* push border down for spacing */
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Replay the grow-down animation when switching tools while visible */
|
||||
.current-tool-slot.replay .current-tool-content {
|
||||
animation: currentToolGrowDown 450ms ease-out;
|
||||
}
|
||||
|
||||
/* Also animate the container itself when replaying so it "pushes down" again */
|
||||
.current-tool-slot.replay {
|
||||
animation: currentToolGrowDown 450ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes currentToolGrowDown {
|
||||
0% {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
max-height: 7.875rem; /* enough space for icon + up to 3-line label (126px) */
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Divider that animates growing from top */
|
||||
.current-tool-divider {
|
||||
width: 3.75rem;
|
||||
border-color: var(--color-gray-300);
|
||||
margin: 0.5rem auto 0.5rem auto;
|
||||
transform-origin: top;
|
||||
animation: dividerGrowDown 350ms ease-out;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@keyframes dividerGrowDown {
|
||||
0% {
|
||||
transform: scaleY(0);
|
||||
opacity: 0;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scaleY(1);
|
||||
opacity: 1;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ButtonConfig } from '../../../types/sidebar';
|
||||
|
||||
// Border radius constants
|
||||
export const ROUND_BORDER_RADIUS = '0.5rem';
|
||||
|
||||
/**
|
||||
* Check if a navigation button is currently active
|
||||
*/
|
||||
export const isNavButtonActive = (
|
||||
config: ButtonConfig,
|
||||
activeButton: string,
|
||||
isFilesModalOpen: boolean,
|
||||
configModalOpen: boolean,
|
||||
selectedToolKey?: string | null,
|
||||
leftPanelView?: 'toolPicker' | 'toolContent'
|
||||
): boolean => {
|
||||
const isActiveByLocalState = config.type === 'navigation' && activeButton === config.id;
|
||||
const isActiveByContext =
|
||||
config.type === 'navigation' &&
|
||||
leftPanelView === 'toolContent' &&
|
||||
selectedToolKey === config.id;
|
||||
const isActiveByModal =
|
||||
(config.type === 'modal' && config.id === 'files' && isFilesModalOpen) ||
|
||||
(config.type === 'modal' && config.id === 'config' && configModalOpen);
|
||||
|
||||
return isActiveByLocalState || isActiveByContext || isActiveByModal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get button styles based on active state
|
||||
*/
|
||||
export const getNavButtonStyle = (
|
||||
config: ButtonConfig,
|
||||
activeButton: string,
|
||||
isFilesModalOpen: boolean,
|
||||
configModalOpen: boolean,
|
||||
selectedToolKey?: string | null,
|
||||
leftPanelView?: 'toolPicker' | 'toolContent'
|
||||
) => {
|
||||
const isActive = isNavButtonActive(
|
||||
config,
|
||||
activeButton,
|
||||
isFilesModalOpen,
|
||||
configModalOpen,
|
||||
selectedToolKey,
|
||||
leftPanelView
|
||||
);
|
||||
|
||||
if (isActive) {
|
||||
return {
|
||||
backgroundColor: `var(--icon-${config.id}-bg)`,
|
||||
color: `var(--icon-${config.id}-color)`,
|
||||
border: 'none',
|
||||
borderRadius: ROUND_BORDER_RADIUS,
|
||||
};
|
||||
}
|
||||
|
||||
// Inactive state for all buttons
|
||||
return {
|
||||
backgroundColor: 'var(--icon-inactive-bg)',
|
||||
color: 'var(--icon-inactive-color)',
|
||||
border: 'none',
|
||||
borderRadius: ROUND_BORDER_RADIUS,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Determine the active nav button based on current tool state and registry
|
||||
*/
|
||||
export const getActiveNavButton = (
|
||||
selectedToolKey: string | null,
|
||||
readerMode: boolean
|
||||
): string => {
|
||||
// Reader mode takes precedence and should highlight the Read nav item
|
||||
if (readerMode) {
|
||||
return 'read';
|
||||
}
|
||||
// If a tool is selected, highlight it immediately even if the panel view
|
||||
// transition to 'toolContent' has not completed yet. This prevents a brief
|
||||
// period of no-highlight during rapid navigation.
|
||||
return selectedToolKey ? selectedToolKey : 'tools';
|
||||
};
|
||||
Reference in New Issue
Block a user