* -------------------------------------------------------------------------
* This file is part of the MindStudio project.
* Copyright (c) 2026 Huawei Technologies Co.,Ltd.
*
* MindStudio is licensed under Mulan PSL v2.
* You can use this software according to the terms and conditions of the Mulan PSL v2.
* You may obtain a copy of Mulan PSL v2 at:
*
* http://license.coscl.org.cn/MulanPSL2
*
* THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
* EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
* MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
* See the Mulan PSL v2 for more details.
* -------------------------------------------------------------------------
*/
import styled from '@emotion/styled';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { observer } from 'mobx-react-lite';
import { runInAction } from 'mobx';
import { Tooltip } from '@insight/lib/components';
import { getShortcutKey } from '@insight/lib/utils';
import type { Session } from '../../entity/session';
import eventBus from '../../utils/eventBus';
import { ReactComponent as ArrowIcon } from '../../assets/images/floating-tools/arrow.svg';
import { ReactComponent as ChevronDoubleRightIcon } from '../../assets/images/floating-tools/chevron-double-right.svg';
import { ReactComponent as HeightIcon } from '../../assets/images/floating-tools/height.svg';
import { ReactComponent as HelpIcon } from '../../assets/images/floating-tools/help.svg';
import { ReactComponent as MoveIcon } from '../../assets/images/floating-tools/move.svg';
import { ReactComponent as SliceIcon } from '../../assets/images/floating-tools/slice.svg';
import sampleDefault from '../../assets/images/floating-tools/sample-1.png';
import sampleHeight from '../../assets/images/floating-tools/sample-2.png';
import sampleSlice from '../../assets/images/floating-tools/sample-3.png';
import sampleDrag from '../../assets/images/floating-tools/sample-4.png';
const TOOLBAR_TOP_OFFSET_PX = 78;
const TOOLBAR_RIGHT_OFFSET_PX = 8;
const TOOLBAR_BUTTON_SIZE_PX = 24;
type FloatingToolbarProps = {
session: Session;
};
type ToolbarIcon = React.FunctionComponent<React.SVGProps<SVGSVGElement> & { title?: string }>;
type ToolbarItem = {
id: 'default-mode' | 'drag-mode' | 'auto-fit-height' | 'selection-mode';
icon: ToolbarIcon;
tooltipKey: string;
descriptionKey: string;
testId: string;
image: string;
};
type ToolbarMode = Exclude<ToolbarItem['id'], 'auto-fit-height'>;
const TOOLBAR_ITEMS: ToolbarItem[] = [
{
id: 'default-mode',
icon: ArrowIcon,
tooltipKey: 'floatingToolbar.defaultMode',
descriptionKey: 'floatingToolbar.defaultModeDescription',
testId: 'timeline-floating-toolbar-default-mode',
image: sampleDefault,
},
{
id: 'drag-mode',
icon: MoveIcon,
tooltipKey: 'floatingToolbar.dragMode',
descriptionKey: 'floatingToolbar.dragModeDescription',
testId: 'timeline-floating-toolbar-drag-mode',
image: sampleDrag,
},
{
id: 'auto-fit-height',
icon: HeightIcon,
tooltipKey: 'floatingToolbar.autoFitHeight',
descriptionKey: 'floatingToolbar.autoFitHeightDescription',
testId: 'timeline-floating-toolbar-auto-fit-height',
image: sampleHeight,
},
{
id: 'selection-mode',
icon: SliceIcon,
tooltipKey: 'floatingToolbar.selectionMode',
descriptionKey: 'floatingToolbar.selectionModeDescription',
testId: 'timeline-floating-toolbar-selection-mode',
image: sampleSlice,
},
];
const COLLAPSED_TOP_OFFSET_PX = 206;
const ToolbarContainer = styled.div<{ collapsed: boolean }>`
position: absolute;
top: ${TOOLBAR_TOP_OFFSET_PX}px;
right: 0;
z-index: 11;
display: flex;
align-items: flex-start;
transform: ${(props): string => props.collapsed
? `translate(0, ${COLLAPSED_TOP_OFFSET_PX - TOOLBAR_TOP_OFFSET_PX}px)`
: `translate(-${TOOLBAR_RIGHT_OFFSET_PX}px, 0)`};
transition: transform 200ms ease-out;
`;
const ToolbarPanel = styled.div<{ collapsed: boolean }>`
display: flex;
flex-direction: column;
align-items: center;
gap: ${(props): string => props.collapsed ? '0' : '4px'};
width: ${(props): string => props.collapsed ? '16px' : '32px'};
padding: ${(props): string => props.collapsed ? '0' : '4px'};
border: 1px solid ${(props): string => props.theme.borderColor};
border-right: ${(props): string => props.collapsed ? 'none' : `1px solid ${props.theme.borderColor}`};
border-radius: ${(props): string => props.collapsed ? '12px 0 0 12px' : '6px'};
background-color: ${(props): string => props.theme.contentBackgroundColor};
box-shadow: ${(props): string => props.theme.boxShadow};
transition: width 200ms ease-out, padding 200ms ease-out, border-radius 200ms ease-out;
`;
const ToolbarItems = styled.div<{ collapsed: boolean }>`
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
max-height: ${(props): string => props.collapsed ? '0' : '136px'};
opacity: ${(props): number => props.collapsed ? 0 : 1};
overflow: hidden;
transform: ${(props): string => props.collapsed ? 'translateX(8px)' : 'translateX(0)'};
transform-origin: right top;
pointer-events: ${(props): string => props.collapsed ? 'none' : 'auto'};
transition: opacity 160ms ease-out, max-height 200ms ease-out, transform 200ms ease-out;
`;
const ToolbarButton = styled.button<{ selected?: boolean; disabled?: boolean }>`
display: flex;
align-items: center;
justify-content: center;
width: ${TOOLBAR_BUTTON_SIZE_PX}px;
height: ${TOOLBAR_BUTTON_SIZE_PX}px;
padding: 0;
border: none;
border-radius: 4px;
color: ${(props): string => props.disabled ? props.theme.textColorDisabled : props.theme.iconColor};
background-color: ${(props): string => props.selected ? props.theme.selectedChartBackgroundColor : 'transparent'};
cursor: ${(props): string => props.disabled ? 'not-allowed' : 'pointer'};
opacity: ${(props): number => props.disabled ? 0.5 : 1};
&:hover {
background-color: ${(props): string => props.disabled ? props.selected ? props.theme.selectedChartBackgroundColor : 'transparent' : props.selected ? props.theme.selectedChartBackgroundColor : props.theme.bgColorLight};
}
svg {
width: 16px;
height: 16px;
}
`;
const HelpButtonWrap = styled.div`
margin-top: 2px;
padding-top: 4px;
border-top: 1px solid ${(props): string => props.theme.borderColorLighter};
`;
const ToggleButton = styled(ToolbarButton)<{ collapsed: boolean }>`
width: ${(props): string => props.collapsed ? '16px' : `${TOOLBAR_BUTTON_SIZE_PX}px`};
height: ${(props): string => props.collapsed ? '48px' : `${TOOLBAR_BUTTON_SIZE_PX}px`};
border-radius: ${(props): string => props.collapsed ? '12px 0 0 12px' : '4px'};
transition: width 200ms ease-out, height 200ms ease-out, border-radius 200ms ease-out, background-color 160ms ease-out;
&:hover {
background-color: ${(props): string => props.theme.bgColorLight};
}
svg {
width: 16px;
height: 16px;
transform: ${(props): string => props.collapsed ? 'rotate(180deg)' : 'none'};
transition: transform 200ms ease-out;
}
`;
const GuideOverlay = styled.div`
position: fixed;
inset: 0;
z-index: 10;
background-color: ${(props): string => props.theme.bgColor};
opacity: 0.22;
pointer-events: none;
`;
const GuidePanel = styled.div`
position: absolute;
top: 0;
right: 42px;
z-index: 12;
max-height: calc(100vh - 120px);
padding: 16px 16px 12px;
overflow-y: auto;
border: 1px solid ${(props): string => props.theme.borderColorLighter};
border-radius: 4px;
background-color: ${(props): string => props.theme.bgColorLight};
box-shadow: ${(props): string => props.theme.boxShadow};
`;
const GuideHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
color: ${(props): string => props.theme.textColorPrimary};
font-size: 14px;
font-weight: 600;
`;
const CloseButton = styled.button`
width: 20px;
height: 20px;
padding: 0;
border: none;
color: ${(props): string => props.theme.iconColor};
background: transparent;
cursor: pointer;
font-size: 20px;
line-height: 18px;
&:hover {
color: ${(props): string => props.theme.primaryColor};
}
`;
const GuideItem = styled.div`
margin-bottom: 14px;
`;
const GuideTitle = styled.div`
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
color: ${(props): string => props.theme.textColorPrimary};
font-size: 13px;
font-weight: 600;
svg {
width: 16px;
height: 16px;
color: ${(props): string => props.theme.iconColor};
}
`;
const GuideDescription = styled.div`
margin-left: 22px;
margin-bottom: 8px;
color: ${(props): string => props.theme.textColorSecondary};
font-size: 12px;
line-height: 18px;
`;
const GuideImage = styled.img`
display: block;
width: 318px;
height: 96px;
margin-left: 22px;
object-fit: cover;
border-radius: 4px;
background-color: ${(props): string => props.theme.bgColorLight};
`;
const stopToolbarEvent = (event: React.MouseEvent<HTMLElement>): void => {
event.preventDefault();
event.stopPropagation();
};
const toggleAutoFitHeight = (session: Session): void => {
runInAction(() => {
session.autoAdjustUnitHeight = !session.autoAdjustUnitHeight;
session.renderTrigger = !session.renderTrigger;
});
};
const toggleSelectionMode = (session: Session, active: boolean): void => {
runInAction(() => {
session.sliceSelection.active = active;
session.sliceSelection.activeIsChanged = true;
eventBus.emit('sliceActiveChanged', session.sliceSelection.active);
});
};
const getActiveMode = (session: Session): ToolbarMode => {
if (session.panMode) {
return 'drag-mode';
}
if (session.sliceSelection.active) {
return 'selection-mode';
}
return 'default-mode';
};
const getVisualActiveMode = (session: Session): ToolbarMode => {
if (session.panModePressed) {
return 'drag-mode';
}
return getActiveMode(session);
};
const FloatingToolbar = observer(({ session }: FloatingToolbarProps): JSX.Element => {
const { t } = useTranslation('timeline');
const [collapsed, setCollapsed] = React.useState(false);
const [guideVisible, setGuideVisible] = React.useState(false);
const activeMode = getActiveMode(session);
const visualActiveMode = getVisualActiveMode(session);
const handleItemClick = (event: React.MouseEvent<HTMLElement>, item: ToolbarItem): void => {
stopToolbarEvent(event);
if (isDisabled(item)) {
return;
}
switch (item.id) {
case 'auto-fit-height':
toggleAutoFitHeight(session);
break;
case 'default-mode':
runInAction(() => {
session.panMode = false;
});
toggleSelectionMode(session, false);
break;
case 'drag-mode': {
runInAction(() => {
session.panMode = activeMode !== 'drag-mode';
});
toggleSelectionMode(session, false);
break;
}
case 'selection-mode': {
const active = activeMode !== 'selection-mode';
runInAction(() => {
session.panMode = false;
});
toggleSelectionMode(session, active);
break;
}
default:
break;
}
};
const isSelected = (item: ToolbarItem): boolean => {
if (item.id === 'auto-fit-height') {
return session.autoAdjustUnitHeight;
}
return visualActiveMode === item.id;
};
const isDisabled = (item: ToolbarItem): boolean => {
if (item.id === 'selection-mode') {
return session.isTimeAnalysisMode;
}
return false;
};
const getToolbarTitle = (item: ToolbarItem): string => {
if (item.id === 'drag-mode') {
return `${t(item.tooltipKey, { key: getShortcutKey('CtrlOrCmd') })}`;
}
return t(item.tooltipKey);
};
return <>
{guideVisible && <GuideOverlay />}
<ToolbarContainer collapsed={collapsed} onMouseDown={stopToolbarEvent} onClick={stopToolbarEvent}>
{guideVisible && <GuidePanel>
<GuideHeader>
<span>{t('floatingToolbar.guideTitle')}</span>
<CloseButton
type="button"
aria-label={t('floatingToolbar.closeGuide')}
onClick={(event): void => {
stopToolbarEvent(event);
setGuideVisible(false);
}}
>×</CloseButton>
</GuideHeader>
{TOOLBAR_ITEMS.map((item) => {
const Icon = item.icon;
const title = getToolbarTitle(item);
return <GuideItem key={item.id}>
<GuideTitle>
<Icon />
<span>{title}</span>
</GuideTitle>
<GuideDescription>{t(item.descriptionKey)}</GuideDescription>
<GuideImage src={item.image} alt={title} />
</GuideItem>;
})}
</GuidePanel>}
<ToolbarPanel collapsed={collapsed}>
<ToolbarItems collapsed={collapsed}>
{TOOLBAR_ITEMS.map((item) => {
const Icon = item.icon;
return <Tooltip key={item.id} title={getToolbarTitle(item)} placement="left">
<ToolbarButton
type="button"
selected={isSelected(item)}
disabled={isDisabled(item)}
data-testid={item.testId}
onClick={(event): void => handleItemClick(event, item)}
>
<Icon />
</ToolbarButton>
</Tooltip>;
})}
<HelpButtonWrap>
<Tooltip title={t('floatingToolbar.guideTitle')} placement="left">
<ToolbarButton
type="button"
selected={guideVisible}
data-testid="timeline-floating-toolbar-guide"
onClick={(): void => {
setGuideVisible((prev) => !prev);
}}
>
<HelpIcon />
</ToolbarButton>
</Tooltip>
</HelpButtonWrap>
</ToolbarItems>
<Tooltip title={collapsed ? t('floatingToolbar.expand') : t('floatingToolbar.collapse')} placement="left">
<ToggleButton
type="button"
collapsed={collapsed}
data-testid="timeline-floating-toolbar-toggle"
onClick={(): void => {
setCollapsed((prev) => !prev);
setGuideVisible(false);
}}
>
<ChevronDoubleRightIcon />
</ToggleButton>
</Tooltip>
</ToolbarPanel>
</ToolbarContainer>
</>;
});
export default FloatingToolbar;