Загрузка данных


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(),
    }),
  ]);
};