mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Bug/v2/fix rtl (#4958)
This commit is contained in:
@@ -18,7 +18,7 @@ export function useFileEditorRightRailButtons({
|
|||||||
onDeselectAll,
|
onDeselectAll,
|
||||||
onCloseSelected,
|
onCloseSelected,
|
||||||
}: FileEditorRightRailButtonsParams) {
|
}: FileEditorRightRailButtonsParams) {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
const buttons = useMemo<RightRailButtonWithAction[]>(() => [
|
const buttons = useMemo<RightRailButtonWithAction[]>(() => [
|
||||||
{
|
{
|
||||||
@@ -54,7 +54,7 @@ export function useFileEditorRightRailButtons({
|
|||||||
visible: totalItems > 0,
|
visible: totalItems > 0,
|
||||||
onClick: onCloseSelected,
|
onClick: onCloseSelected,
|
||||||
},
|
},
|
||||||
], [t, totalItems, selectedCount, onSelectAll, onDeselectAll, onCloseSelected]);
|
], [t, i18n.language, totalItems, selectedCount, onSelectAll, onDeselectAll, onCloseSelected]);
|
||||||
|
|
||||||
useRightRailButtons(buttons);
|
useRightRailButtons(buttons);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,3 +31,15 @@
|
|||||||
inset 0 0 30px rgba(59, 130, 246, 0.2);
|
inset 0 0 30px rgba(59, 130, 246, 0.2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* RTL: mirror step indicator and controls in Reactour popovers */
|
||||||
|
:root[dir='rtl'] .reactour__popover {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Minimal overrides retained for glow only */
|
||||||
|
|
||||||
|
:root[dir='rtl'] .reactour__badge {
|
||||||
|
left: auto;
|
||||||
|
right: 16px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
|||||||
import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
|
import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
|
||||||
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
|
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
|
||||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import CheckIcon from '@mui/icons-material/Check';
|
import CheckIcon from '@mui/icons-material/Check';
|
||||||
import TourWelcomeModal from '@app/components/onboarding/TourWelcomeModal';
|
import TourWelcomeModal from '@app/components/onboarding/TourWelcomeModal';
|
||||||
import '@app/components/onboarding/OnboardingTour.css';
|
import '@app/components/onboarding/OnboardingTour.css';
|
||||||
@@ -70,6 +71,7 @@ export default function OnboardingTour() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { completeTour, showWelcomeModal, setShowWelcomeModal, startTour, tourType, isOpen } = useOnboarding();
|
const { completeTour, showWelcomeModal, setShowWelcomeModal, startTour, tourType, isOpen } = useOnboarding();
|
||||||
const { openFilesModal, closeFilesModal } = useFilesModalContext();
|
const { openFilesModal, closeFilesModal } = useFilesModalContext();
|
||||||
|
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
|
||||||
|
|
||||||
// Helper to add glow to multiple elements
|
// Helper to add glow to multiple elements
|
||||||
const addGlowToElements = (selectors: string[]) => {
|
const addGlowToElements = (selectors: string[]) => {
|
||||||
@@ -438,6 +440,7 @@ export default function OnboardingTour() {
|
|||||||
handleCloseTour(clickProps);
|
handleCloseTour(clickProps);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
rtl={isRTL}
|
||||||
styles={{
|
styles={{
|
||||||
popover: (base) => ({
|
popover: (base) => ({
|
||||||
...base,
|
...base,
|
||||||
@@ -466,21 +469,19 @@ export default function OnboardingTour() {
|
|||||||
showBadge={false}
|
showBadge={false}
|
||||||
showCloseButton={true}
|
showCloseButton={true}
|
||||||
disableInteraction={true}
|
disableInteraction={true}
|
||||||
disableDotsNavigation={true}
|
disableDotsNavigation={false}
|
||||||
prevButton={() => null}
|
prevButton={() => null}
|
||||||
nextButton={({ currentStep, stepsLength, setCurrentStep, setIsOpen }) => {
|
nextButton={({ currentStep, stepsLength, setCurrentStep, setIsOpen }) => {
|
||||||
const isLast = currentStep === stepsLength - 1;
|
const isLast = currentStep === stepsLength - 1;
|
||||||
|
const ArrowIcon = isRTL ? ArrowBackIcon : ArrowForwardIcon;
|
||||||
return (
|
return (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => {
|
onClick={() => advanceTour({ setCurrentStep, currentStep, steps, setIsOpen })}
|
||||||
advanceTour({ setCurrentStep, currentStep, steps, setIsOpen });
|
|
||||||
}}
|
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={isLast ? t('onboarding.finish', 'Finish') : t('onboarding.next', 'Next')}
|
aria-label={isLast ? t('onboarding.finish', 'Finish') : t('onboarding.next', 'Next')}
|
||||||
>
|
>
|
||||||
{isLast ? <CheckIcon /> : <ArrowForwardIcon />}
|
{isLast ? <CheckIcon /> : <ArrowIcon />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
|||||||
closePdf,
|
closePdf,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
// Lift i18n labels out of memo for clarity
|
// Lift i18n labels out of memo for clarity
|
||||||
const selectAllLabel = t('rightRail.selectAll', 'Select All');
|
const selectAllLabel = t('rightRail.selectAll', 'Select All');
|
||||||
@@ -144,6 +144,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
|||||||
];
|
];
|
||||||
}, [
|
}, [
|
||||||
t,
|
t,
|
||||||
|
i18n.language,
|
||||||
selectAllLabel,
|
selectAllLabel,
|
||||||
deselectAllLabel,
|
deselectAllLabel,
|
||||||
selectByNumberLabel,
|
selectByNumberLabel,
|
||||||
|
|||||||
@@ -10,9 +10,14 @@ import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
|
|||||||
interface AllToolsNavButtonProps {
|
interface AllToolsNavButtonProps {
|
||||||
activeButton: string;
|
activeButton: string;
|
||||||
setActiveButton: (id: string) => void;
|
setActiveButton: (id: string) => void;
|
||||||
|
tooltipPosition?: 'left' | 'right' | 'top' | 'bottom';
|
||||||
}
|
}
|
||||||
|
|
||||||
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, setActiveButton }) => {
|
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
|
||||||
|
activeButton,
|
||||||
|
setActiveButton,
|
||||||
|
tooltipPosition = 'right'
|
||||||
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
|
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
|
||||||
const { getHomeNavigation } = useSidebarNavigation();
|
const { getHomeNavigation } = useSidebarNavigation();
|
||||||
@@ -40,7 +45,13 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip content={t("quickAccess.allTools", "Tools")} position="right" arrow containerStyle={{ marginTop: "-1rem" }} maxWidth={200}>
|
<Tooltip
|
||||||
|
content={t("quickAccess.allTools", "Tools")}
|
||||||
|
position={tooltipPosition}
|
||||||
|
arrow
|
||||||
|
containerStyle={{ marginTop: "-1rem" }}
|
||||||
|
maxWidth={200}
|
||||||
|
>
|
||||||
<div className="flex flex-col items-center gap-1 mt-4 mb-2">
|
<div className="flex flex-col items-center gap-1 mt-4 mb-2">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
component="a"
|
component="a"
|
||||||
@@ -70,4 +81,3 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
|
|||||||
|
|
||||||
export default AllToolsNavButton;
|
export default AllToolsNavButton;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,11 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
|
|||||||
|
|
||||||
// Clear ripple effect
|
// Clear ripple effect
|
||||||
setTimeout(() => setRippleEffect(null), 100);
|
setTimeout(() => setRippleEffect(null), 100);
|
||||||
|
|
||||||
|
// Force a full reload so RTL/LTR layout and tooltips re-evaluate correctly
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
}, 200);
|
}, 200);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
|||||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||||
const isOverflow = useIsOverflowing(scrollableRef);
|
const isOverflow = useIsOverflowing(scrollableRef);
|
||||||
|
|
||||||
|
const isRTL = typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
|
||||||
|
|
||||||
// Open modal if URL is at /settings/*
|
// Open modal if URL is at /settings/*
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isSettings = location.pathname.startsWith('/settings');
|
const isSettings = location.pathname.startsWith('/settings');
|
||||||
@@ -189,9 +191,6 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
data-sidebar="quick-access"
|
data-sidebar="quick-access"
|
||||||
className={`h-screen flex flex-col w-16 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
|
className={`h-screen flex flex-col w-16 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
|
||||||
style={{
|
|
||||||
borderRight: '1px solid var(--border-default)'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Fixed header outside scrollable area */}
|
{/* Fixed header outside scrollable area */}
|
||||||
<div className="quick-access-header">
|
<div className="quick-access-header">
|
||||||
@@ -278,7 +277,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
|||||||
// If admin, show menu with both options
|
// If admin, show menu with both options
|
||||||
return (
|
return (
|
||||||
<div key={buttonConfig.id} data-tour="help-button">
|
<div key={buttonConfig.id} data-tour="help-button">
|
||||||
<Menu position="right" offset={10} zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}>
|
<Menu position={isRTL ? 'left' : 'right'} offset={10} zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<div>{renderNavButton(buttonConfig, index)}</div>
|
<div>{renderNavButton(buttonConfig, index)}</div>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|||||||
@@ -19,19 +19,22 @@ import LightModeIcon from '@mui/icons-material/LightMode';
|
|||||||
|
|
||||||
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||||
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail';
|
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail';
|
||||||
|
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
|
||||||
|
|
||||||
const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom'];
|
const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom'];
|
||||||
|
|
||||||
function renderWithTooltip(
|
function renderWithTooltip(
|
||||||
node: React.ReactNode,
|
node: React.ReactNode,
|
||||||
tooltip: React.ReactNode | undefined
|
tooltip: React.ReactNode | undefined,
|
||||||
|
position: 'left' | 'right',
|
||||||
|
offset: number
|
||||||
) {
|
) {
|
||||||
if (!tooltip) return node;
|
if (!tooltip) return node;
|
||||||
|
|
||||||
const portalTarget = typeof document !== 'undefined' ? document.body : undefined;
|
const portalTarget = typeof document !== 'undefined' ? document.body : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip content={tooltip} position="left" offset={12} arrow portalTarget={portalTarget}>
|
<Tooltip content={tooltip} position={position} offset={offset} arrow portalTarget={portalTarget}>
|
||||||
<div className="right-rail-tooltip-wrapper">{node}</div>
|
<div className="right-rail-tooltip-wrapper">{node}</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
@@ -39,6 +42,7 @@ function renderWithTooltip(
|
|||||||
|
|
||||||
export default function RightRail() {
|
export default function RightRail() {
|
||||||
const { sidebarRefs } = useSidebarContext();
|
const { sidebarRefs } = useSidebarContext();
|
||||||
|
const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const viewerContext = React.useContext(ViewerContext);
|
const viewerContext = React.useContext(ViewerContext);
|
||||||
const { toggleTheme, themeMode } = useRainbowThemeContext();
|
const { toggleTheme, themeMode } = useRainbowThemeContext();
|
||||||
@@ -117,9 +121,9 @@ export default function RightRail() {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
);
|
);
|
||||||
|
|
||||||
return renderWithTooltip(buttonNode, btn.tooltip);
|
return renderWithTooltip(buttonNode, btn.tooltip, tooltipPosition, tooltipOffset);
|
||||||
},
|
},
|
||||||
[actions, allButtonsDisabled, disableForFullscreen]
|
[actions, allButtonsDisabled, disableForFullscreen, tooltipPosition, tooltipOffset]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleExportAll = useCallback(async () => {
|
const handleExportAll = useCallback(async () => {
|
||||||
@@ -203,14 +207,18 @@ export default function RightRail() {
|
|||||||
<DarkModeIcon sx={{ fontSize: '1.5rem' }} />
|
<DarkModeIcon sx={{ fontSize: '1.5rem' }} />
|
||||||
)}
|
)}
|
||||||
</ActionIcon>,
|
</ActionIcon>,
|
||||||
t('rightRail.toggleTheme', 'Toggle Theme')
|
t('rightRail.toggleTheme', 'Toggle Theme'),
|
||||||
|
tooltipPosition,
|
||||||
|
tooltipOffset
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{renderWithTooltip(
|
{renderWithTooltip(
|
||||||
<div style={{ display: 'inline-flex' }}>
|
<div style={{ display: 'inline-flex' }}>
|
||||||
<LanguageSelector position="left-start" offset={6} compact />
|
<LanguageSelector position="left-start" offset={6} compact />
|
||||||
</div>,
|
</div>,
|
||||||
t('rightRail.language', 'Language')
|
t('rightRail.language', 'Language'),
|
||||||
|
tooltipPosition,
|
||||||
|
tooltipOffset
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{renderWithTooltip(
|
{renderWithTooltip(
|
||||||
@@ -226,7 +234,9 @@ export default function RightRail() {
|
|||||||
>
|
>
|
||||||
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
|
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
|
||||||
</ActionIcon>,
|
</ActionIcon>,
|
||||||
downloadTooltip
|
downloadTooltip,
|
||||||
|
tooltipPosition,
|
||||||
|
tooltipOffset
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export interface TooltipProps {
|
|||||||
|
|
||||||
export const Tooltip: React.FC<TooltipProps> = ({
|
export const Tooltip: React.FC<TooltipProps> = ({
|
||||||
sidebarTooltip = false,
|
sidebarTooltip = false,
|
||||||
position = 'right',
|
position,
|
||||||
content,
|
content,
|
||||||
tips,
|
tips,
|
||||||
children,
|
children,
|
||||||
@@ -81,6 +81,16 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
const isControlled = controlledOpen !== undefined;
|
const isControlled = controlledOpen !== undefined;
|
||||||
const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled;
|
const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled;
|
||||||
|
|
||||||
|
const resolvedPosition: NonNullable<TooltipProps['position']> = useMemo(() => {
|
||||||
|
const htmlDir = typeof document !== 'undefined' ? document.documentElement.dir : 'ltr';
|
||||||
|
const isRTL = htmlDir === 'rtl';
|
||||||
|
const base = position ?? 'right';
|
||||||
|
if (!isRTL) return base as NonNullable<TooltipProps['position']>;
|
||||||
|
if (base === 'left') return 'right';
|
||||||
|
if (base === 'right') return 'left';
|
||||||
|
return base as NonNullable<TooltipProps['position']>;
|
||||||
|
}, [position]);
|
||||||
|
|
||||||
const setOpen = useCallback(
|
const setOpen = useCallback(
|
||||||
(newOpen: boolean) => {
|
(newOpen: boolean) => {
|
||||||
if (newOpen === open) return; // avoid churn
|
if (newOpen === open) return; // avoid churn
|
||||||
@@ -94,7 +104,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
const { coords, positionReady } = useTooltipPosition({
|
const { coords, positionReady } = useTooltipPosition({
|
||||||
open,
|
open,
|
||||||
sidebarTooltip,
|
sidebarTooltip,
|
||||||
position,
|
position: resolvedPosition,
|
||||||
gap,
|
gap,
|
||||||
triggerRef,
|
triggerRef,
|
||||||
tooltipRef,
|
tooltipRef,
|
||||||
@@ -145,8 +155,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
left: 'tooltip-arrow-left',
|
left: 'tooltip-arrow-left',
|
||||||
right: 'tooltip-arrow-right',
|
right: 'tooltip-arrow-right',
|
||||||
};
|
};
|
||||||
return map[position] || map.right;
|
return map[resolvedPosition] || map.right;
|
||||||
}, [position, sidebarTooltip]);
|
}, [resolvedPosition, sidebarTooltip]);
|
||||||
|
|
||||||
const getArrowStyleClass = useCallback(
|
const getArrowStyleClass = useCallback(
|
||||||
(key: string) =>
|
(key: string) =>
|
||||||
@@ -332,7 +342,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
className={`${styles['tooltip-arrow']} ${getArrowStyleClass(arrowClass!)}`}
|
className={`${styles['tooltip-arrow']} ${getArrowStyleClass(arrowClass!)}`}
|
||||||
style={
|
style={
|
||||||
coords.arrowOffset !== null
|
coords.arrowOffset !== null
|
||||||
? { [position === 'top' || position === 'bottom' ? 'left' : 'top']: coords.arrowOffset }
|
? { [resolvedPosition === 'top' || resolvedPosition === 'bottom' ? 'left' : 'top']: coords.arrowOffset }
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -24,11 +24,12 @@ import { Tooltip } from '@app/components/shared/Tooltip';
|
|||||||
interface ActiveToolButtonProps {
|
interface ActiveToolButtonProps {
|
||||||
activeButton: string;
|
activeButton: string;
|
||||||
setActiveButton: (id: string) => void;
|
setActiveButton: (id: string) => void;
|
||||||
|
tooltipPosition?: 'left' | 'right' | 'top' | 'bottom';
|
||||||
}
|
}
|
||||||
|
|
||||||
const NAV_IDS = ['read', 'sign', 'automate'];
|
const NAV_IDS = ['read', 'sign', 'automate'];
|
||||||
|
|
||||||
const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton }) => {
|
const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton, tooltipPosition = 'right' }) => {
|
||||||
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow();
|
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow();
|
||||||
const { getHomeNavigation } = useSidebarNavigation();
|
const { getHomeNavigation } = useSidebarNavigation();
|
||||||
|
|
||||||
@@ -139,7 +140,12 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
|
|||||||
{indicatorTool && (
|
{indicatorTool && (
|
||||||
<div className="current-tool-content">
|
<div className="current-tool-content">
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<Tooltip content={isBackHover ? 'Back to all tools' : indicatorTool.name} position="right" arrow maxWidth={140}>
|
<Tooltip
|
||||||
|
content={isBackHover ? 'Back to all tools' : indicatorTool.name}
|
||||||
|
position={tooltipPosition}
|
||||||
|
arrow
|
||||||
|
maxWidth={140}
|
||||||
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
component="a"
|
component="a"
|
||||||
href={getHomeNavigation().href}
|
href={getHomeNavigation().href}
|
||||||
@@ -189,4 +195,3 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
|
|||||||
|
|
||||||
export default ActiveToolButton;
|
export default ActiveToolButton;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,10 @@
|
|||||||
max-width: 4rem;
|
max-width: 4rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
border-right: 1px solid var(--border-default);
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
direction: ltr; /* keep layout stable when document is rtl */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rainbow mode container */
|
/* Rainbow mode container */
|
||||||
@@ -53,6 +57,17 @@
|
|||||||
max-width: 4rem;
|
max-width: 4rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
border-right: 1px solid var(--border-default);
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RTL adjustments keep the bar on-screen and separated from content */
|
||||||
|
:root[dir='rtl'] .quick-access-bar-main,
|
||||||
|
:root[dir='rtl'] .quick-access-bar-main.rainbow-mode {
|
||||||
|
border-right: none;
|
||||||
|
border-left: 1px solid var(--border-default);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header padding */
|
/* Header padding */
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { generateThumbnailWithMetadata } from '@app/utils/thumbnailUtils';
|
|||||||
import { createProcessedFile } from '@app/contexts/file/fileActions';
|
import { createProcessedFile } from '@app/contexts/file/fileActions';
|
||||||
import { createStirlingFile, createNewStirlingFileStub } from '@app/types/fileContext';
|
import { createStirlingFile, createNewStirlingFileStub } from '@app/types/fileContext';
|
||||||
import { useNavigationState } from '@app/contexts/NavigationContext';
|
import { useNavigationState } from '@app/contexts/NavigationContext';
|
||||||
|
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||||
|
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
|
||||||
|
|
||||||
interface ViewerAnnotationControlsProps {
|
interface ViewerAnnotationControlsProps {
|
||||||
currentView: string;
|
currentView: string;
|
||||||
@@ -19,6 +21,8 @@ interface ViewerAnnotationControlsProps {
|
|||||||
|
|
||||||
export default function ViewerAnnotationControls({ currentView, disabled = false }: ViewerAnnotationControlsProps) {
|
export default function ViewerAnnotationControls({ currentView, disabled = false }: ViewerAnnotationControlsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { sidebarRefs } = useSidebarContext();
|
||||||
|
const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs);
|
||||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||||
const [isHoverColorPickerOpen, setIsHoverColorPickerOpen] = useState(false);
|
const [isHoverColorPickerOpen, setIsHoverColorPickerOpen] = useState(false);
|
||||||
@@ -53,7 +57,7 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Annotation Visibility Toggle */}
|
{/* Annotation Visibility Toggle */}
|
||||||
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position="left" offset={12} arrow portalTarget={document.body}>
|
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -130,7 +134,7 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// When inactive: Show "Draw" tooltip
|
// When inactive: Show "Draw" tooltip
|
||||||
<Tooltip content={t('rightRail.draw', 'Draw')} position="left" offset={12} arrow portalTarget={document.body}>
|
<Tooltip content={t('rightRail.draw', 'Draw')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -156,7 +160,7 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Save PDF with Annotations */}
|
{/* Save PDF with Annotations */}
|
||||||
<Tooltip content={t('rightRail.save', 'Save')} position="left" offset={12} arrow portalTarget={document.body}>
|
<Tooltip content={t('rightRail.save', 'Save')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
radius="md"
|
radius="md"
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const FullscreenToolSurface = ({
|
|||||||
const { colorScheme } = useMantineColorScheme();
|
const { colorScheme } = useMantineColorScheme();
|
||||||
const [isExiting, setIsExiting] = useState(false);
|
const [isExiting, setIsExiting] = useState(false);
|
||||||
const surfaceRef = useRef<HTMLDivElement>(null);
|
const surfaceRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isRTL = typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
|
||||||
|
|
||||||
// Enable focus trap when surface is active
|
// Enable focus trap when surface is active
|
||||||
useFocusTrap(surfaceRef, !isExiting);
|
useFocusTrap(surfaceRef, !isExiting);
|
||||||
@@ -114,7 +115,10 @@ const FullscreenToolSurface = ({
|
|||||||
aria-label={toggleLabel}
|
aria-label={toggleLabel}
|
||||||
style={{ color: 'var(--right-rail-icon)' }}
|
style={{ color: 'var(--right-rail-icon)' }}
|
||||||
>
|
>
|
||||||
<DoubleArrowIcon fontSize="small" style={{ transform: 'rotate(180deg)' }} />
|
<DoubleArrowIcon
|
||||||
|
fontSize="small"
|
||||||
|
style={{ transform: isRTL ? undefined : 'rotate(180deg)' }}
|
||||||
|
/>
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,4 +160,3 @@ const FullscreenToolSurface = ({
|
|||||||
|
|
||||||
export default FullscreenToolSurface;
|
export default FullscreenToolSurface;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,14 @@
|
|||||||
animation: tool-panel-fullscreen-slide-out var(--fullscreen-anim-out-duration) ease forwards;
|
animation: tool-panel-fullscreen-slide-out var(--fullscreen-anim-out-duration) ease forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[dir='rtl'] .tool-panel__fullscreen-surface-inner {
|
||||||
|
animation-name: tool-panel-fullscreen-slide-in-rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[dir='rtl'] .tool-panel__fullscreen-surface-inner.tool-panel__fullscreen-surface-inner--exiting {
|
||||||
|
animation-name: tool-panel-fullscreen-slide-out-rtl;
|
||||||
|
}
|
||||||
|
|
||||||
.tool-panel__fullscreen-header {
|
.tool-panel__fullscreen-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -502,6 +510,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes tool-panel-fullscreen-slide-in-rtl {
|
||||||
|
from {
|
||||||
|
transform: translateX(6%) scaleX(0.85);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0) scaleX(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tool-panel-fullscreen-slide-out-rtl {
|
||||||
|
from {
|
||||||
|
transform: translateX(0) scaleX(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(6%) scaleX(0.85);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.tool-panel__fullscreen-surface-inner {
|
.tool-panel__fullscreen-surface-inner {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
@@ -543,4 +573,3 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export default function ToolPanel() {
|
|||||||
const isFullscreenMode = toolPanelMode === 'fullscreen';
|
const isFullscreenMode = toolPanelMode === 'fullscreen';
|
||||||
const toolPickerVisible = !readerMode;
|
const toolPickerVisible = !readerMode;
|
||||||
const fullscreenExpanded = isFullscreenMode && leftPanelView === 'toolPicker' && !isMobile && toolPickerVisible;
|
const fullscreenExpanded = isFullscreenMode && leftPanelView === 'toolPicker' && !isMobile && toolPickerVisible;
|
||||||
|
const isRTL = typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
|
||||||
|
|
||||||
|
|
||||||
// Disable right rail buttons when fullscreen mode is active
|
// Disable right rail buttons when fullscreen mode is active
|
||||||
@@ -150,7 +151,10 @@ export default function ToolPanel() {
|
|||||||
aria-label={toggleLabel}
|
aria-label={toggleLabel}
|
||||||
className="tool-panel__mode-toggle"
|
className="tool-panel__mode-toggle"
|
||||||
>
|
>
|
||||||
<DoubleArrowIcon fontSize="small" />
|
<DoubleArrowIcon
|
||||||
|
fontSize="small"
|
||||||
|
style={{ transform: isRTL ? 'scaleX(-1)' : undefined }}
|
||||||
|
/>
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export const useCompareRightRailButtons = ({
|
|||||||
baseScrollRef,
|
baseScrollRef,
|
||||||
comparisonScrollRef,
|
comparisonScrollRef,
|
||||||
}: UseCompareRightRailButtonsOptions): RightRailButtonWithAction[] => {
|
}: UseCompareRightRailButtonsOptions): RightRailButtonWithAction[] => {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return useMemo<RightRailButtonWithAction[]>(() => [
|
return useMemo<RightRailButtonWithAction[]>(() => [
|
||||||
@@ -184,6 +184,7 @@ export const useCompareRightRailButtons = ({
|
|||||||
setIsScrollLinked,
|
setIsScrollLinked,
|
||||||
zoomLimits,
|
zoomLimits,
|
||||||
t,
|
t,
|
||||||
|
i18n.language,
|
||||||
isMobile,
|
isMobile,
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ import LocalIcon from '@app/components/shared/LocalIcon';
|
|||||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||||
import { SearchInterface } from '@app/components/viewer/SearchInterface';
|
import { SearchInterface } from '@app/components/viewer/SearchInterface';
|
||||||
import ViewerAnnotationControls from '@app/components/shared/rightRail/ViewerAnnotationControls';
|
import ViewerAnnotationControls from '@app/components/shared/rightRail/ViewerAnnotationControls';
|
||||||
|
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||||
|
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
|
||||||
|
|
||||||
export function useViewerRightRailButtons() {
|
export function useViewerRightRailButtons() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const viewer = useViewer();
|
const viewer = useViewer();
|
||||||
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
|
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
|
||||||
|
const { sidebarRefs } = useSidebarContext();
|
||||||
|
const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12);
|
||||||
|
|
||||||
// Lift i18n labels out of memo for clarity
|
// Lift i18n labels out of memo for clarity
|
||||||
const searchLabel = t('rightRail.search', 'Search PDF');
|
const searchLabel = t('rightRail.search', 'Search PDF');
|
||||||
@@ -30,8 +34,8 @@ export function useViewerRightRailButtons() {
|
|||||||
section: 'top' as const,
|
section: 'top' as const,
|
||||||
order: 10,
|
order: 10,
|
||||||
render: ({ disabled }) => (
|
render: ({ disabled }) => (
|
||||||
<Tooltip content={searchLabel} position="left" offset={12} arrow portalTarget={document.body}>
|
<Tooltip content={searchLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}>
|
||||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
<Popover position={tooltipPosition} withArrow shadow="md" offset={8}>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<div style={{ display: 'inline-flex' }}>
|
<div style={{ display: 'inline-flex' }}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
@@ -61,7 +65,7 @@ export function useViewerRightRailButtons() {
|
|||||||
section: 'top' as const,
|
section: 'top' as const,
|
||||||
order: 20,
|
order: 20,
|
||||||
render: ({ disabled }) => (
|
render: ({ disabled }) => (
|
||||||
<Tooltip content={panLabel} position="left" offset={12} arrow portalTarget={document.body}>
|
<Tooltip content={panLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant={isPanning ? 'default' : 'subtle'}
|
variant={isPanning ? 'default' : 'subtle'}
|
||||||
color={undefined}
|
color={undefined}
|
||||||
@@ -132,7 +136,7 @@ export function useViewerRightRailButtons() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}, [t, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel]);
|
}, [t, i18n.language, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel, tooltipPosition]);
|
||||||
|
|
||||||
useRightRailButtons(viewerButtons);
|
useRightRailButtons(viewerButtons);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,11 +42,26 @@ export function useToolPanelGeometry({
|
|||||||
const computeAndSetGeometry = () => {
|
const computeAndSetGeometry = () => {
|
||||||
const rect = panelEl.getBoundingClientRect();
|
const rect = panelEl.getBoundingClientRect();
|
||||||
const rail = rightRailEl();
|
const rail = rightRailEl();
|
||||||
const rightOffset = rail ? Math.max(0, window.innerWidth - rail.getBoundingClientRect().left) : 0;
|
const isRTL = typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
|
||||||
const width = Math.max(360, window.innerWidth - rect.left - rightOffset);
|
const railRect = rail?.getBoundingClientRect();
|
||||||
|
const railIsOnRight = railRect ? railRect.right > window.innerWidth / 2 : false;
|
||||||
|
const rightOffset = railRect && railIsOnRight ? Math.max(0, window.innerWidth - railRect.right) : 0;
|
||||||
|
let width: number;
|
||||||
|
let left: number;
|
||||||
|
|
||||||
|
if (isRTL) {
|
||||||
|
// In RTL, QuickAccessBar is on the right, so start after it (using rect.right as the right edge)
|
||||||
|
const quickAccessRect = quickAccessRef.current?.getBoundingClientRect();
|
||||||
|
const quickAccessWidth = quickAccessRect ? quickAccessRect.width : 0;
|
||||||
|
width = Math.max(360, window.innerWidth - quickAccessWidth - rightOffset);
|
||||||
|
left = quickAccessWidth;
|
||||||
|
} else {
|
||||||
|
width = Math.max(360, window.innerWidth - rect.left - rightOffset);
|
||||||
|
left = rect.left;
|
||||||
|
}
|
||||||
const height = Math.max(rect.height, window.innerHeight - rect.top);
|
const height = Math.max(rect.height, window.innerHeight - rect.top);
|
||||||
setGeometry({
|
setGeometry({
|
||||||
left: rect.left,
|
left,
|
||||||
top: rect.top,
|
top: rect.top,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { SidebarRefs } from '@app/types/sidebar';
|
||||||
|
|
||||||
|
export function useRightRailTooltipSide(
|
||||||
|
sidebarRefs?: SidebarRefs,
|
||||||
|
defaultOffset: number = 16
|
||||||
|
): { position: 'left' | 'right'; offset: number } {
|
||||||
|
const [position, setPosition] = useState<'left' | 'right'>('left');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const computePosition = () => {
|
||||||
|
const rail = sidebarRefs?.rightRailRef?.current;
|
||||||
|
const isRTL = typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
|
||||||
|
|
||||||
|
// Fallback to left if we can't measure
|
||||||
|
if (!rail || typeof window === 'undefined') {
|
||||||
|
setPosition(isRTL ? 'left' : 'left');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = rail.getBoundingClientRect();
|
||||||
|
const center = rect.left + rect.width / 2;
|
||||||
|
const preferred = center > window.innerWidth / 2 ? 'left' : 'right';
|
||||||
|
setPosition(isRTL ? 'left' : preferred);
|
||||||
|
};
|
||||||
|
|
||||||
|
computePosition();
|
||||||
|
window.addEventListener('resize', computePosition);
|
||||||
|
return () => window.removeEventListener('resize', computePosition);
|
||||||
|
}, [sidebarRefs]);
|
||||||
|
|
||||||
|
return { position, offset: defaultOffset };
|
||||||
|
}
|
||||||
@@ -74,6 +74,7 @@ export function useTooltipPosition({
|
|||||||
|
|
||||||
// Fallback sidebar position (only used as last resort)
|
// Fallback sidebar position (only used as last resort)
|
||||||
const sidebarLeft = 240;
|
const sidebarLeft = 240;
|
||||||
|
const isRTL = typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
|
||||||
|
|
||||||
const updatePosition = () => {
|
const updatePosition = () => {
|
||||||
if (!triggerRef.current || !open) return;
|
if (!triggerRef.current || !open) return;
|
||||||
@@ -93,7 +94,11 @@ export function useTooltipPosition({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sidebarInfo = getSidebarInfo(sidebarRefs, sidebarState);
|
const sidebarInfo = getSidebarInfo(sidebarRefs, sidebarState);
|
||||||
const currentSidebarRight = sidebarInfo.rect ? sidebarInfo.rect.right : sidebarLeft;
|
const rect = sidebarInfo.rect;
|
||||||
|
if (!rect) {
|
||||||
|
setPositionReady(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Only show tooltip if we have the tool panel active
|
// Only show tooltip if we have the tool panel active
|
||||||
if (!sidebarInfo.isToolPanelActive) {
|
if (!sidebarInfo.isToolPanelActive) {
|
||||||
@@ -102,13 +107,18 @@ export function useTooltipPosition({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position to the right of active sidebar with 20px gap
|
const tooltipRect = tooltipRef.current?.getBoundingClientRect() || null;
|
||||||
left = currentSidebarRight + 20;
|
|
||||||
|
// Position adjacent to sidebar; mirror for RTL
|
||||||
|
if (isRTL) {
|
||||||
|
left = rect.left - (tooltipRect?.width || 0) - 20;
|
||||||
|
} else {
|
||||||
|
left = rect.right + 20;
|
||||||
|
}
|
||||||
top = triggerRect.top; // Align top of tooltip with trigger element
|
top = triggerRect.top; // Align top of tooltip with trigger element
|
||||||
|
|
||||||
// Only clamp if we have tooltip dimensions
|
// Only clamp if we have tooltip dimensions
|
||||||
if (tooltipRef.current) {
|
if (tooltipRect) {
|
||||||
const tooltipRect = tooltipRef.current.getBoundingClientRect();
|
|
||||||
const maxTop = window.innerHeight - tooltipRect.height - 4;
|
const maxTop = window.innerHeight - tooltipRect.height - 4;
|
||||||
const originalTop = top;
|
const originalTop = top;
|
||||||
top = clamp(top, 4, maxTop);
|
top = clamp(top, 4, maxTop);
|
||||||
|
|||||||
Reference in New Issue
Block a user