Загрузка данных
import { MenuList, MenuOption } from '@src/components/Menu/MenuList';
import { t } from '@src/translations';
type SnapshotAction = 'copy-image';
interface SnapshotMenuProps {
onCopyImage: () => Promise<void>;
}
const options = (): MenuOption<SnapshotAction>[] => [
{
value: 'copy-image',
label: t('Copy image'),
},
];
export function SnapshotMenu({ onCopyImage }: SnapshotMenuProps) {
const handleClick = (action: SnapshotAction): void => {
if (action === 'copy-image') {
void onCopyImage();
}
};
return (
<MenuList
mode="single"
value={undefined}
options={options()}
onClick={handleClick}
/>
);
}
import { Button, Divider, Tooltip } from 'exchange-elements/v2';
import { useEffect, useState } from 'react';
import { type Observable, of } from 'rxjs';
import { IndicatorsSelect } from '@components/IndicatorsSelect';
import { IndicatorsIds } from '@src/constants';
import { FullscreenController } from '@src/core/Fullscreen';
import { UndoRedo, type UndoRedoState } from '@src/core/UndoRedo';
import { t } from '@src/translations';
import { ChartSeriesType } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import { useObservable } from '@src/utils';
import { Dropdown } from '../Dropdown';
import {
BarIcon,
BurgerMenuIcon,
CameraIcon,
CandleStickIcon,
FullscreenIcon,
LineIcon,
PlusCircleIcon,
RedoIcon,
SearchIcon,
UndoIcon,
} from '../Icon';
import { SeriesMenu, SnapshotMenu, TimeframesMenu } from '../Menu';
import styles from './index.module.scss';
const defaultUndoRedoState: UndoRedoState = {
canUndo: false,
canRedo: false,
};
const defaultUndoRedoState$ = of(defaultUndoRedoState);
interface HeaderProps {
timeframes: Timeframes[];
selectedTimeframeObs: Observable<Timeframes>;
setTimeframe: (value: Timeframes) => void;
seriesTypes: ChartSeriesType[];
setSelectedSeries: (next: ChartSeriesType) => void;
selectedSeriesObs: Observable<ChartSeriesType>;
showSettingsModal: (() => void) | undefined;
addIndicatorToChart: (id: IndicatorsIds) => void;
toggleToolbarVisible: () => boolean;
showCompareButton: boolean;
showSymbolSearchButton: boolean;
undoRedo: UndoRedo | undefined;
showMenuButton?: boolean;
showFullscreenButton?: boolean;
fullscreen: FullscreenController;
openCompareModal?: () => void;
openSymbolSearchModal?: () => void;
copyImage: () => Promise<void>;
isMXT: boolean;
}
export function Header({
setTimeframe,
selectedTimeframeObs,
setSelectedSeries,
selectedSeriesObs,
timeframes,
seriesTypes,
showSettingsModal,
addIndicatorToChart,
toggleToolbarVisible,
showCompareButton,
showSymbolSearchButton,
fullscreen,
undoRedo,
showFullscreenButton,
showMenuButton,
openCompareModal,
openSymbolSearchModal,
copyImage,
isMXT,
}: HeaderProps) {
const [isToolbarOpen, setIsToolbarOpen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(fullscreen.isFullscreen);
const undoRedoState = useObservable(
undoRedo?.getState() ?? defaultUndoRedoState$,
defaultUndoRedoState,
);
useEffect(() => {
const unsubscribe = fullscreen.onChange(() => {
setIsFullscreen(fullscreen.isFullscreen);
});
return unsubscribe;
}, [fullscreen]);
const selectedTimeframe = useObservable(selectedTimeframeObs);
const selectedSeries = useObservable(selectedSeriesObs);
const seriesDropdownValue =
selectedSeries === 'Line' ? (
<LineIcon />
) : selectedSeries === 'Bar' ? (
<BarIcon />
) : (
<CandleStickIcon />
);
const handleOpenToolbar = (): void => {
setIsToolbarOpen(toggleToolbarVisible());
};
return (
<header className={styles.header}>
<div className={styles.group}>
{showMenuButton && (
<Tooltip
label={t(
isToolbarOpen
? 'Hide drawing toolbar'
: 'Show drawing toolbar',
)}
>
<Button
size="sm"
className={`${styles.button} ${isToolbarOpen ? styles.pressed : ''}`}
onClick={handleOpenToolbar}
label={<BurgerMenuIcon />}
/>
</Tooltip>
)}
{showSymbolSearchButton && (
<Tooltip label={t('Symbol search')}>
<Button
size="sm"
className={styles.button}
onClick={openSymbolSearchModal}
label={<SearchIcon />}
/>
</Tooltip>
)}
{showCompareButton && (
<Tooltip label={t('Compare symbols')}>
<Button
size="sm"
className={styles.button}
onClick={openCompareModal}
label={<PlusCircleIcon />}
/>
</Tooltip>
)}
</div>
{(showMenuButton || showCompareButton || showSymbolSearchButton) && (
<Divider
direction="vertical"
pt={{ divider: { className: styles.divider } }}
/>
)}
<Tooltip label={t('Time interval')}>
<Dropdown
menuClassName={styles.menu}
selectedValue={
selectedTimeframe ? t(selectedTimeframe) : selectedTimeframe
}
>
<TimeframesMenu
onClick={setTimeframe}
{...{ selectedTimeframe, timeframes }}
/>
</Dropdown>
</Tooltip>
<Divider
direction="vertical"
pt={{ divider: { className: styles.divider } }}
/>
<div className={styles.group}>
<IndicatorsSelect
addIndicatorToChart={addIndicatorToChart}
isMXT={isMXT}
/>
{showSettingsModal && (
<Button
size="sm"
className={styles.button}
onClick={showSettingsModal}
label={t('Settings')}
/>
)}
<Tooltip label={t('Chart type')}>
<Dropdown
menuClassName={styles.menu}
selectedValue={seriesDropdownValue}
>
<SeriesMenu
selectedType={selectedSeries}
seriesTypes={seriesTypes}
onClick={setSelectedSeries}
/>
</Dropdown>
</Tooltip>
</div>
<Divider
direction="vertical"
pt={{ divider: { className: styles.divider } }}
/>
<div className={styles.group}>
{undoRedo && (
<>
<Tooltip label={t('Undo')}>
<Button
size="sm"
className={styles.button}
onClick={undoRedo.undo}
disabled={!undoRedoState?.canUndo}
label={<UndoIcon />}
/>
</Tooltip>
<Tooltip label={t('Redo')}>
<Button
size="sm"
className={styles.button}
onClick={undoRedo.redo}
disabled={!undoRedoState?.canRedo}
label={<RedoIcon />}
/>
</Tooltip>
</>
)}
<Tooltip label={t('Snapshot')}>
<Dropdown
menuClassName={styles.menu}
selectedValue={<CameraIcon />}
>
<SnapshotMenu onCopyImage={copyImage} />
</Dropdown>
</Tooltip>
{showFullscreenButton && (
<Tooltip
label={t(
isFullscreen ? 'Exit fullscreen' : 'Fullscreen',
)}
>
<Button
size="sm"
className={`${styles.button} ${isFullscreen ? styles.pressed : ''}`}
onClick={() => fullscreen.toggle()}
label={<FullscreenIcon />}
/>
</Tooltip>
)}
</div>
</header>
);
}
'Show drawing toolbar': 'Показать панель рисования',
'Hide drawing toolbar': 'Скрыть панель рисования',
'Symbol search': 'Поиск инструмента',
'Compare symbols': 'Сравнить инструменты',
'Time interval': 'Интервал',
'Chart type': 'Тип графика',
Undo: 'Отменить',
Redo: 'Повторить',
Snapshot: 'Снимок графика',
'Copy image': 'Копировать изображение',
Fullscreen: 'Во весь экран',
'Exit fullscreen': 'Выйти из полноэкранного режима',
export const CHART_PANE_LEGEND_CONTAINER =
'chart-pane-legend-container';
const legendContainer = document.createElement('div');
legendContainer.classList.add(CHART_PANE_LEGEND_CONTAINER);
legendContainer.style.width = '80%';
legendContainer.style.position = 'absolute';
legendContainer.style.top = '0';
legendContainer.style.left = '0';
legendContainer.style.zIndex = ZIndex.Legend;
legendContainer.style.pointerEvents = 'none';
function copyComputedStyles(
source: HTMLElement,
target: HTMLElement,
): void {
const computedStyle = window.getComputedStyle(source);
for (const property of computedStyle) {
target.style.setProperty(
property,
computedStyle.getPropertyValue(property),
computedStyle.getPropertyPriority(property),
);
}
Array.from(source.children).forEach((sourceChild, index) => {
const targetChild = target.children.item(index);
if (
sourceChild instanceof HTMLElement &&
targetChild instanceof HTMLElement
) {
copyComputedStyles(sourceChild, targetChild);
}
});
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () =>
reject(new Error('Failed to render DOM element'));
image.src = src;
});
}
export async function drawHTMLElementOnCanvas(
canvas: HTMLCanvasElement,
element: HTMLElement,
root: HTMLElement,
): Promise<void> {
const elementRect = element.getBoundingClientRect();
if (!elementRect.width || !elementRect.height) {
return;
}
const rootRect = root.getBoundingClientRect();
const clone = element.cloneNode(true) as HTMLElement;
copyComputedStyles(element, clone);
clone.setAttribute(
'xmlns',
'http://www.w3.org/1999/xhtml',
);
const content = new XMLSerializer().serializeToString(clone);
const svg = `
<svg
xmlns="http://www.w3.org/2000/svg"
width="${elementRect.width}"
height="${elementRect.height}"
>
<foreignObject
width="100%"
height="100%"
>
${content}
</foreignObject>
</svg>
`;
const blob = new Blob([svg], {
type: 'image/svg+xml;charset=utf-8',
});
const url = URL.createObjectURL(blob);
try {
const image = await loadImage(url);
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Canvas context is not available');
}
const scaleX = canvas.width / rootRect.width;
const scaleY = canvas.height / rootRect.height;
context.drawImage(
image,
(elementRect.left - rootRect.left) * scaleX,
(elementRect.top - rootRect.top) * scaleY,
elementRect.width * scaleX,
elementRect.height * scaleY,
);
} finally {
URL.revokeObjectURL(url);
}
}
export function canvasToBlob(
canvas: HTMLCanvasElement,
): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error('Failed to create chart image'));
}, 'image/png');
});
}
private createImageBlob = async (): Promise<Blob> => {
const canvas = this.lwcChart.takeScreenshot(true, false);
const chartElement = this.lwcChart.chartElement();
const legends =
chartElement.querySelectorAll<HTMLElement>(
`.${CHART_PANE_LEGEND_CONTAINER}`,
);
for (const legend of legends) {
const style = window.getComputedStyle(legend);
if (
legend.hidden ||
style.display === 'none' ||
style.visibility === 'hidden'
) {
continue;
}
await drawHTMLElementOnCanvas(
canvas,
legend,
chartElement,
);
}
return canvasToBlob(canvas);
};
public copyImage = (): Promise<void> => {
if (
!window.isSecureContext ||
!navigator.clipboard?.write ||
typeof ClipboardItem === 'undefined'
) {
return Promise.reject(
new Error('Clipboard image API is not supported'),
);
}
return navigator.clipboard.write([
new ClipboardItem({
'image/png': this.createImageBlob(),
}),
]);
};