Загрузка данных
import { BehaviorSubject, Observable } from 'rxjs';
import { ChartSeriesType, Intervals, SymbolInfo, TimeFormat, Timeframes } from '@src/types';
import { DateFormat } from '@src/utils';
export type UndoKey = keyof UndoConfig;
export interface UndoRedoState {
canUndo: boolean;
canRedo: boolean;
}
interface UndoConfig {
timeframe: (value: Timeframes) => void;
seriesSelected: (value: ChartSeriesType) => void;
symbolInfo: (value: SymbolInfo) => void;
timeFormat: (value: TimeFormat) => void;
dateFormat: (value: DateFormat) => void;
interval: (value: Intervals | null) => void;
}
interface HistoryItem {
kind: 'item';
key: UndoKey;
prev: unknown;
next: unknown;
}
interface HistoryCommand {
kind: 'command';
undo: () => void;
redo: () => void;
}
interface HistoryGroup {
kind: 'group';
entries: HistoryEntry[];
}
type HistoryEntry = HistoryItem | HistoryCommand | HistoryGroup;
export class UndoRedo {
private undoStack: HistoryEntry[] = [];
private redoStack: HistoryEntry[] = [];
private groupStack: HistoryEntry[][] = [];
private state$ = new BehaviorSubject<UndoRedoState>({
canUndo: false,
canRedo: false,
});
constructor(private readonly config: UndoConfig) {}
private beginGroup(): void {
this.groupStack.push([]);
}
private endGroup(): void {
const entries = this.groupStack.pop();
if (!entries || entries.length === 0) {
return;
}
this.addEntry({
kind: 'group',
entries,
});
}
public group<T>(callback: () => T): T {
this.beginGroup();
try {
return callback();
} finally {
this.endGroup();
}
}
public push(key: UndoKey, prev: unknown, next: unknown): void {
if (Object.is(prev, next)) {
return;
}
this.addEntry({
kind: 'item',
key,
prev,
next,
});
}
public pushCommand(command: Omit<HistoryCommand, 'kind'>): void {
this.addEntry({
kind: 'command',
...command,
});
}
public undo = (): void => {
const entry = this.undoStack.pop();
if (!entry) {
return;
}
this.applyEntry(entry, 'undo');
this.redoStack.push(entry);
this.updateState();
};
public redo = (): void => {
const entry = this.redoStack.pop();
if (!entry) {
return;
}
this.applyEntry(entry, 'redo');
this.undoStack.push(entry);
this.updateState();
};
public canUndo(): boolean {
return this.undoStack.length > 0;
}
public canRedo(): boolean {
return this.redoStack.length > 0;
}
public getState(): Observable<UndoRedoState> {
return this.state$.asObservable();
}
public clear(): void {
this.undoStack = [];
this.redoStack = [];
this.groupStack = [];
this.updateState();
}
private addEntry(entry: HistoryEntry): void {
if (this.redoStack.length > 0) {
this.redoStack = [];
}
const currentGroup = this.groupStack[this.groupStack.length - 1];
if (currentGroup) {
currentGroup.push(entry);
return;
}
this.undoStack.push(entry);
this.updateState();
}
private applyEntry(entry: HistoryEntry, direction: 'undo' | 'redo'): void {
if (entry.kind === 'group') {
const entries = direction === 'undo' ? [...entry.entries].reverse() : entry.entries;
entries.forEach((groupEntry) => {
this.applyEntry(groupEntry, direction);
});
return;
}
if (entry.kind === 'command') {
entry[direction]();
return;
}
const apply = this.config[entry.key] as (value: unknown) => void;
apply(direction === 'undo' ? entry.prev : entry.next);
}
private updateState(): void {
const nextState: UndoRedoState = {
canUndo: this.canUndo(),
canRedo: this.canRedo(),
};
const currentState = this.state$.value;
if (
currentState.canUndo === nextState.canUndo &&
currentState.canRedo === nextState.canRedo
) {
return;
}
this.state$.next(nextState);
}
}
export enum Keys {
'escape' = 'escape',
'delete' = 'delete',
'tab' = 'tab',
'shift' = 'shift',
'control' = 'control',
'meta' = 'meta',
'mod' = 'mod',
'alt' = 'alt',
't' = 'keyt',
'h' = 'keyh',
'v' = 'keyv',
'f' = 'keyf',
'z' = 'keyz',
'y' = 'keyy',
'c' = 'keyc',
'mousedown' = 'mousedown',
}
type HotkeyCallback = () => void | Promise<void>;
interface RegisterHotkeyParams {
keys: Keys[];
callback: HotkeyCallback;
}
interface UnregisterHotkeyParams {
keys: Keys[];
hash?: string | null;
}
interface HotkeyRegistration {
keys: Keys[];
callback: HotkeyCallback;
}
export interface IHotkeys {
register(params: RegisterHotkeyParams): string | null;
unregister(params: UnregisterHotkeyParams): void;
}
// todo: возможно можно сделать синглтоном и использовать импортируя эк земпляр класса,
// там где это нужно вместо props drilling, как сейчас
export class Hotkeys implements IHotkeys {
private registrations = new Map<string, HotkeyRegistration>();
constructor() {
document.addEventListener('keydown', this.handleKeyDown);
}
public register({ keys, callback }: RegisterHotkeyParams): string | null {
if (keys.length === 0) {
console.error('[Hotkeys] попытка задать пустой хоткей');
return null;
}
const hash = `hotkeyCallback-${crypto.randomUUID()}`;
this.registrations.set(hash, {
keys,
callback,
});
return hash;
}
public unregister({ hash }: UnregisterHotkeyParams): void {
if (!hash) {
return;
}
this.registrations.delete(hash);
}
public destroy(): void {
document.removeEventListener('keydown', this.handleKeyDown);
this.registrations.clear();
}
private handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
if (event.repeat || isEditableElement(event.target)) {
return;
}
const callbacks = [...this.registrations.values()]
.filter(({ keys }) => matchesHotkey(event, keys))
.map(({ callback }) => callback)
.reverse();
if (callbacks.length === 0) {
return;
}
event.preventDefault();
for (const callback of callbacks) {
// eslint-disable-next-line no-await-in-loop
await callback();
}
};
}
function matchesHotkey(event: KeyboardEvent, keys: Keys[]): boolean {
const eventKey = normalizeCode(event.code);
const primaryKeys = keys.filter((key) => !isModifier(key));
if (primaryKeys.length > 1) {
return false;
}
if (primaryKeys.length === 1 && primaryKeys[0] !== eventKey) {
return false;
}
if (primaryKeys.length === 0 && !matchesModifierKey(keys, eventKey)) {
return false;
}
if (keys.includes(Keys.mod)) {
if (!event.ctrlKey && !event.metaKey) {
return false;
}
} else if (
event.ctrlKey !== keys.includes(Keys.control) ||
event.metaKey !== keys.includes(Keys.meta)
) {
return false;
}
return (
event.shiftKey === keys.includes(Keys.shift) &&
event.altKey === keys.includes(Keys.alt)
);
}
function matchesModifierKey(keys: Keys[], eventKey: Keys): boolean {
if (keys.includes(eventKey)) {
return true;
}
return (
keys.includes(Keys.mod) &&
(eventKey === Keys.control || eventKey === Keys.meta)
);
}
function isModifier(key: Keys): boolean {
return (
key === Keys.mod ||
key === Keys.control ||
key === Keys.meta ||
key === Keys.shift ||
key === Keys.alt
);
}
function normalizeCode(code: string): Keys {
switch (code.toLowerCase()) {
case 'controlleft':
case 'controlright':
return Keys.control;
case 'metaleft':
case 'metaright':
return Keys.meta;
case 'shiftleft':
case 'shiftright':
return Keys.shift;
case 'altleft':
case 'altright':
return Keys.alt;
default:
return code.toLowerCase() as Keys;
}
}
function isEditableElement(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
return (
target.isContentEditable ||
target.closest('input, textarea, select, [contenteditable]') !== null
);
}
import { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { DOMObject, DOMObjectParams } from '@core/DOMObject';
import { Hotkeys, Keys } from '@core/Hotkeys';
import { DrawingsNames } from '@src/constants';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { SettingsTab, SettingsValues, ToolbarSettingField } from '@src/types/settings';
import type { DrawingInteraction, ISeriesDrawing } from '@core/Drawings/common';
import type { DrawingSnapshotItem } from '@core/DrawingsManager';
type IDrawing = DOMObject;
interface DrawingParams extends DOMObjectParams {
drawingName: DrawingsNames;
lwcChart: IChartApi;
mainSeries: SeriesStrategies;
onDelete: (id: string) => void;
construct: (
chart: IChartApi,
series: ISeriesApi<SeriesType>,
interaction: DrawingInteraction,
) => ISeriesDrawing;
selected$: Observable<boolean>;
isSelected: () => boolean;
select: () => void;
deselect: () => void;
isLocked?: boolean;
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 lockedSubject: BehaviorSubject<boolean>;
private subscriptions = new Subscription();
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,
selected$,
isSelected,
select,
deselect,
isLocked = false,
paneId,
hotkeys,
setCopyPasteBuffer,
resetActiveTool,
}: DrawingParams) {
super({
id,
name,
zIndex,
onDelete,
moveUp,
moveDown,
paneId,
});
this.hotkeys = hotkeys;
this.mainSeries = mainSeries;
this.drawingName = drawingName;
this.lockedSubject = new BehaviorSubject(isLocked);
const interaction: DrawingInteraction = {
selected$,
locked$: this.lockedSubject.asObservable(),
isSelected,
isLocked: () => this.lockedSubject.value,
select,
deselect,
};
this.lwcDrawing = construct(
lwcChart,
mainSeries,
interaction,
);
this.onDelete = onDelete;
this.escapeUnregisterHash = hotkeys.register({
keys: [Keys.escape],
callback: () => {
this.delete();
resetActiveTool();
},
});
this.subscriptions.add(
selected$.subscribe((isSelectedDrawing) => {
if (isSelectedDrawing) {
this.deleteUnregisterHash = hotkeys.register({
keys: [Keys.delete],
callback: () => {
this.delete();
},
});
this.copyUnregisterHash = hotkeys.register({
keys: [Keys.mod, Keys.c],
callback: () => {
if (!this.isCreationPending()) {
setCopyPasteBuffer({
id: this.id,
drawingName: this.getDrawingName(),
state: this.getState(),
isLocked: this.isLocked(),
});
}
},
});
return;
}
hotkeys.unregister({
keys: [Keys.delete],
hash: this.deleteUnregisterHash,
});
hotkeys.unregister({
keys: [Keys.mod, Keys.c],
hash: this.copyUnregisterHash,
});
this.deleteUnregisterHash = null;
this.copyUnregisterHash = null;
}),
);
this.afterCreation(() => {
hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
this.escapeUnregisterHash = null;
});
}
public delete(): void {
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.lockedSubject.subscribe(callback);
}
public subscribeSettings(callback: (settings: SettingsValues) => void): Subscription {
return this.lwcDrawing.subscribeSettings(callback);
}
public isLocked(): boolean {
return this.lockedSubject.value;
}
public setLocked(isLocked: boolean): void {
if (this.lockedSubject.value === isLocked) {
return;
}
this.lockedSubject.next(isLocked);
}
public toggleLock(): void {
this.setLocked(!this.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);
this.lwcDrawing.updateSettings(this.lwcDrawing.getSettings());
}
public getSettings(): SettingsValues {
return this.lwcDrawing.getSettings();
}
public updateSettings(settings: SettingsValues): void {
this.lwcDrawing.updateSettings(settings);
}
public getSettingsTabs(): SettingsTab[] {
return this.lwcDrawing.getSettingsTabs();
}
public getToolbarSettings(): ToolbarSettingField[] {
return this.getSettingsTabs()
.flatMap((tab) => tab.fields)
.filter((field): field is ToolbarSettingField => field.toolbar !== undefined);
}
public hasSettings(): boolean {
return this.getSettingsTabs().some((tab) => tab.fields.length > 0);
}
public destroy(): void {
this.subscriptions.unsubscribe();
this.hotkeys.unregister({
keys: [Keys.delete],
hash: this.deleteUnregisterHash,
});
this.hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
this.hotkeys.unregister({
keys: [Keys.mod, Keys.c],
hash: this.copyUnregisterHash,
});
this.lockedSubject.complete();
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 { cloneDeep, isEqual } from 'lodash-es';
import { BehaviorSubject, distinctUntilChanged, map, 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';
import type { DrawingInteraction } from '@src/core/Drawings/common';
import type { SettingsValues } from '@src/types/settings';
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 DrawingHistoryItem {
snapshot: DrawingSnapshotItem;
index: number;
}
interface CreateDrawingOptions {
id?: string;
state?: unknown;
isLocked?: boolean;
shouldUpdateDrawingsList?: boolean;
replacedDrawings?: DrawingHistoryItem[];
}
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 pointerDownDrawingSnapshot: DrawingSnapshotItem | 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);
window.addEventListener('pointercancel', 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 => {
const drawing = this.selectedDrawing$.value;
this.pointerDownDrawingSnapshot =
drawing && !drawing.isCreationPending()
? this.createDrawingSnapshot(drawing)
: null;
this.DOM.refreshEntities();
};
private handlePointerUp = (): void => {
const previousSnapshot = this.pointerDownDrawingSnapshot;
this.pointerDownDrawingSnapshot = null;
queueMicrotask(() => {
if (!previousSnapshot) {
return;
}
const drawing = this.findDrawing(previousSnapshot.id);
if (!drawing || drawing.isCreationPending()) {
return;
}
this.pushDrawingChange(
previousSnapshot,
this.createDrawingSnapshot(drawing),
);
});
this.DOM.refreshEntities();
this.updateActiveTool();
};
private handleClick = (): void => {
this.DOM.refreshEntities();
this.updateActiveTool();
};
private findDrawing(id: string): Drawing | undefined {
return this.drawings$.value.find((drawing) => drawing.id === id);
}
private createDrawingSnapshot(drawing: Drawing): DrawingSnapshotItem {
return {
id: drawing.id,
drawingName: drawing.getDrawingName(),
state: cloneDeep(drawing.getState()),
isLocked: drawing.isLocked(),
};
}
private createDrawingHistoryItem(drawing: Drawing): DrawingHistoryItem {
return {
snapshot: this.createDrawingSnapshot(drawing),
index: this.drawings$.value.indexOf(drawing),
};
}
private pushDrawingChange(
previousSnapshot: DrawingSnapshotItem,
nextSnapshot: DrawingSnapshotItem,
): void {
if (isEqual(previousSnapshot, nextSnapshot)) {
return;
}
const previous = cloneDeep(previousSnapshot);
const next = cloneDeep(nextSnapshot);
this.eventManager.getUndoRedo().pushCommand({
undo: () => {
this.applyDrawingSnapshot(previous);
},
redo: () => {
this.applyDrawingSnapshot(next);
},
});
}
private pushDrawingCreation(
createdDrawing: DrawingHistoryItem,
replacedDrawings: DrawingHistoryItem[],
): void {
const created = cloneDeep(createdDrawing);
const replaced = cloneDeep(replacedDrawings);
this.eventManager.getUndoRedo().pushCommand({
undo: () => {
this.removeDrawingWithoutHistory(created.snapshot.id);
this.restoreDrawings(replaced);
},
redo: () => {
replaced.forEach(({ snapshot }) => {
this.removeDrawingWithoutHistory(snapshot.id);
});
this.restoreDrawing(created);
},
});
}
private pushDrawingDeletion(deletedDrawing: DrawingHistoryItem): void {
const deleted = cloneDeep(deletedDrawing);
this.eventManager.getUndoRedo().pushCommand({
undo: () => {
this.restoreDrawing(deleted);
},
redo: () => {
this.removeDrawingWithoutHistory(deleted.snapshot.id);
},
});
}
private applyDrawingSnapshot(snapshot: DrawingSnapshotItem): void {
const drawing = this.findDrawing(snapshot.id);
if (!drawing) {
return;
}
drawing.setState(cloneDeep(snapshot.state));
drawing.setLocked(snapshot.isLocked ?? false);
this.DOM.refreshEntities();
}
private restoreDrawing(item: DrawingHistoryItem): void {
if (this.findDrawing(item.snapshot.id)) {
return;
}
const drawing = this.createDrawing(
item.snapshot.drawingName,
{
id: item.snapshot.id,
state: cloneDeep(item.snapshot.state),
isLocked: item.snapshot.isLocked,
shouldUpdateDrawingsList: false,
},
);
const drawings = [...this.drawings$.value];
const index = Math.max(
0,
Math.min(item.index, drawings.length),
);
drawings.splice(index, 0, drawing);
this.drawings$.next(drawings);
this.activeTool$.next('crosshair');
this.DOM.refreshEntities();
}
private restoreDrawings(items: DrawingHistoryItem[]): void {
[...items]
.sort((left, right) => left.index - right.index)
.forEach((item) => {
this.restoreDrawing(item);
});
}
private updateDrawingSettings(
drawing: Drawing,
settings: SettingsValues,
): void {
const previousSnapshot = this.createDrawingSnapshot(drawing);
drawing.updateSettings(settings);
this.pushDrawingChange(
previousSnapshot,
this.createDrawingSnapshot(drawing),
);
}
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.findDrawing(id);
if (!drawing) {
return;
}
if (drawing.isCreationPending()) {
this.removeDrawings([drawing]);
return;
}
const historyItem = this.createDrawingHistoryItem(drawing);
this.removeDrawings([drawing]);
this.pushDrawingDeletion(historyItem);
};
private removeDrawingWithoutHistory(id: string): void {
const drawing = this.findDrawing(id);
if (!drawing) {
return;
}
this.removeDrawings([drawing], false);
this.activeTool$.next('crosshair');
this.DOM.refreshEntities();
}
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.DOM.refreshEntities();
}
public addDrawingForce = (name: DrawingsNames): Promise<void> => {
this.removePendingDrawings(false);
const replacedDrawings = drawingsMap[name].singleInstance
? this.drawings$.value
.filter((drawing) => drawing.getDrawingName() === name)
.map((drawing) => this.createDrawingHistoryItem(drawing))
: [];
if (replacedDrawings.length > 0) {
this.removeDrawingsByName(name, false);
}
this.activeTool$.next(name);
const drawing = this.createDrawing(name, {
replacedDrawings,
});
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,
replacedDrawings = [],
} = options;
const shouldSelectAfterCreation = state === undefined;
if (shouldSelectAfterCreation && this.selectedDrawing$.value) {
this.selectedDrawing$.next(null);
}
const config = drawingsMap[name];
const drawingId = id ?? crypto.randomUUID();
let createdDrawing: Drawing | null = null;
const selected$ = this.selectedDrawing$.pipe(
map((drawing) => drawing?.id === drawingId),
distinctUntilChanged(),
);
const construct = (
chart: IChartApi,
series: ISeriesApi<SeriesType>,
interaction: DrawingInteraction,
) => {
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,
interaction,
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,
selected$,
isSelected: () => this.selectedDrawing$.value?.id === drawingId,
select: () => {
if (
!createdDrawing ||
createdDrawing.isCreationPending() ||
this.selectedDrawing$.value === createdDrawing
) {
return;
}
this.selectedDrawing$.next(createdDrawing);
},
deselect: () => {
if (
!createdDrawing ||
this.selectedDrawing$.value !== createdDrawing
) {
return;
}
this.selectedDrawing$.next(null);
},
isLocked,
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(cloneDeep(state));
}
if (shouldUpdateDrawingsList) {
this.drawings$.next([
...this.drawings$.value,
entity,
]);
}
if (shouldSelectAfterCreation) {
entity.waitForCreation().then(() => {
if (!this.drawings$.value.includes(entity)) {
return;
}
this.pushDrawingCreation(
this.createDrawingHistoryItem(entity),
replacedDrawings,
);
this.selectedDrawing$.next(entity);
this.updateActiveTool();
this.DOM.refreshEntities();
});
}
return entity;
}
public getSnapshot(): DrawingsManagerSnapshot {
return this.drawings$.value
.filter((drawing) => !drawing.isCreationPending())
.map((drawing) => this.createDrawingSnapshot(drawing));
}
public setSnapshot(snapshot: DrawingsManagerSnapshot): void {
if (!Array.isArray(snapshot)) {
return;
}
if (!this.mainSeries) {
this.pendingSnapshot = cloneDeep(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: cloneDeep(item.state),
isLocked: item.isLocked,
shouldUpdateDrawingsList: false,
}),
);
return drawings;
}, []);
this.drawings$.next(restoredDrawings);
this.activeTool$.next('crosshair');
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 updateSelectedDrawingSettings = (
settings: SettingsValues,
): void => {
const drawing = this.selectedDrawing$.value;
if (!drawing) {
return;
}
this.updateDrawingSettings(drawing, settings);
};
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;
}
const previousSnapshot = this.createDrawingSnapshot(drawing);
drawing.toggleLock();
this.pushDrawingChange(
previousSnapshot,
this.createDrawingSnapshot(drawing),
);
}
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: () => {
this.updateDrawingSettings(
drawing,
settings,
);
},
},
);
};
public getDrawings(): Drawing[] {
return this.drawings$.value;
}
public hideAll(): void {
if (this.selectedDrawing$.value) {
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);
window.removeEventListener('pointercancel', this.handlePointerUp);
this.container.removeEventListener('click', this.handleClick);
this.container.removeEventListener('pointerdown', this.handlePointerDown);
this.drawings$.value.forEach((drawing) => drawing.destroy());
this.pointerDownDrawingSnapshot = null;
this.subscriptions.unsubscribe();
this.drawings$.complete();
this.selectedDrawing$.complete();
this.activeTool$.complete();
this.endlessMode$.complete();
}
}
import classNames from 'classnames';
import { Button, Tooltip } from 'exchange-elements/v2';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { GearIcon, LockIcon, LockOpenIcon, TrashIcon } from '@src/components/Icon';
import { ToolbarColorControl } from '@src/components/ToolbarColorControl';
import { t } from '@src/translations';
import styles from './index.module.scss';
import type { Drawing } from '@core/Drawings';
import type { SettingsValues } from '@src/types/settings';
import type { PointerEvent, SyntheticEvent } from 'react';
import type { Observable } from 'rxjs';
interface FloatingDrawingToolbarProps {
selectedDrawing$: Observable<Drawing | null>;
onUpdateSettings: (settings: SettingsValues) => void;
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$,
onUpdateSettings,
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 [settings, setSettings] = useState<SettingsValues>({});
const [isLocked, setIsLocked] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState<Position | null>(null);
const shouldShowToolbar = selectedDrawing?.hasSettings() ?? false;
useEffect(() => {
const subscription = selectedDrawing$.subscribe(setSelectedDrawing);
return () => {
subscription.unsubscribe();
};
}, [selectedDrawing$]);
useEffect(() => {
if (!selectedDrawing || !shouldShowToolbar) {
setIsLocked(false);
return;
}
const subscription = selectedDrawing.subscribeIsLocked(setIsLocked);
return () => {
subscription.unsubscribe();
};
}, [selectedDrawing, shouldShowToolbar]);
useEffect(() => {
if (!selectedDrawing || !shouldShowToolbar) {
setSettings({});
return;
}
const subscription = selectedDrawing.subscribeSettings(setSettings);
return () => {
subscription.unsubscribe();
};
}, [selectedDrawing, shouldShowToolbar]);
useLayoutEffect(() => {
if (!selectedDrawing || !shouldShowToolbar) {
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, shouldShowToolbar]);
const handleDragStart = (
event: PointerEvent<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: PointerEvent<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: PointerEvent<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 || !shouldShowToolbar) {
return null;
}
const toolbarSettings = selectedDrawing.getToolbarSettings();
const lockLabel = isLocked ? t('Unlock') : t('Lock');
return (
<div
ref={toolbarRef}
className={styles.toolbar}
style={{
visibility: position ? 'visible' : 'hidden',
transform: `translate3d(${position?.x ?? 0}px, ${position?.y ?? 0}px, 0)`,
}}
onPointerDown={stopPropagation}
onClick={stopPropagation}
onDoubleClick={stopPropagation}
onContextMenu={stopPropagation}
>
<button
type="button"
className={classNames(styles.toolbar_handle, {
[styles.dragging]: isDragging,
})}
onPointerDown={handleDragStart}
onPointerMove={handleDrag}
onPointerUp={handleDragEnd}
onPointerCancel={handleDragEnd}
>
<span />
<span />
<span />
<span />
<span />
<span />
</button>
{toolbarSettings.map((field) => {
if (
field.type !== 'color' ||
field.toolbar.control !== 'color'
) {
return null;
}
return (
<ToolbarColorControl
key={field.key}
role={field.toolbar.role}
value={String(
settings[field.key] ??
field.defaultValue,
)}
onChange={(value) => {
onUpdateSettings({
[field.key]: value,
});
}}
/>
);
})}
<Tooltip
tooltipClassName={styles.toolbar_tooltip}
showMessageOnFocus
label={t('Settings')}
location="top"
>
<Button
size="sm"
className={styles.button}
onClick={onOpenSettings}
label={<GearIcon />}
/>
</Tooltip>
<Tooltip
tooltipClassName={styles.toolbar_tooltip}
showMessageOnFocus
label={lockLabel}
location="top"
>
<Button
size="sm"
className={classNames(styles.button, {
[styles.pressed]: isLocked,
})}
onClick={onToggleLock}
label={isLocked ? <LockIcon /> : <LockOpenIcon />}
/>
</Tooltip>
<Tooltip
tooltipClassName={styles.toolbar_tooltip}
showMessageOnFocus
label={t('Remove')}
location="top"
>
<Button
size="sm"
className={styles.button}
onClick={onDelete}
label={<TrashIcon />}
/>
</Tooltip>
</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,
),
),
};
}
this.hotkeys = new Hotkeys();
if (config.chartCollectionPreset.undoRedoEnabled) {
const undoRedo = this.eventManager.getUndoRedo();
this.hotkeys.register({
keys: [Keys.mod, Keys.z],
callback: undoRedo.undo,
});
this.hotkeys.register({
keys: [Keys.mod, Keys.shift, Keys.z],
callback: undoRedo.redo,
});
this.hotkeys.register({
keys: [Keys.mod, Keys.y],
callback: undoRedo.redo,
});
}
this.modalRenderer = new ModalRenderer(modalContainer);
<FloatingDrawingToolbar
selectedDrawing$={drawingsManager.selectedDrawing()}
onUpdateSettings={drawingsManager.updateSelectedDrawingSettings}
onToggleLock={() => drawingsManager.toggleSelectedDrawingLock()}
onOpenSettings={() => drawingsManager.openSelectedDrawingSettings()}
onDelete={() => drawingsManager.deleteSelectedDrawing()}
/>