Загрузка данных
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { SettingField, SettingsTab, SettingsValues } from '@src/types';
export interface RectangleStyle {
borderColor: string;
fillColor: string;
}
export interface RectangleTextStyle {
text: string;
fontSize: number;
isBold: boolean;
isItalic: boolean;
textColor: string;
}
export type RectangleSettings = RectangleStyle & RectangleTextStyle & SettingsValues;
export function createDefaultSettings(): RectangleSettings {
const { colors } = getThemeStore();
return {
borderColor: colors.rectangleBorderFill,
fillColor: colors.rectangleAreaFill,
text: '',
fontSize: 14,
isBold: false,
isItalic: false,
textColor: colors.rectangleBorderFill,
};
}
export function getRectangleSettingsTabs(settings: RectangleSettings): SettingsTab[] {
const styleFields: SettingField[] = [
{
key: 'borderColor',
label: t('Border color'),
type: 'color',
defaultValue: settings.borderColor,
},
{
key: 'fillColor',
label: t('Background color'),
type: 'color',
defaultValue: settings.fillColor,
},
];
const textFields: SettingField[] = [
{
key: 'fontSize',
label: t('Font size'),
type: 'number',
defaultValue: settings.fontSize,
min: 8,
max: 24,
},
{
key: 'text',
label: t('Text'),
type: 'textarea',
defaultValue: settings.text,
placeholder: t('Enter text'),
},
{
key: 'isBold',
label: t('Bold'),
type: 'boolean',
defaultValue: settings.isBold,
},
{
key: 'isItalic',
label: t('Italics'),
type: 'boolean',
defaultValue: settings.isItalic,
},
{
key: 'textColor',
label: t('Text color'),
type: 'color',
defaultValue: settings.textColor,
},
];
return [
{
key: 'style',
label: t('Style'),
fields: styleFields,
},
{
key: 'text',
label: t('Text'),
fields: textFields,
},
];
}
import { Observable } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
clamp,
clampPointToContainer as clampPointToContainerInElement,
getAnchorFromPoint,
getContainerSize as getElementContainerSize,
getPriceDelta as getPriceDeltaFromCoordinates,
getPriceFromYCoordinate,
getTimeFromXCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
isPointInBounds,
normalizeBounds,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { RectanglePaneView } from './paneView';
import {
createDefaultSettings,
getRectangleSettingsTabs,
RectangleSettings,
RectangleStyle,
RectangleTextStyle,
} from './settings';
import type { ISeriesDrawing } from '@core/Drawings/common';
import type { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
import type { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
type RectangleMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type RectangleHandle = 'body' | 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | null;
type RectangleHandleKey = Exclude<RectangleHandle, 'body' | null>;
type TimeLabelKind = 'left' | 'right';
type PriceLabelKind = 'top' | 'bottom';
interface RectangleParams {
container: HTMLElement;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
openSettings?: () => void;
}
interface RectangleState {
hidden: boolean;
isActive: boolean;
mode: RectangleMode;
startTime: Time | null;
endTime: Time | null;
startPrice: number | null;
endPrice: number | null;
settings: RectangleSettings;
}
interface RectangleGeometry {
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
handles: Record<RectangleHandleKey, Point>;
}
export interface RectangleRenderData extends RectangleGeometry, RectangleStyle, RectangleTextStyle {
showFill: boolean;
showHandles: boolean;
}
const HANDLE_HIT_TOLERANCE = 8;
const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_SIZE = 6;
export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: RectangleSettings = createDefaultSettings();
protected mode: RectangleMode = 'idle';
private startTime: Time | null = null;
private endTime: Time | null = null;
private startPrice: number | null = null;
private endPrice: number | null = null;
private activeDragTarget: RectangleHandle = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragStateSnapshot: RectangleState | null = null;
private dragGeometrySnapshot: RectangleGeometry | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly paneView: RectanglePaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly leftTimeAxisView: CustomTimeAxisView;
private readonly rightTimeAxisView: CustomTimeAxisView;
private readonly topPriceAxisView: CustomPriceAxisView;
private readonly bottomPriceAxisView: CustomPriceAxisView;
constructor(
chart: IChartApi,
series: SeriesApi,
{ container, formatObservable, removeSelf, openSettings }: RectangleParams,
) {
super({ chart, series, container });
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.paneView = new RectanglePaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.leftTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'left',
});
this.rightTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'right',
});
this.topPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'top',
});
this.bottomPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'bottom',
});
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
this.series.attachPrimitive(this);
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public getState(): RectangleState {
return {
hidden: this.hidden,
isActive: this.isActive.value,
mode: this.mode,
startTime: this.startTime,
endTime: this.endTime,
startPrice: this.startPrice,
endPrice: this.endPrice,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
const nextState = state as Partial<RectangleState>;
if ('hidden' in nextState && typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if ('isActive' in nextState && typeof nextState.isActive === 'boolean') {
this.isActive.next(nextState.isActive);
}
if ('mode' in nextState && nextState.mode) {
this.mode = nextState.mode;
}
if ('startTime' in nextState) {
this.startTime = nextState.startTime ?? null;
}
if ('endTime' in nextState) {
this.endTime = nextState.endTime ?? null;
}
if ('startPrice' in nextState) {
this.startPrice = nextState.startPrice ?? null;
}
if ('endPrice' in nextState) {
this.endPrice = nextState.endPrice ?? null;
}
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getRectangleSettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.leftTimeAxisView,
this.rightTimeAxisView,
this.topPriceAxisView,
this.bottomPriceAxisView,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.priceAxisPaneView];
}
public timeAxisViews() {
return [this.leftTimeAxisView, this.rightTimeAxisView];
}
public priceAxisViews() {
return [this.topPriceAxisView, this.bottomPriceAxisView];
}
public getRenderData(): RectangleRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
showFill: true,
showHandles: this.shouldShowHandles(),
...this.settings,
};
}
public getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
}
const point = { x, y };
if (!this.isActive.value) {
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'pointer',
externalId: 'rectangle-position',
zOrder: 'top',
};
}
const handleTarget = this.getHandleTarget(point);
if (handleTarget) {
return {
cursorStyle: this.getCursorStyle(handleTarget),
externalId: 'rectangle-position',
zOrder: 'top',
};
}
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'grab',
externalId: 'rectangle-position',
zOrder: 'top',
};
}
protected getTimeAxisSegments(): AxisSegment[] {
if (!this.isActive.value) {
return [];
}
const bounds = this.getTimeBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.left,
to: bounds.right,
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (!this.isActive.value) {
return [];
}
const bounds = this.getPriceBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.top,
to: bounds.bottom,
color: colors.axisMarkerAreaFill,
},
];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.isActive.value || (kind !== 'left' && kind !== 'right')) {
return null;
}
const labelKind = kind as TimeLabelKind;
const coordinate = this.getTimeCoordinate(labelKind);
const text = this.getTimeText(labelKind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.isActive.value || (kind !== 'top' && kind !== 'bottom')) {
return null;
}
const labelKind = kind as PriceLabelKind;
const coordinate = this.getPriceCoordinate(labelKind);
const text = this.getPriceText(labelKind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready') {
return;
}
const rect = this.container.getBoundingClientRect();
const point = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
if (!this.containsPoint(point) && !this.getHandleTarget(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
protected handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
event.preventDefault();
event.stopPropagation();
this.startDrawing(point);
return;
}
if (this.mode === 'drawing') {
event.preventDefault();
event.stopPropagation();
this.updateDrawing(point);
this.finishDrawing();
return;
}
if (this.mode !== 'ready') {
return;
}
if (!this.isActive.value) {
if (!this.containsPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.isActive.next(true);
this.render();
return;
}
const dragTarget = this.getDragTarget(point);
if (!dragTarget) {
this.isActive.next(false);
this.render();
return;
}
event.preventDefault();
event.stopPropagation();
this.startDragging(point, event.pointerId, dragTarget);
};
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
this.updateDrawing(point);
return;
}
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
event.preventDefault();
if (this.activeDragTarget === 'body') {
this.moveWhole(point);
this.render();
return;
}
this.resizeRectangle(point);
this.render();
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.finishDragging();
};
private startDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.startTime = anchor.time;
this.endTime = anchor.time;
this.startPrice = anchor.price;
this.endPrice = anchor.price;
this.isActive.next(true);
this.mode = 'drawing';
this.render();
}
private updateDrawing(point: Point): void {
const clampedPoint = this.clampPointToContainer(point);
const anchor = this.createAnchor(clampedPoint);
if (!anchor) {
return;
}
this.endTime = anchor.time;
this.endPrice = anchor.price;
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (!geometry || geometry.width < MIN_RECTANGLE_SIZE || geometry.height < MIN_RECTANGLE_SIZE) {
if (this.removeSelf) {
this.removeSelf();
return;
}
this.resetToIdle();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDragging(point: Point, pointerId: number, dragTarget: Exclude<RectangleHandle, null>): void {
this.mode = 'dragging';
this.activeDragTarget = dragTarget;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.dragGeometrySnapshot = this.getGeometry();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
this.clearInteractionState();
this.render();
}
private clearInteractionState(): void {
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.dragGeometrySnapshot = null;
}
private resetToIdle(): void {
this.hidden = false;
this.isActive.next(false);
this.mode = 'idle';
this.startTime = null;
this.endTime = null;
this.startPrice = null;
this.endPrice = null;
this.clearInteractionState();
this.render();
}
private getDragTarget(point: Point): Exclude<RectangleHandle, null> | null {
const handleTarget = this.getHandleTarget(point);
if (handleTarget) {
return handleTarget;
}
if (this.containsPoint(point)) {
return 'body';
}
return null;
}
private moveWhole(point: Point): void {
const snapshot = this.dragStateSnapshot;
const geometry = this.dragGeometrySnapshot;
if (!snapshot || !geometry || !this.dragStartPoint) {
return;
}
if (
snapshot.startTime === null ||
snapshot.endTime === null ||
snapshot.startPrice === null ||
snapshot.endPrice === null
) {
return;
}
const containerSize = this.getContainerSize();
const rawOffsetX = point.x - this.dragStartPoint.x;
const rawOffsetY = point.y - this.dragStartPoint.y;
const minOffsetX = -geometry.left;
const maxOffsetX = containerSize.width - geometry.right;
const clampedOffsetX = clamp(rawOffsetX, minOffsetX, maxOffsetX);
const minOffsetY = -geometry.top;
const maxOffsetY = containerSize.height - geometry.bottom;
const clampedOffsetY = clamp(rawOffsetY, minOffsetY, maxOffsetY);
const nextStartTime = this.shiftTime(snapshot.startTime, clampedOffsetX);
const nextEndTime = this.shiftTime(snapshot.endTime, clampedOffsetX);
if (nextStartTime === null || nextEndTime === null) {
return;
}
const priceOffset = this.getPriceDelta(this.dragStartPoint.y, this.dragStartPoint.y + clampedOffsetY);
this.startTime = nextStartTime;
this.endTime = nextEndTime;
this.startPrice = snapshot.startPrice + priceOffset;
this.endPrice = snapshot.endPrice + priceOffset;
}
private resizeRectangle(point: Point): void {
const geometry = this.dragGeometrySnapshot;
if (!geometry || !this.activeDragTarget || this.activeDragTarget === 'body') {
return;
}
const clampedPoint = this.clampPointToContainer(point);
let { left } = geometry;
let { right } = geometry;
let { top } = geometry;
let { bottom } = geometry;
switch (this.activeDragTarget) {
case 'nw':
left = clampedPoint.x;
top = clampedPoint.y;
break;
case 'n':
top = clampedPoint.y;
break;
case 'ne':
right = clampedPoint.x;
top = clampedPoint.y;
break;
case 'e':
right = clampedPoint.x;
break;
case 'se':
right = clampedPoint.x;
bottom = clampedPoint.y;
break;
case 's':
bottom = clampedPoint.y;
break;
case 'sw':
left = clampedPoint.x;
bottom = clampedPoint.y;
break;
case 'w':
left = clampedPoint.x;
break;
default:
return;
}
this.setRectangleBounds(left, right, top, bottom);
}
private setRectangleBounds(left: number, right: number, top: number, bottom: number): boolean {
const bounds = normalizeBounds(left, right, top, bottom, this.container);
const startTime = getTimeFromXCoordinate(this.chart, bounds.left);
const endTime = getTimeFromXCoordinate(this.chart, bounds.right);
const startPrice = getPriceFromYCoordinate(this.series, bounds.top);
const endPrice = getPriceFromYCoordinate(this.series, bounds.bottom);
if (startTime === null || endTime === null || startPrice === null || endPrice === null) {
return false;
}
this.startTime = startTime;
this.endTime = endTime;
this.startPrice = startPrice;
this.endPrice = endPrice;
return true;
}
private createAnchor(point: Point): { time: Time; price: number } | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getGeometry(): RectangleGeometry | null {
if (this.startTime === null || this.endTime === null || this.startPrice === null || this.endPrice === null) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
const startY = getYCoordinateFromPrice(this.series, this.startPrice);
const endY = getYCoordinateFromPrice(this.series, this.endPrice);
if (startX === null || endX === null || startY === null || endY === null) {
return null;
}
const left = Math.round(Math.min(Number(startX), Number(endX)));
const right = Math.round(Math.max(Number(startX), Number(endX)));
const top = Math.round(Math.min(Number(startY), Number(endY)));
const bottom = Math.round(Math.max(Number(startY), Number(endY)));
const centerX = (left + right) / 2;
const centerY = (top + bottom) / 2;
return {
left,
right,
top,
bottom,
width: right - left,
height: bottom - top,
handles: {
nw: { x: left, y: top },
n: { x: centerX, y: top },
ne: { x: right, y: top },
e: { x: right, y: centerY },
se: { x: right, y: bottom },
s: { x: centerX, y: bottom },
sw: { x: left, y: bottom },
w: { x: left, y: centerY },
},
};
}
private getTimeBounds(): { left: number; right: number } | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
left: geometry.left,
right: geometry.right,
};
}
private getPriceBounds(): { top: number; bottom: number } | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
top: geometry.top,
bottom: geometry.bottom,
};
}
private getTimeCoordinate(kind: TimeLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return kind === 'left' ? geometry.left : geometry.right;
}
private getPriceCoordinate(kind: PriceLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return kind === 'top' ? geometry.top : geometry.bottom;
}
private getTimeText(kind: TimeLabelKind): string {
const time = this.getTimeValueForLabel(kind);
if (typeof time !== 'number') {
return '';
}
return formatDate(
time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
private getPriceText(kind: PriceLabelKind): string {
const price = this.getPriceValueForLabel(kind);
if (price === null) {
return '';
}
return formatPrice(price) ?? '';
}
private getTimeValueForLabel(kind: TimeLabelKind): Time | null {
if (this.startTime === null || this.endTime === null) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
if (startX === null || endX === null) {
return kind === 'left' ? this.startTime : this.endTime;
}
const startIsLeft = Number(startX) <= Number(endX);
if (kind === 'left') {
return startIsLeft ? this.startTime : this.endTime;
}
return startIsLeft ? this.endTime : this.startTime;
}
private getPriceValueForLabel(kind: PriceLabelKind): number | null {
if (this.startPrice === null || this.endPrice === null) {
return null;
}
const startY = getYCoordinateFromPrice(this.series, this.startPrice);
const endY = getYCoordinateFromPrice(this.series, this.endPrice);
if (startY === null || endY === null) {
return kind === 'top' ? Math.max(this.startPrice, this.endPrice) : Math.min(this.startPrice, this.endPrice);
}
const startIsTop = Number(startY) <= Number(endY);
if (kind === 'top') {
return startIsTop ? this.startPrice : this.endPrice;
}
return startIsTop ? this.endPrice : this.startPrice;
}
private getHandleTarget(point: Point): RectangleHandleKey | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
const handleOrder: RectangleHandleKey[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
for (const handleName of handleOrder) {
const handle = geometry.handles[handleName];
if (isNearPoint(point, handle.x, handle.y, HANDLE_HIT_TOLERANCE)) {
return handleName;
}
}
return null;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
}
private getCursorStyle(handle: Exclude<RectangleHandle, null>): PrimitiveHoveredItem['cursorStyle'] {
switch (handle) {
case 'nw':
case 'se':
return 'nwse-resize';
case 'ne':
case 'sw':
return 'nesw-resize';
case 'n':
case 's':
return 'ns-resize';
case 'e':
case 'w':
return 'ew-resize';
case 'body':
return 'grab';
default:
return 'default';
}
}
private shiftTime(time: Time, offsetX: number): Time | null {
return shiftTimeByPixels(this.chart, time, offsetX, this.series);
}
private getPriceDelta(fromY: number, toY: number): number {
return getPriceDeltaFromCoordinates(this.series, fromY, toY);
}
private getContainerSize(): { width: number; height: number } {
return getElementContainerSize(this.container);
}
private clampPointToContainer(point: Point): Point {
return clampPointToContainerInElement(point, this.container);
}
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
import { Rectangle } from './rectangle';
const UI = {
borderWidth: 1,
handleSize: 10,
handleBorderWidth: 1,
textOffset: 4,
textLineHeightMultiplier: 1.2,
};
export class RectanglePaneRenderer implements IPrimitivePaneRenderer {
private readonly rectangle: Rectangle;
constructor(rectangle: Rectangle) {
this.rectangle = rectangle;
}
public draw(target: CanvasRenderingTarget2D): void {
const data = this.rectangle.getRenderData();
if (!data) {
return;
}
const { colors } = getThemeStore();
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
const left = data.left * horizontalPixelRatio;
const right = data.right * horizontalPixelRatio;
const top = data.top * verticalPixelRatio;
const bottom = data.bottom * verticalPixelRatio;
context.save();
if (data.showFill) {
context.fillStyle = data.fillColor;
context.fillRect(left, top, right - left, bottom - top);
}
context.lineWidth = UI.borderWidth * pixelRatio;
context.strokeStyle = data.borderColor;
context.strokeRect(left, top, right - left, bottom - top);
drawRectangleText(context, {
left,
right,
top,
text: data.text,
fontSize: data.fontSize,
isBold: data.isBold,
isItalic: data.isItalic,
textColor: data.textColor,
pixelRatio,
});
if (data.showHandles) {
for (const handle of Object.values(data.handles)) {
drawHandle(
context,
handle.x * horizontalPixelRatio,
handle.y * verticalPixelRatio,
horizontalPixelRatio,
verticalPixelRatio,
colors.chartLineColor,
colors.chartBackground,
);
}
}
context.restore();
});
}
}
function drawRectangleText(
context: CanvasRenderingContext2D,
params: {
left: number;
right: number;
top: number;
text: string;
fontSize: number;
isBold: boolean;
isItalic: boolean;
textColor: string;
pixelRatio: number;
},
): void {
const { left, top, text, fontSize, isBold, isItalic, textColor, pixelRatio } = params;
if (!text.trim()) {
return;
}
const lines = text.split('\n');
const safeFontSize = Math.max(1, fontSize);
const fontSizePx = safeFontSize * pixelRatio;
const lineHeight = safeFontSize * UI.textLineHeightMultiplier * pixelRatio;
const fontWeight = isBold ? '700 ' : '';
const fontStyle = isItalic ? 'italic ' : '';
const textOffset = UI.textOffset * pixelRatio;
const textX = left;
const blockHeight = lines.length * lineHeight;
const firstLineY = top - textOffset - blockHeight + lineHeight / 2;
context.save();
context.font = `${fontStyle}${fontWeight}${fontSizePx}px Inter, sans-serif`;
context.fillStyle = textColor;
context.textAlign = 'left';
context.textBaseline = 'middle';
lines.forEach((line, index) => {
context.fillText(line, textX, firstLineY + index * lineHeight);
});
context.restore();
}
function drawHandle(
context: CanvasRenderingContext2D,
x: number,
y: number,
horizontalPixelRatio: number,
verticalPixelRatio: number,
strokeColor: string,
fillColor: string,
): void {
const width = UI.handleSize * horizontalPixelRatio;
const height = UI.handleSize * verticalPixelRatio;
const left = x - width / 2;
const top = y - height / 2;
context.save();
context.fillStyle = fillColor;
context.strokeStyle = strokeColor;
context.lineWidth = UI.handleBorderWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
context.beginPath();
context.rect(left, top, width, height);
context.fill();
context.stroke();
context.restore();
}
import { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';
import { Subscription } from 'rxjs';
import { DOMObject, DOMObjectParams } from '@core/DOMObject';
import { ISeriesDrawing } from '@core/Drawings/common';
import { DrawingSnapshotItem } from '@core/DrawingsManager';
import { Hotkeys, Keys } from '@core/Hotkeys';
import { DrawingsNames } from '@src/constants';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { SettingsTab, SettingsValues } from '@src/types/settings';
type IDrawing = DOMObject;
interface DrawingParams extends DOMObjectParams {
drawingName: DrawingsNames;
lwcChart: IChartApi;
mainSeries: SeriesStrategies;
onDelete: (id: string) => void;
construct: (chart: IChartApi, series: ISeriesApi<SeriesType>) => ISeriesDrawing;
hotkeys: Hotkeys;
setCopyPasteBuffer: (copiedObject: DrawingSnapshotItem) => void;
resetActiveTool: () => void;
}
export class Drawing extends DOMObject implements IDrawing {
private lwcDrawing: ISeriesDrawing;
private mainSeries: SeriesStrategies;
private drawingName: DrawingsNames;
private hotkeys: Hotkeys;
private escapeUnregisterHash: string | null = null;
private deleteUnregisterHash: string | null = null;
private copyUnregisterHash: string | null = null;
constructor({
lwcChart,
name,
mainSeries,
drawingName,
id,
onDelete,
zIndex,
moveUp,
moveDown,
construct,
paneId,
hotkeys,
setCopyPasteBuffer,
resetActiveTool,
}: DrawingParams) {
super({ id, name, zIndex, onDelete, moveUp, moveDown, paneId });
this.hotkeys = hotkeys;
this.lwcDrawing = construct(lwcChart, mainSeries);
this.onDelete = onDelete;
this.escapeUnregisterHash = hotkeys.register({
keys: [Keys.escape],
callback: () => {
this.delete();
resetActiveTool();
},
});
this.lwcDrawing.subscribeIsSelected((isSelected) => {
if (isSelected) {
this.deleteUnregisterHash = hotkeys.register({
keys: [Keys.delete],
callback: () => {
this.delete();
},
});
this.copyUnregisterHash = hotkeys.register({
keys: [Keys.control, Keys.c],
callback: () => {
if (!this.isCreationPending()) {
const copiedObject = {
id: this.id,
drawingName: this.getDrawingName(),
state: this.getState(),
isLocked: this.isLocked(),
};
setCopyPasteBuffer(copiedObject);
}
},
});
} else {
hotkeys.unregister({
keys: [Keys.delete],
hash: this.deleteUnregisterHash,
});
hotkeys.unregister({
keys: [Keys.control, Keys.c],
hash: this.copyUnregisterHash,
});
}
});
this.mainSeries = mainSeries;
this.drawingName = drawingName;
this.afterCreation(() => {
hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
});
}
public delete(): void {
this.destroy();
super.delete();
}
public getDrawingName(): DrawingsNames {
return this.drawingName;
}
public getLwcDrawing(): ISeriesDrawing {
return this.lwcDrawing;
}
public show(): void {
this.lwcDrawing.show();
super.show();
}
public hide(): void {
this.lwcDrawing.hide();
super.hide();
}
public rebind = (nextMainSeries: SeriesStrategies): void => {
this.lwcDrawing.rebind(nextMainSeries);
this.mainSeries = nextMainSeries;
};
public isCreationPending(): boolean {
return this.lwcDrawing.isCreationPending();
}
public subscribeIsLocked(callback: (isLocked: boolean) => void): Subscription {
return this.lwcDrawing.subscribeIsLocked(callback);
}
public isLocked(): boolean {
return this.lwcDrawing.isLocked();
}
public isSelected(): boolean {
return this.lwcDrawing.isSelected();
}
public setLocked(isLocked: boolean): void {
this.lwcDrawing.setLocked(isLocked);
}
public async waitForCreation(): Promise<void> {
return this.lwcDrawing.waitTillReady();
}
public shouldShowInObjectTree(): boolean {
return this.lwcDrawing.shouldShowInObjectTree();
}
public getState(): unknown {
return this.lwcDrawing.getState();
}
public setState(state: unknown): void {
this.lwcDrawing.setState(state);
}
public getSettings(): SettingsValues {
return this.lwcDrawing.getSettings();
}
public updateSettings(settings: SettingsValues): void {
this.lwcDrawing.updateSettings(settings);
}
public getSettingsTabs(): SettingsTab[] {
return this.lwcDrawing.getSettingsTabs();
}
public hasSettings(): boolean {
return this.getSettingsTabs().some((tab) => tab.fields.length > 0);
}
public destroy(): void {
this.hotkeys.unregister({
keys: [Keys.delete],
hash: this.deleteUnregisterHash,
});
this.hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
this.hotkeys.unregister({
keys: [Keys.control, Keys.c],
hash: this.copyUnregisterHash,
});
this.mainSeries.detachPrimitive(this.lwcDrawing);
this.lwcDrawing.destroy();
}
private async afterCreation(callback: () => void): Promise<void> {
await this.lwcDrawing.waitTillReady();
callback();
}
}
import { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { EventManager } from '@core';
import { DOMModel } from '@core/DOMModel';
import { Drawing } from '@core/Drawings';
import { Hotkeys, Keys } from '@core/Hotkeys';
import { EntitySettingsModal } from '@src/components/EntitySettingsModal';
import { drawingLabelById, drawingsMap, DrawingsNames } from '@src/constants';
import { ModalRenderer } from '@src/core/ModalRenderer';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ActiveDrawingTool } from '@src/types';
interface DrawingsManagerParams {
eventManager: EventManager;
mainSeries$: Observable<SeriesStrategies | null>;
lwcChart: IChartApi;
DOM: DOMModel;
container: HTMLElement;
modalRenderer: ModalRenderer;
paneId: number;
hotkeys: Hotkeys;
}
export interface DrawingSnapshotItem {
id: string;
drawingName: DrawingsNames;
state: unknown;
isLocked?: boolean;
}
interface CreateDrawingOptions {
id?: string;
state?: unknown;
isLocked?: boolean;
shouldUpdateDrawingsList?: boolean;
}
export type DrawingsManagerSnapshot = DrawingSnapshotItem[];
export class DrawingsManager {
private eventManager: EventManager;
private lwcChart: IChartApi;
private DOM: DOMModel;
private container: HTMLElement;
private modalRenderer: ModalRenderer;
private mainSeries: SeriesStrategies | null = null;
private subscriptions = new Subscription();
private drawings$ = new BehaviorSubject<Drawing[]>([]);
private selectedDrawing$ = new BehaviorSubject<Drawing | null>(null);
private activeTool$ = new BehaviorSubject<ActiveDrawingTool>('crosshair');
private endlessMode$ = new BehaviorSubject(false);
private recreateScheduled = false;
private pendingSnapshot: DrawingsManagerSnapshot | null = null;
private paneId: number;
private hotkeys: Hotkeys;
private copyPasteBuffer: DrawingSnapshotItem | null = null;
private escapeUnregisterHash: string | null = null;
constructor({
eventManager,
mainSeries$,
lwcChart,
DOM,
container,
modalRenderer,
paneId,
hotkeys,
}: DrawingsManagerParams) {
this.DOM = DOM;
this.eventManager = eventManager;
this.paneId = paneId;
this.lwcChart = lwcChart;
this.container = container;
this.modalRenderer = modalRenderer;
this.hotkeys = hotkeys;
this.subscriptions.add(
mainSeries$.subscribe((series) => {
if (!series) {
return;
}
this.mainSeries = series;
this.drawings$.value.forEach((drawing) => drawing.rebind(series));
if (this.pendingSnapshot) {
const snapshot = this.pendingSnapshot;
this.pendingSnapshot = null;
this.setSnapshot(snapshot);
}
}),
);
window.addEventListener('pointerup', this.handlePointerUp);
this.container.addEventListener('click', this.handleClick);
this.container.addEventListener('pointerdown', this.handlePointerDown);
// todo: implement ctrl+v
// hotkeys.register({
// keys: [Keys.control, Keys.v],
// callback: () => {
// const bufferWithAppliedPosition = {
// ...this.copyPasteBuffer,
// state: {
// ...this.copyPasteBuffer?.state,
// startAnchor: {
// price: 73.36210252637723,
// time: 1783679170
// }
// }
// }
//
// this.setSnapshot([
// ...this.getSnapshot(),
// bufferWithAppliedPosition
// ])
// // hotkeys.unregister({ // todo: unregister all else ctrl+c's
// // keys: [Keys.control, Keys.c]
// // })
// }
// })
}
private handlePointerDown = (): void => {
this.DOM.refreshEntities();
queueMicrotask(this.updateSelectedDrawing);
};
private handlePointerUp = (): void => {
this.DOM.refreshEntities();
this.updateActiveTool();
queueMicrotask(this.updateSelectedDrawing);
};
private handleClick = (): void => {
this.DOM.refreshEntities();
this.updateActiveTool();
this.updateSelectedDrawing();
};
private updateSelectedDrawing = (): void => {
const selectedDrawing =
this.drawings$.value.find((drawing) => drawing.isSelected() && !drawing.isCreationPending()) ?? null;
if (this.selectedDrawing$.value === selectedDrawing) {
return;
}
this.selectedDrawing$.next(selectedDrawing);
};
private updateActiveTool = (): void => {
const hasPendingDrawing = this.drawings$.value.some((drawing) => drawing.isCreationPending());
if (hasPendingDrawing) {
return;
}
const activeTool = this.activeTool$.value;
const isSingleInstanceTool = activeTool !== 'crosshair' && drawingsMap[activeTool]?.singleInstance;
if (activeTool !== 'crosshair' && this.endlessMode$.value && !isSingleInstanceTool) {
if (this.recreateScheduled) {
return;
}
this.recreateScheduled = true;
queueMicrotask(() => {
this.recreateScheduled = false;
const currentTool = this.activeTool$.value;
const hasPendingAfterTick = this.drawings$.value.some((drawing) => drawing.isCreationPending());
if (currentTool === 'crosshair') {
return;
}
if (!this.endlessMode$.value) {
return;
}
if (drawingsMap[currentTool]?.singleInstance) {
return;
}
if (hasPendingAfterTick) {
return;
}
this.createDrawing(currentTool);
});
return;
}
this.activeTool$.next('crosshair');
};
private removeDrawing = (id: string): void => {
const drawing = this.drawings$.value.find((item) => item.id === id);
if (!drawing) {
return;
}
this.removeDrawings([drawing]);
};
private removeDrawingsByName(name: DrawingsNames, shouldUpdateTool = true): void {
const drawingsToRemove = this.drawings$.value.filter((drawing) => drawing.getDrawingName() === name);
this.removeDrawings(drawingsToRemove, shouldUpdateTool);
}
private removePendingDrawings(shouldUpdateTool = true): void {
const drawingsToRemove = this.drawings$.value.filter((drawing) => drawing.isCreationPending());
this.removeDrawings(drawingsToRemove, shouldUpdateTool);
}
private removeDrawings(drawingsToRemove: Drawing[], shouldUpdateTool = true): void {
if (!drawingsToRemove.length) {
return;
}
const selectedDrawing = this.selectedDrawing$.value;
if (selectedDrawing && drawingsToRemove.includes(selectedDrawing)) {
this.selectedDrawing$.next(null);
}
drawingsToRemove.forEach((drawing) => {
drawing.destroy();
this.DOM.removeEntity(drawing);
});
this.drawings$.next(this.drawings$.value.filter((drawing) => !drawingsToRemove.includes(drawing)));
if (shouldUpdateTool) {
this.updateActiveTool();
}
this.updateSelectedDrawing();
this.DOM.refreshEntities();
}
public addDrawingForce = (name: DrawingsNames): Promise<void> => {
this.removePendingDrawings(false);
if (drawingsMap[name].singleInstance) {
this.removeDrawingsByName(name, false);
}
this.activeTool$.next(name);
const drawing = this.createDrawing(name);
this.DOM.refreshEntities();
return drawing.waitForCreation();
};
private createDrawing(name: DrawingsNames, options: CreateDrawingOptions = {}): Drawing {
if (!this.mainSeries) {
throw new Error('[Drawings] main series is not defined');
}
const { id, state, isLocked = false, shouldUpdateDrawingsList = true } = options;
const config = drawingsMap[name];
const drawingId = id ?? crypto.randomUUID();
let createdDrawing: Drawing | null = null;
const construct = (chart: IChartApi, series: ISeriesApi<SeriesType>) => {
const paneElement = series.getPane().getHTMLElement();
if (!paneElement) {
throw new Error('[Drawing Manager]: cannot place drawing, there is no pane');
}
const cells = paneElement.querySelectorAll<HTMLTableCellElement>(':scope > td');
const canvasElement = cells.item(1);
return config.construct({
chart,
series,
eventManager: this.eventManager,
container: canvasElement,
removeSelf: () => this.removeDrawing(drawingId),
openSettings: () => {
if (createdDrawing) {
this.openSettings(createdDrawing);
}
},
});
};
const drawingFactory = (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) =>
new Drawing({
lwcChart: this.lwcChart,
mainSeries: this.mainSeries as SeriesStrategies,
id: drawingId,
drawingName: name,
name: drawingLabelById()[name],
onDelete: this.removeDrawing,
zIndex,
moveDown,
moveUp,
construct,
paneId: this.paneId,
hotkeys: this.hotkeys,
setCopyPasteBuffer: (copyPasteBuffer) => {
this.copyPasteBuffer = copyPasteBuffer;
},
resetActiveTool: () => {
this.activeTool$.next('crosshair');
},
});
const entity = this.DOM.setEntity<Drawing>(drawingFactory);
createdDrawing = entity;
if (state !== undefined) {
entity.setState(state);
}
entity.setLocked(isLocked);
if (shouldUpdateDrawingsList) {
this.drawings$.next([...this.drawings$.value, entity]);
}
return entity;
}
public getSnapshot(): DrawingsManagerSnapshot {
return this.drawings$.value
.filter((drawing) => !drawing.isCreationPending())
.map((drawing) => ({
id: drawing.id,
drawingName: drawing.getDrawingName(),
state: drawing.getState(),
isLocked: drawing.isLocked(),
}));
}
public setSnapshot(snapshot: DrawingsManagerSnapshot): void {
if (!Array.isArray(snapshot)) {
return;
}
if (!this.mainSeries) {
this.pendingSnapshot = snapshot;
return;
}
this.removeDrawings(this.drawings$.value, false);
const restoredDrawings = snapshot.reduce<Drawing[]>((drawings, item) => {
if (!drawingsMap[item.drawingName]) {
return drawings;
}
drawings.push(
this.createDrawing(item.drawingName, {
id: item.id,
state: item.state,
isLocked: item.isLocked,
shouldUpdateDrawingsList: false,
}),
);
return drawings;
}, []);
this.drawings$.next(restoredDrawings);
this.activeTool$.next('crosshair');
this.updateSelectedDrawing();
this.DOM.refreshEntities();
}
public setEndlessDrawingMode = (value: boolean): void => {
if (value) {
this.escapeUnregisterHash = this.hotkeys.register({
keys: [Keys.escape],
callback: () => {
this.setEndlessDrawingMode(false);
},
});
} else {
this.hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
this.escapeUnregisterHash = null;
}
this.endlessMode$.next(value);
};
public isEndlessDrawingsMode(): Observable<boolean> {
return this.endlessMode$.asObservable();
}
public getActiveTool(): Observable<ActiveDrawingTool> {
return this.activeTool$.asObservable();
}
public activateCrosshair(): void {
this.removePendingDrawings(false);
this.activeTool$.next('crosshair');
this.DOM.refreshEntities();
}
public entities(): Observable<Drawing[]> {
return this.drawings$.asObservable();
}
public selectedDrawing(): Observable<Drawing | null> {
return this.selectedDrawing$.asObservable();
}
public openSelectedDrawingSettings(): void {
const drawing = this.selectedDrawing$.value;
if (!drawing) {
return;
}
this.openSettings(drawing);
}
public deleteSelectedDrawing(): void {
const drawing = this.selectedDrawing$.value;
if (!drawing) {
return;
}
this.removeDrawing(drawing.id);
}
public toggleSelectedDrawingLock(): void {
const drawing = this.selectedDrawing$.value;
if (!drawing) {
return;
}
drawing.setLocked(!drawing.isLocked());
}
private openSettings = (drawing: Drawing): void => {
const tabs = drawing.getSettingsTabs();
if (!tabs.length || tabs.every((tab) => tab.fields.length === 0)) {
return;
}
let settings = drawing.getSettings();
this.modalRenderer.renderComponent(
<EntitySettingsModal
tabs={tabs}
values={settings}
onChange={(nextSettings) => {
settings = nextSettings;
}}
initialTabKey={tabs[0]?.key}
/>,
{
size: 'sm',
title: drawing.name,
onSave: () => drawing.updateSettings(settings),
},
);
};
public getDrawings(): Drawing[] {
return this.drawings$.value;
}
public hideAll(): void {
this.selectedDrawing$.next(null);
this.drawings$.value.forEach((drawing) => drawing.hide());
this.DOM.refreshEntities();
}
public destroy(): void {
this.hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
window.removeEventListener('pointerup', this.handlePointerUp);
this.container.removeEventListener('click', this.handleClick);
this.container.removeEventListener('pointerdown', this.handlePointerDown);
this.drawings$.value.forEach((drawing) => drawing.destroy());
this.subscriptions.unsubscribe();
this.drawings$.complete();
this.selectedDrawing$.complete();
this.activeTool$.complete();
this.endlessMode$.complete();
}
}
import classNames from 'classnames';
import { Button } from 'exchange-elements/v2';
import { useEffect, useMemo, useState } from 'react';
import {
CheckboxField,
ColorField,
NumberField,
RangeField,
SelectField,
TextAreaField,
TextField,
} from '@src/components/FormFields';
import { SettingField, SettingsTab, SettingsValues, SettingValue } from '@src/types';
import styles from './index.module.scss';
interface EntitySettingsModalProps<TValues extends SettingsValues, TField extends SettingField = SettingField> {
tabs: SettingsTab<TField>[];
values: TValues;
onChange: (settings: TValues) => void;
initialTabKey?: string;
}
export const EntitySettingsModal = <TValues extends SettingsValues, TField extends SettingField = SettingField>({
tabs,
values,
onChange,
initialTabKey,
}: EntitySettingsModalProps<TValues, TField>) => {
const availableTabs = useMemo(() => tabs.filter((tab) => tab.fields.length > 0), [tabs]);
const [activeTabKey, setActiveTabKey] = useState(initialTabKey ?? availableTabs[0]?.key ?? '');
const [form, setForm] = useState<TValues>(values);
useEffect(() => {
setForm(values);
}, [values]);
useEffect(() => {
if (!availableTabs.find((tab) => tab.key === activeTabKey)) {
setActiveTabKey(availableTabs[0]?.key ?? '');
}
}, [availableTabs, activeTabKey]);
const activeTab = availableTabs.find((tab) => tab.key === activeTabKey) ?? availableTabs[0];
const changeValue = (key: string, value: SettingValue) => {
setForm((current) => {
const next = {
...current,
[key]: value,
} as TValues;
onChange(next);
return next;
});
};
const renderField = (field: TField) => {
const value = form[field.key] ?? field.defaultValue;
if (field.type === 'select') {
return (
<SelectField
key={field.key}
label={field.label}
value={String(value)}
options={field.options}
onValueChange={(nextValue) => {
changeValue(field.key, nextValue);
}}
/>
);
}
if (field.type === 'color') {
return (
<ColorField
key={field.key}
label={field.label}
value={String(value)}
onChange={(nextValue) => {
changeValue(field.key, nextValue);
}}
/>
);
}
if (field.type === 'text') {
return (
<TextField
key={field.key}
label={field.label}
value={String(value)}
placeholder={field.placeholder}
onValueChange={(nextValue) => {
changeValue(field.key, nextValue);
}}
/>
);
}
if (field.type === 'textarea') {
return (
<TextAreaField
key={field.key}
label={field.label}
value={String(value)}
placeholder={field.placeholder}
onValueChange={(nextValue) => {
changeValue(field.key, nextValue);
}}
/>
);
}
if (field.type === 'boolean') {
return (
<CheckboxField
key={field.key}
label={field.label}
checked={Boolean(value)}
onValueChange={(nextValue) => {
changeValue(field.key, nextValue);
}}
/>
);
}
if (field.type === 'range') {
return (
<RangeField
key={field.key}
label={field.label}
value={typeof value === 'number' ? value : field.defaultValue}
min={field.min}
max={field.max}
step={field.step}
color={field.color}
suffix={field.suffix}
onChange={(event) => {
changeValue(field.key, Number(event.target.value));
}}
/>
);
}
return (
<NumberField
key={field.key}
label={field.label}
value={typeof value === 'number' ? value : field.defaultValue}
min={field.min}
max={field.max}
onValueChange={(nextValue) => {
changeValue(field.key, nextValue);
}}
/>
);
};
return (
<div className={styles.wrapper}>
{availableTabs.length > 1 && (
<div className={styles.tabs}>
{availableTabs.map(({ key, label }) => (
<Button
key={key}
className={classNames(styles.tab, { [styles.tab_active]: key === activeTabKey })}
onClick={() => setActiveTabKey(key)}
>
{label}
</Button>
))}
</div>
)}
{activeTab?.fields.map(renderField)}
</div>
);
};
import classNames from 'classnames';
import { Button } from 'exchange-elements/v2';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { GearIcon, LockIcon, LockOpenIcon, TrashIcon } from '@src/components/Icon';
import styles from './index.module.scss';
import type { Drawing } from '@core/Drawings';
import type { PointerEvent as ReactPointerEvent, SyntheticEvent } from 'react';
import type { Observable } from 'rxjs';
interface FloatingDrawingToolbarProps {
selectedDrawing$: Observable<Drawing | null>;
onToggleLock: () => void;
onOpenSettings: () => void;
onDelete: () => void;
}
interface Position {
x: number;
y: number;
}
interface DragState {
pointerId: number;
startX: number;
startY: number;
initialX: number;
initialY: number;
}
const TOOLBAR_TOP_OFFSET = 12;
export function FloatingDrawingToolbar({
selectedDrawing$,
onToggleLock,
onOpenSettings,
onDelete,
}: FloatingDrawingToolbarProps) {
const toolbarRef = useRef<HTMLDivElement | null>(null);
const dragStateRef = useRef<DragState | null>(null);
const positionRef = useRef<Position | null>(null);
const [selectedDrawing, setSelectedDrawing] = useState<Drawing | null>(null);
const [isLocked, setIsLocked] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState<Position | null>(null);
useEffect(() => {
const subscription = selectedDrawing$.subscribe(setSelectedDrawing);
return () => {
subscription.unsubscribe();
};
}, [selectedDrawing$]);
useEffect(() => {
if (!selectedDrawing) {
setIsLocked(false);
return;
}
const subscription = selectedDrawing.subscribeIsLocked(setIsLocked);
return () => {
subscription.unsubscribe();
};
}, [selectedDrawing]);
useLayoutEffect(() => {
if (!selectedDrawing) {
return;
}
const toolbar = toolbarRef.current;
const container = toolbar?.parentElement;
if (!toolbar || !container) {
return;
}
const currentPosition = positionRef.current;
if (currentPosition) {
updatePosition(clampPosition(currentPosition.x, currentPosition.y, toolbar, container));
return;
}
updatePosition({
x: Math.round((container.clientWidth - toolbar.offsetWidth) / 2),
y: TOOLBAR_TOP_OFFSET,
});
}, [selectedDrawing]);
const handleDragStart = (event: ReactPointerEvent<HTMLButtonElement>): void => {
const currentPosition = positionRef.current;
if (event.button !== 0 || !currentPosition) {
return;
}
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
dragStateRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
initialX: currentPosition.x,
initialY: currentPosition.y,
};
setIsDragging(true);
};
const handleDrag = (event: ReactPointerEvent<HTMLButtonElement>): void => {
const dragState = dragStateRef.current;
const toolbar = toolbarRef.current;
const container = toolbar?.parentElement;
if (!dragState || dragState.pointerId !== event.pointerId || !toolbar || !container) {
return;
}
event.preventDefault();
updatePosition(
clampPosition(
dragState.initialX + event.clientX - dragState.startX,
dragState.initialY + event.clientY - dragState.startY,
toolbar,
container,
),
);
};
const handleDragEnd = (event: ReactPointerEvent<HTMLButtonElement>): void => {
if (dragStateRef.current?.pointerId !== event.pointerId) {
return;
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
dragStateRef.current = null;
setIsDragging(false);
};
const stopPropagation = (event: SyntheticEvent): void => {
event.stopPropagation();
};
function updatePosition(nextPosition: Position): void {
positionRef.current = nextPosition;
setPosition((currentPosition) => {
if (currentPosition?.x === nextPosition.x && currentPosition.y === nextPosition.y) {
return currentPosition;
}
return nextPosition;
});
}
if (!selectedDrawing) {
return null;
}
return (
<div
ref={toolbarRef}
className={styles.toolbar}
style={{
visibility: position ? 'visible' : 'hidden',
transform: `translate3d(
${position?.x ?? 0}px,
${position?.y ?? 0}px,
0
)`,
}}
onClick={stopPropagation}
onContextMenu={stopPropagation}
onDoubleClick={stopPropagation}
onPointerCancel={stopPropagation}
onPointerDown={stopPropagation}
onPointerMove={stopPropagation}
onPointerUp={stopPropagation}
>
<button
type="button"
className={classNames(styles.toolbar_handle, {
[styles.dragging]: isDragging,
})}
onPointerCancel={handleDragEnd}
onPointerDown={handleDragStart}
onPointerMove={handleDrag}
onPointerUp={handleDragEnd}
>
<span />
<span />
<span />
<span />
<span />
<span />
</button>
{selectedDrawing.hasSettings() && (
<Button
size="sm"
className={styles.button}
onClick={onOpenSettings}
label={<GearIcon />}
/>
)}
<Button
size="sm"
className={classNames(styles.button, {
[styles.pressed]: isLocked,
})}
onClick={onToggleLock}
label={isLocked ? <LockIcon /> : <LockOpenIcon />}
/>
<Button
size="sm"
className={styles.button}
onClick={onDelete}
label={<TrashIcon />}
/>
</div>
);
}
function clampPosition(x: number, y: number, toolbar: HTMLElement, container: HTMLElement): Position {
return {
x: Math.max(0, Math.min(Math.round(x), container.clientWidth - toolbar.offsetWidth)),
y: Math.max(0, Math.min(Math.round(y), container.clientHeight - toolbar.offsetHeight)),
};
}