Загрузка данных
diff --git a/src/components/FloatingToolbar/index.tsx b/src/components/FloatingToolbar/index.tsx
index d055097..cce1bd7 100644
--- a/src/components/FloatingToolbar/index.tsx
+++ b/src/components/FloatingToolbar/index.tsx
@@ -15,6 +15,7 @@ import type { Observable } from 'rxjs';
interface FloatingDrawingToolbarProps {
selectedDrawing$: Observable<Drawing | null>;
+ onUpdateSettings: (settings: SettingsValues) => void;
onToggleLock: () => void;
onOpenSettings: () => void;
onDelete: () => void;
@@ -37,6 +38,7 @@ const TOOLBAR_TOP_OFFSET = 12;
export function FloatingDrawingToolbar({
selectedDrawing$,
+ onUpdateSettings,
onToggleLock,
onOpenSettings,
onDelete,
@@ -235,7 +237,7 @@ export function FloatingDrawingToolbar({
role={field.toolbar.role}
value={String(settings[field.key] ?? field.defaultValue)}
onChange={(value) => {
- selectedDrawing.updateSettings({
+ onUpdateSettings({
[field.key]: value,
});
}}
diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx
index bc5a4bb..d0b8ba8 100644
--- a/src/components/Header/index.tsx
+++ b/src/components/Header/index.tsx
@@ -1,17 +1,14 @@
import { Button, Divider } from 'exchange-elements/v2';
import { useEffect, useState } from 'react';
-import { Observable } from 'rxjs';
import { IndicatorsSelect } from '@components/IndicatorsSelect';
import { IndicatorsIds } from '@src/constants';
import { FullscreenController } from '@src/core/Fullscreen';
-import { UndoRedo } from '@src/core/UndoRedo';
+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';
@@ -26,11 +23,17 @@ import {
SearchIcon,
UndoIcon,
} from '../Icon';
-
import { SeriesMenu, TimeframesMenu } from '../Menu';
import styles from './index.module.scss';
+import type { Observable } from 'rxjs';
+
+const defaultUndoRedoState: UndoRedoState = {
+ canUndo: false,
+ canRedo: false,
+};
+
interface HeaderProps {
timeframes: Timeframes[];
selectedTimeframeObs: Observable<Timeframes>;
@@ -74,20 +77,39 @@ export function Header({
}: HeaderProps) {
const [isToolbarOpen, setIsToolbarOpen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(fullscreen.isFullscreen);
+ const [undoRedoState, setUndoRedoState] = useState(defaultUndoRedoState);
useEffect(() => {
- const unsubscribe = fullscreen.onChange(() => setIsFullscreen(fullscreen.isFullscreen));
+ const unsubscribe = fullscreen.onChange(() => {
+ setIsFullscreen(fullscreen.isFullscreen);
+ });
return unsubscribe;
}, [fullscreen]);
+ useEffect(() => {
+ if (!undoRedo) {
+ setUndoRedoState(defaultUndoRedoState);
+
+ return;
+ }
+
+ const subscription = undoRedo.getState().subscribe(setUndoRedoState);
+
+ return () => {
+ subscription.unsubscribe();
+ };
+ }, [undoRedo]);
+
const selectedTimeframe = useObservable(selectedTimeframeObs);
const selectedSeries = useObservable(selectedSeriesObs);
const seriesDropdownValue =
selectedSeries === 'Line' ? <LineIcon /> : selectedSeries === 'Bar' ? <BarIcon /> : <CandleStickIcon />;
- const handleOpenToolbar = () => setIsToolbarOpen(() => toggleToolbarVisible());
+ const handleOpenToolbar = (): void => {
+ setIsToolbarOpen(toggleToolbarVisible());
+ };
return (
<header className={styles.header}>
@@ -148,8 +170,8 @@ export function Header({
{showSettingsModal && (
<Button
size="sm"
- className={`${styles.button}`}
- onClick={() => showSettingsModal()}
+ className={styles.button}
+ onClick={showSettingsModal}
label={t('Settings')}
/>
)}
@@ -165,27 +187,27 @@ export function Header({
</Dropdown>
</div>
- {(!!undoRedo || showFullscreenButton) && (
+ {(undoRedo || showFullscreenButton) && (
<Divider
direction="vertical"
pt={{ divider: { className: styles.divider } }}
/>
)}
- {!!undoRedo && (
+ {undoRedo && (
<div className={styles.group}>
<Button
size="sm"
className={styles.button}
- onClick={() => undoRedo?.undo()}
- disabled={!undoRedo?.canUndo()}
+ onClick={undoRedo.undo}
+ disabled={!undoRedoState.canUndo}
label={<UndoIcon />}
/>
<Button
size="sm"
className={styles.button}
- onClick={() => undoRedo?.redo()}
- disabled={!undoRedo?.canRedo()}
+ onClick={undoRedo.redo}
+ disabled={!undoRedoState.canRedo}
label={<RedoIcon />}
/>
</div>
diff --git a/src/core/DOMModel.tsx b/src/core/DOMModel.tsx
index 15c4d57..236e9ad 100644
--- a/src/core/DOMModel.tsx
+++ b/src/core/DOMModel.tsx
@@ -25,74 +25,60 @@ export class DOMModel {
}
public removeEntity = <T extends IDOMObject>(entity: T): void => {
- this.entities.next(this.entities.value.filter((d) => d.id !== entity.id));
+ this.entities.next(this.entities.value.filter((item) => item.id !== entity.id));
};
public setEntity = <T extends IDOMObject>(
- cb: (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) => T,
+ callback: (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) => T,
+ zIndex?: number,
): T => {
- const entity = cb(this.lastZIndex++, this.moveUp, this.moveDown);
+ const entityZIndex = zIndex ?? this.lastZIndex;
+ const entity = callback(entityZIndex, this.moveUp, this.moveDown);
- this.entities.next([...this.entities.value, entity].sort((a, b) => a.zIndex - b.zIndex));
+ this.lastZIndex = Math.max(this.lastZIndex, entityZIndex + 1);
+ this.entities.next([...this.entities.value, entity].sort((left, right) => left.zIndex - right.zIndex));
return entity;
};
private moveUp = (id: string): void => {
- const entities = this.entities.value;
-
- const curr = entities.find((e) => e.id === id);
-
- if (!curr) {
- return;
- }
-
- const next = entities.find((e) => e.zIndex === curr.zIndex + 1);
-
- const ent = entities.filter((e) => e.id !== id && e.zIndex !== curr.zIndex + 1);
-
- if (!next) {
- return;
- }
- const [left, right] = [curr.zIndex, next.zIndex];
-
- curr.setZIndex(right);
- next.setZIndex(left);
-
- this.entities.next([...ent, curr, next].sort((a, b) => a.zIndex - b.zIndex));
+ this.moveEntity(id, 1);
};
private moveDown = (id: string): void => {
- const entities = this.entities.value;
+ this.moveEntity(id, -1);
+ };
- const curr = entities.find((e) => e.id === id);
+ private moveEntity(id: string, direction: -1 | 1): void {
+ const entities = [...this.entities.value].sort((left, right) => left.zIndex - right.zIndex);
- if (!curr) {
+ const currentIndex = entities.findIndex((entity) => entity.id === id);
+
+ if (currentIndex === -1) {
return;
}
- const prev = entities.find((e) => e.zIndex === curr.zIndex - 1);
-
- const ent = entities.filter((e) => e.id !== id && e.zIndex !== curr.zIndex - 1);
+ const target = entities[currentIndex + direction];
- if (!prev) {
+ if (!target) {
return;
}
- const [left, right] = [curr.zIndex, prev.zIndex];
+ const current = entities[currentIndex];
+ const currentZIndex = current.zIndex;
- curr.setZIndex(right);
- prev.setZIndex(left);
+ current.setZIndex(target.zIndex);
+ target.setZIndex(currentZIndex);
- this.entities.next([...ent, curr, prev].sort((a, b) => a.zIndex - b.zIndex));
- };
+ this.entities.next(entities.sort((left, right) => left.zIndex - right.zIndex));
+ }
public getEntities = (): Observable<IDOMObject[]> => {
return this.entities.pipe(map((entities) => entities.filter((entity) => entity.shouldShowInObjectTree())));
};
public refreshEntities = (): void => {
- this.entities.next([...this.entities.value]);
+ this.entities.next([...this.entities.value].sort((left, right) => left.zIndex - right.zIndex));
};
public toggleDOM = () => {
diff --git a/src/core/Drawings.ts b/src/core/Drawings.ts
index ed9f8da..abafeec 100644
--- a/src/core/Drawings.ts
+++ b/src/core/Drawings.ts
@@ -2,7 +2,6 @@ import { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { DOMObject, DOMObjectParams } from '@core/DOMObject';
-import { DrawingSnapshotItem } from '@core/DrawingsManager';
import { Hotkeys, Keys } from '@core/Hotkeys';
import { DrawingsNames } from '@src/constants';
@@ -11,13 +10,12 @@ import { SettingsTab, SettingsValues, ToolbarSettingField } from '@src/types/set
import type { DrawingInteraction, ISeriesDrawing } from '@core/Drawings/common';
-type IDrawing = DOMObject;
-
interface DrawingParams extends DOMObjectParams {
drawingName: DrawingsNames;
lwcChart: IChartApi;
mainSeries: SeriesStrategies;
onDelete: (id: string) => void;
+ onCopy: () => void;
construct: (chart: IChartApi, series: ISeriesApi<SeriesType>, interaction: DrawingInteraction) => ISeriesDrawing;
selected$: Observable<boolean>;
isSelected: () => boolean;
@@ -25,16 +23,16 @@ interface DrawingParams extends DOMObjectParams {
deselect: () => void;
isLocked?: boolean;
hotkeys: Hotkeys;
- setCopyPasteBuffer: (copiedObject: DrawingSnapshotItem) => void;
resetActiveTool: () => void;
}
-export class Drawing extends DOMObject implements IDrawing {
+export class Drawing extends DOMObject {
private lwcDrawing: ISeriesDrawing;
private mainSeries: SeriesStrategies;
private drawingName: DrawingsNames;
private hotkeys: Hotkeys;
private lockedSubject: BehaviorSubject<boolean>;
+ private settingsSubject: BehaviorSubject<SettingsValues>;
private subscriptions = new Subscription();
private escapeUnregisterHash: string | null = null;
@@ -48,6 +46,7 @@ export class Drawing extends DOMObject implements IDrawing {
drawingName,
id,
onDelete,
+ onCopy,
zIndex,
moveUp,
moveDown,
@@ -59,10 +58,17 @@ export class Drawing extends DOMObject implements IDrawing {
isLocked = false,
paneId,
hotkeys,
- setCopyPasteBuffer,
resetActiveTool,
}: DrawingParams) {
- super({ id, name, zIndex, onDelete, moveUp, moveDown, paneId });
+ super({
+ id,
+ name,
+ zIndex,
+ onDelete,
+ moveUp,
+ moveDown,
+ paneId,
+ });
this.hotkeys = hotkeys;
this.mainSeries = mainSeries;
@@ -79,7 +85,13 @@ export class Drawing extends DOMObject implements IDrawing {
};
this.lwcDrawing = construct(lwcChart, mainSeries, interaction);
- this.onDelete = onDelete;
+ this.settingsSubject = new BehaviorSubject(this.lwcDrawing.getSettings());
+
+ this.subscriptions.add(
+ this.lwcDrawing.subscribeSettings((settings) => {
+ this.settingsSubject.next(settings);
+ }),
+ );
this.escapeUnregisterHash = hotkeys.register({
keys: [Keys.escape],
@@ -100,15 +112,10 @@ export class Drawing extends DOMObject implements IDrawing {
});
this.copyUnregisterHash = hotkeys.register({
- keys: [Keys.control, Keys.c],
+ keys: [Keys.mod, Keys.c],
callback: () => {
if (!this.isCreationPending()) {
- setCopyPasteBuffer({
- id: this.id,
- drawingName: this.getDrawingName(),
- state: this.getState(),
- isLocked: this.isLocked(),
- });
+ onCopy();
}
},
});
@@ -116,22 +123,11 @@ export class Drawing extends DOMObject implements IDrawing {
return;
}
- hotkeys.unregister({
- keys: [Keys.delete],
- hash: this.deleteUnregisterHash,
- });
-
- hotkeys.unregister({
- keys: [Keys.control, Keys.c],
- hash: this.copyUnregisterHash,
- });
-
- this.deleteUnregisterHash = null;
- this.copyUnregisterHash = null;
+ this.unregisterSelectedDrawingHotkeys();
}),
);
- this.afterCreation(() => {
+ this.waitForCreation().then(() => {
hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
@@ -141,11 +137,6 @@ export class Drawing extends DOMObject implements IDrawing {
});
}
- public delete(): void {
- this.destroy();
- super.delete();
- }
-
public getDrawingName(): DrawingsNames {
return this.drawingName;
}
@@ -178,18 +169,26 @@ export class Drawing extends DOMObject implements IDrawing {
}
public subscribeSettings(callback: (settings: SettingsValues) => void): Subscription {
- return this.lwcDrawing.subscribeSettings(callback);
+ return this.settingsSubject.subscribe(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.lockedSubject.next(!this.lockedSubject.value);
+ this.setLocked(!this.isLocked());
}
- public async waitForCreation(): Promise<void> {
+ public waitForCreation(): Promise<void> {
return this.lwcDrawing.waitTillReady();
}
@@ -203,6 +202,7 @@ export class Drawing extends DOMObject implements IDrawing {
public setState(state: unknown): void {
this.lwcDrawing.setState(state);
+ this.settingsSubject.next(this.lwcDrawing.getSettings());
}
public getSettings(): SettingsValues {
@@ -229,29 +229,34 @@ export class Drawing extends DOMObject implements IDrawing {
public destroy(): void {
this.subscriptions.unsubscribe();
-
- this.hotkeys.unregister({
- keys: [Keys.delete],
- hash: this.deleteUnregisterHash,
- });
+ this.unregisterSelectedDrawingHotkeys();
this.hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
- this.hotkeys.unregister({
- keys: [Keys.control, Keys.c],
- hash: this.copyUnregisterHash,
- });
+ this.escapeUnregisterHash = null;
this.lockedSubject.complete();
+ this.settingsSubject.complete();
+
this.mainSeries.detachPrimitive(this.lwcDrawing);
this.lwcDrawing.destroy();
}
- private async afterCreation(callback: () => void): Promise<void> {
- await this.lwcDrawing.waitTillReady();
- callback();
+ private unregisterSelectedDrawingHotkeys(): void {
+ this.hotkeys.unregister({
+ keys: [Keys.delete],
+ hash: this.deleteUnregisterHash,
+ });
+
+ this.hotkeys.unregister({
+ keys: [Keys.mod, Keys.c],
+ hash: this.copyUnregisterHash,
+ });
+
+ this.deleteUnregisterHash = null;
+ this.copyUnregisterHash = null;
}
}
diff --git a/src/core/DrawingsManager.tsx b/src/core/DrawingsManager.tsx
index 569c8ee..34b1cc0 100644
--- a/src/core/DrawingsManager.tsx
+++ b/src/core/DrawingsManager.tsx
@@ -1,4 +1,5 @@
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';
@@ -13,6 +14,7 @@ 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;
@@ -30,12 +32,14 @@ export interface DrawingSnapshotItem {
drawingName: DrawingsNames;
state: unknown;
isLocked?: boolean;
+ zIndex?: number;
}
interface CreateDrawingOptions {
id?: string;
state?: unknown;
isLocked?: boolean;
+ zIndex?: number;
shouldUpdateDrawingsList?: boolean;
}
@@ -47,6 +51,8 @@ export class DrawingsManager {
private DOM: DOMModel;
private container: HTMLElement;
private modalRenderer: ModalRenderer;
+ private paneId: number;
+ private hotkeys: Hotkeys;
private mainSeries: SeriesStrategies | null = null;
private subscriptions = new Subscription();
@@ -54,11 +60,10 @@ export class DrawingsManager {
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 pointerDownSnapshot: DrawingSnapshotItem | null = null;
+ private recreateScheduled = false;
- private hotkeys: Hotkeys;
private copyPasteBuffer: DrawingSnapshotItem | null = null;
private escapeUnregisterHash: string | null = null;
@@ -98,6 +103,7 @@ export class DrawingsManager {
);
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
@@ -127,10 +133,40 @@ export class DrawingsManager {
}
private handlePointerDown = (): void => {
+ this.pointerDownSnapshot = null;
+
+ queueMicrotask(() => {
+ const drawing = this.selectedDrawing$.value;
+
+ if (!drawing || drawing.isCreationPending()) {
+ return;
+ }
+
+ this.pointerDownSnapshot = this.createDrawingSnapshot(drawing);
+ });
+
this.DOM.refreshEntities();
};
private handlePointerUp = (): void => {
+ const previousSnapshot = this.pointerDownSnapshot;
+
+ this.pointerDownSnapshot = 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();
};
@@ -140,6 +176,106 @@ export class DrawingsManager {
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(),
+ zIndex: drawing.zIndex,
+ };
+ }
+
+ private updateDrawing(drawing: Drawing, update: () => void): void {
+ if (drawing.isCreationPending()) {
+ return;
+ }
+
+ const previousSnapshot = this.createDrawingSnapshot(drawing);
+
+ update();
+
+ this.pushDrawingChange(previousSnapshot, this.createDrawingSnapshot(drawing));
+ }
+
+ private pushDrawingChange(
+ previousSnapshot: DrawingSnapshotItem | null,
+ nextSnapshot: DrawingSnapshotItem | null,
+ ): void {
+ if (isEqual(previousSnapshot, nextSnapshot)) {
+ return;
+ }
+
+ const previous = cloneDeep(previousSnapshot);
+ const next = cloneDeep(nextSnapshot);
+
+ this.eventManager.getUndoRedo().pushCommand({
+ undo: () => {
+ this.applyDrawingChange(next, previous);
+ },
+ redo: () => {
+ this.applyDrawingChange(previous, next);
+ },
+ });
+ }
+
+ private applyDrawingChange(
+ currentSnapshot: DrawingSnapshotItem | null,
+ nextSnapshot: DrawingSnapshotItem | null,
+ ): void {
+ if (currentSnapshot && nextSnapshot && currentSnapshot.id === nextSnapshot.id) {
+ const drawing = this.findDrawing(nextSnapshot.id);
+
+ if (drawing) {
+ drawing.setState(cloneDeep(nextSnapshot.state));
+ drawing.setLocked(nextSnapshot.isLocked ?? false);
+ this.DOM.refreshEntities();
+
+ return;
+ }
+ }
+
+ if (currentSnapshot) {
+ this.removeDrawingInternal(currentSnapshot.id, false);
+ }
+
+ if (nextSnapshot) {
+ this.restoreDrawing(nextSnapshot);
+ }
+
+ this.activeTool$.next('crosshair');
+ }
+
+ private restoreDrawing(snapshot: DrawingSnapshotItem): Drawing {
+ const existingDrawing = this.findDrawing(snapshot.id);
+
+ if (existingDrawing) {
+ existingDrawing.setState(cloneDeep(snapshot.state));
+ existingDrawing.setLocked(snapshot.isLocked ?? false);
+
+ if (snapshot.zIndex !== undefined) {
+ existingDrawing.setZIndex(snapshot.zIndex);
+ }
+
+ this.drawings$.next([...this.drawings$.value].sort((left, right) => left.zIndex - right.zIndex));
+
+ this.DOM.refreshEntities();
+
+ return existingDrawing;
+ }
+
+ return this.createDrawing(snapshot.drawingName, {
+ id: snapshot.id,
+ state: cloneDeep(snapshot.state),
+ isLocked: snapshot.isLocked,
+ zIndex: snapshot.zIndex,
+ });
+ }
+
private updateActiveTool = (): void => {
const hasPendingDrawing = this.drawings$.value.some((drawing) => drawing.isCreationPending());
@@ -179,7 +315,7 @@ export class DrawingsManager {
return;
}
- this.createDrawing(currentTool);
+ this.addDrawingForce(currentTool);
});
return;
@@ -189,19 +325,32 @@ export class DrawingsManager {
};
private removeDrawing = (id: string): void => {
- const drawing = this.drawings$.value.find((item) => item.id === id);
+ const drawing = this.findDrawing(id);
if (!drawing) {
return;
}
- this.removeDrawings([drawing]);
+ if (drawing.isCreationPending()) {
+ this.removeDrawingInternal(id);
+
+ return;
+ }
+
+ const snapshot = this.createDrawingSnapshot(drawing);
+
+ this.removeDrawingInternal(id);
+ this.pushDrawingChange(snapshot, null);
};
- private removeDrawingsByName(name: DrawingsNames, shouldUpdateTool = true): void {
- const drawingsToRemove = this.drawings$.value.filter((drawing) => drawing.getDrawingName() === name);
+ private removeDrawingInternal(id: string, shouldUpdateTool = true): void {
+ const drawing = this.findDrawing(id);
- this.removeDrawings(drawingsToRemove, shouldUpdateTool);
+ if (!drawing) {
+ return;
+ }
+
+ this.removeDrawings([drawing], shouldUpdateTool);
}
private removePendingDrawings(shouldUpdateTool = true): void {
@@ -235,26 +384,45 @@ export class DrawingsManager {
this.DOM.refreshEntities();
}
- public addDrawingForce = (name: DrawingsNames): Promise<void> => {
+ public addDrawingForce = async (name: DrawingsNames): Promise<void> => {
this.removePendingDrawings(false);
- if (drawingsMap[name].singleInstance) {
- this.removeDrawingsByName(name, false);
+ const previousDrawing = drawingsMap[name].singleInstance
+ ? this.drawings$.value.find((drawing) => drawing.getDrawingName() === name)
+ : undefined;
+
+ const previousSnapshot = previousDrawing ? this.createDrawingSnapshot(previousDrawing) : null;
+
+ if (previousDrawing) {
+ this.removeDrawingInternal(previousDrawing.id, false);
}
this.activeTool$.next(name);
const drawing = this.createDrawing(name);
this.DOM.refreshEntities();
- return drawing.waitForCreation();
+ await drawing.waitForCreation();
+
+ if (!this.findDrawing(drawing.id)) {
+ if (previousSnapshot) {
+ this.restoreDrawing(previousSnapshot);
+ }
+
+ return;
+ }
+
+ this.pushDrawingChange(previousSnapshot, this.createDrawingSnapshot(drawing));
};
private createDrawing(name: DrawingsNames, options: CreateDrawingOptions = {}): Drawing {
- if (!this.mainSeries) {
+ const { mainSeries } = this;
+
+ if (!mainSeries) {
throw new Error('[Drawings] main series is not defined');
}
- const { id, state, isLocked = false, shouldUpdateDrawingsList = true } = options;
+ const { id, state, isLocked = false, zIndex, shouldUpdateDrawingsList = true } = options;
+
const shouldSelectAfterCreation = state === undefined;
if (shouldSelectAfterCreation && this.selectedDrawing$.value) {
@@ -296,15 +464,20 @@ export class DrawingsManager {
});
};
- const drawingFactory = (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) =>
+ const drawingFactory = (entityZIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) =>
new Drawing({
lwcChart: this.lwcChart,
- mainSeries: this.mainSeries as SeriesStrategies,
+ mainSeries,
id: drawingId,
drawingName: name,
name: drawingLabelById()[name],
onDelete: this.removeDrawing,
- zIndex,
+ onCopy: () => {
+ if (createdDrawing) {
+ this.copyPasteBuffer = this.createDrawingSnapshot(createdDrawing);
+ }
+ },
+ zIndex: entityZIndex,
moveDown,
moveUp,
construct,
@@ -327,23 +500,21 @@ export class DrawingsManager {
isLocked,
paneId: this.paneId,
hotkeys: this.hotkeys,
- setCopyPasteBuffer: (copyPasteBuffer) => {
- this.copyPasteBuffer = copyPasteBuffer;
- },
resetActiveTool: () => {
this.activeTool$.next('crosshair');
},
});
- const entity = this.DOM.setEntity<Drawing>(drawingFactory);
+ const entity = this.DOM.setEntity<Drawing>(drawingFactory, zIndex);
+
createdDrawing = entity;
if (state !== undefined) {
- entity.setState(state);
+ entity.setState(cloneDeep(state));
}
if (shouldUpdateDrawingsList) {
- this.drawings$.next([...this.drawings$.value, entity]);
+ this.drawings$.next([...this.drawings$.value, entity].sort((left, right) => left.zIndex - right.zIndex));
}
if (shouldSelectAfterCreation) {
@@ -364,12 +535,7 @@ export class DrawingsManager {
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(),
- }));
+ .map((drawing) => this.createDrawingSnapshot(drawing));
}
public setSnapshot(snapshot: DrawingsManagerSnapshot): void {
@@ -378,10 +544,12 @@ export class DrawingsManager {
}
if (!this.mainSeries) {
- this.pendingSnapshot = snapshot;
+ this.pendingSnapshot = cloneDeep(snapshot);
+
return;
}
+ this.pointerDownSnapshot = null;
this.removeDrawings(this.drawings$.value, false);
const restoredDrawings = snapshot.reduce<Drawing[]>((drawings, item) => {
@@ -392,8 +560,9 @@ export class DrawingsManager {
drawings.push(
this.createDrawing(item.drawingName, {
id: item.id,
- state: item.state,
+ state: cloneDeep(item.state),
isLocked: item.isLocked,
+ zIndex: item.zIndex,
shouldUpdateDrawingsList: false,
}),
);
@@ -401,7 +570,8 @@ export class DrawingsManager {
return drawings;
}, []);
- this.drawings$.next(restoredDrawings);
+ this.drawings$.next(restoredDrawings.sort((left, right) => left.zIndex - right.zIndex));
+
this.activeTool$.next('crosshair');
this.DOM.refreshEntities();
}
@@ -448,6 +618,18 @@ export class DrawingsManager {
return this.selectedDrawing$.asObservable();
}
+ public updateSelectedDrawingSettings = (settings: SettingsValues): void => {
+ const drawing = this.selectedDrawing$.value;
+
+ if (!drawing) {
+ return;
+ }
+
+ this.updateDrawing(drawing, () => {
+ drawing.updateSettings(settings);
+ });
+ };
+
public openSelectedDrawingSettings(): void {
const drawing = this.selectedDrawing$.value;
@@ -469,7 +651,15 @@ export class DrawingsManager {
}
public toggleSelectedDrawingLock(): void {
- this.selectedDrawing$.value?.toggleLock();
+ const drawing = this.selectedDrawing$.value;
+
+ if (!drawing) {
+ return;
+ }
+
+ this.updateDrawing(drawing, () => {
+ drawing.toggleLock();
+ });
}
private openSettings = (drawing: Drawing): void => {
@@ -493,7 +683,15 @@ export class DrawingsManager {
{
size: 'sm',
title: drawing.name,
- onSave: () => drawing.updateSettings(settings),
+ onSave: () => {
+ if (!this.findDrawing(drawing.id)) {
+ return;
+ }
+
+ this.updateDrawing(drawing, () => {
+ drawing.updateSettings(settings);
+ });
+ },
},
);
};
@@ -518,11 +716,15 @@ export class DrawingsManager {
});
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.pointerDownSnapshot = null;
+ this.copyPasteBuffer = null;
+
this.subscriptions.unsubscribe();
this.drawings$.complete();
this.selectedDrawing$.complete();
diff --git a/src/core/Hotkeys.ts b/src/core/Hotkeys.ts
index 7231ede..4bd0338 100644
--- a/src/core/Hotkeys.ts
+++ b/src/core/Hotkeys.ts
@@ -2,9 +2,11 @@ export enum Keys {
'escape' = 'escape',
'delete' = 'delete',
'tab' = 'tab',
- 'shift' = 'shiftleft',
- 'control' = 'controlleft',
- 'alt' = 'altleft',
+ 'shift' = 'shift',
+ 'control' = 'control',
+ 'meta' = 'meta',
+ 'mod' = 'mod',
+ 'alt' = 'alt',
't' = 'keyt',
'h' = 'keyh',
'v' = 'keyv',
@@ -14,161 +16,141 @@ export enum Keys {
'mousedown' = 'mousedown',
}
-export interface IHotkeys {
- register: ({ keys, callback }: { keys: Keys[]; callback: () => void }) => void;
+type HotkeyCallback = () => void | Promise<void>;
+
+interface RegisterHotkeyParams {
+ keys: Keys[];
+ callback: HotkeyCallback;
}
-type Tree = {
- children: Map<Keys, Tree>;
- callbacks: Map<string, () => void> | null;
- pressHoldRequired?: boolean;
-};
+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 tree: Tree = { children: new Map(), callbacks: null, pressHoldRequired: false };
-
- private buffer: Keys[] = [];
- private prevKey: null | Keys = null;
+ private registrations = new Map<string, HotkeyRegistration>();
constructor() {
- document.addEventListener('keydown', this.handleKeyDown.bind(this));
- document.addEventListener('keyup', this.handleKeyUp.bind(this));
- }
-
- public unregister({ keys, hash }: { keys: Keys[]; hash?: string | null }) {
- if (!hash) {
- return;
- }
- let node = this.tree;
-
- keys.forEach((key: Keys) => {
- const child = node.children.get(key);
- if (child) {
- node = child;
- } else {
- throw new Error('[Hotkeys]: ошибка при отписке от хоткея');
- }
- });
-
- if (!hash) {
- node.callbacks = null;
- }
-
- node.callbacks?.delete(hash);
-
- if (node.callbacks?.size === 0) {
- node.callbacks = null;
- }
+ document.addEventListener('keydown', this.handleKeyDown);
}
- public register({
- keys,
- callback,
- pressHoldRequired = false,
- }: {
- keys: Keys[];
- callback: () => void;
- pressHoldRequired?: boolean;
- }): string | null {
+ public register({ keys, callback }: RegisterHotkeyParams): string | null {
if (keys.length === 0) {
console.error('[Hotkeys] попытка задать пустой хоткей');
+
return null;
}
- let node = this.tree;
-
- keys.forEach((key: Keys) => {
- if (!node.children.has(key)) {
- node.children.set(key, { children: new Map(), callbacks: null, pressHoldRequired });
- }
+ const hash = `hotkeyCallback-${crypto.randomUUID()}`;
- node = node.children.get(key) as Tree;
+ this.registrations.set(hash, {
+ keys,
+ callback,
});
- if (node.callbacks === null || node.callbacks === undefined) {
- node.callbacks = new Map();
- }
-
- const hash = `hotkeyCallback-${crypto.randomUUID()}`;
- node.callbacks?.set(hash, callback);
return hash;
}
- private findNode(keys: Keys[]): Tree | null {
- let node = this.tree;
-
- for (const key of keys) {
- const k = normalize(key);
- const child = node.children.get(k);
- if (child) {
- node = child;
- } else {
- return null;
- }
+ public unregister({ hash }: UnregisterHotkeyParams): void {
+ if (!hash) {
+ return;
}
- return node;
+
+ this.registrations.delete(hash);
}
- private handleKeyUp = async () => {
- const node = this.findNode(this.buffer);
- if (!node) {
- this.buffer = [];
- this.prevKey = null;
- return;
- }
+ public destroy(): void {
+ document.removeEventListener('keydown', this.handleKeyDown);
+ this.registrations.clear();
+ }
- if (node.callbacks && node.pressHoldRequired) {
- await this.handleKeyDown.bind(this)({ code: 'Escape' } as KeyboardEvent);
+ private handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
+ if (event.repeat || isEditableElement(event.target)) {
+ return;
}
- this.buffer = [];
- this.prevKey = null;
- };
- private handleKeyDown = async (event: KeyboardEvent) => {
- const key = normalize(event.code);
+ const callbacks = [...this.registrations.values()]
+ .filter(({ keys }) => matchesHotkey(event, keys))
+ .map(({ callback }) => callback)
+ .reverse();
- if (key === this.prevKey) return;
+ if (callbacks.length === 0) {
+ return;
+ }
- this.prevKey = key;
+ event.preventDefault();
- if (key === Keys.escape) {
- this.buffer = [];
+ for (const callback of callbacks) {
+ // eslint-disable-next-line no-await-in-loop
+ await callback();
}
+ };
+}
- this.buffer.push(key);
+function matchesHotkey(event: KeyboardEvent, keys: Keys[]): boolean {
+ const eventKey = normalizeCode(event.code);
+ const primaryKeys = keys.filter((key) => !isModifier(key));
- const node = this.findNode(this.buffer);
+ if (primaryKeys.length !== 1 || primaryKeys[0] !== eventKey) {
+ return false;
+ }
- if (!node) {
- this.buffer = [];
- this.prevKey = null;
- return;
+ 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;
+ }
- if (node.callbacks && node.children.size <= 0) {
- event.preventDefault?.();
- const callbacks = [];
+ return event.shiftKey === keys.includes(Keys.shift) && event.altKey === keys.includes(Keys.alt);
+}
- for (const [_, cb] of node.callbacks) {
- callbacks.push(cb);
- }
+function isModifier(key: Keys): boolean {
+ return key === Keys.mod || key === Keys.control || key === Keys.meta || key === Keys.shift || key === Keys.alt;
+}
- const reversedCallbacks = [...callbacks].reverse();
+function normalizeCode(code: string): Keys {
+ switch (code.toLowerCase()) {
+ case 'controlleft':
+ case 'controlright':
+ return Keys.control;
- for (const cb of reversedCallbacks) {
- // eslint-disable-next-line no-await-in-loop
- await cb();
- }
+ case 'metaleft':
+ case 'metaright':
+ return Keys.meta;
- this.buffer = [];
- this.prevKey = null;
- }
- };
+ case 'shiftleft':
+ case 'shiftright':
+ return Keys.shift;
+
+ case 'altleft':
+ case 'altright':
+ return Keys.alt;
+
+ default:
+ return code.toLowerCase() as Keys;
+ }
}
-function normalize(key: string): Keys {
- return key.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;
}
diff --git a/src/core/MoexChart.tsx b/src/core/MoexChart.tsx
index 2f46df1..350012a 100644
--- a/src/core/MoexChart.tsx
+++ b/src/core/MoexChart.tsx
@@ -5,7 +5,7 @@ import { Footer } from '@components/Footer';
import { Header } from '@components/Header';
import { DataSource, DataSourceParams } from '@core/DataSource';
-import { Hotkeys } from '@core/Hotkeys';
+import { Hotkeys, Keys } from '@core/Hotkeys';
import { ModalRenderer } from '@core/ModalRenderer';
import { FloatingDrawingToolbar } from '@src/components/FloatingToolbar';
import { SettingsModal } from '@src/components/SettingsModal';
@@ -181,6 +181,20 @@ export class MoexChart implements ISerializable<MoexChartSnapshot> {
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.modalRenderer = new ModalRenderer(modalContainer);
this.chart = new Chart({
@@ -291,6 +305,7 @@ export class MoexChart implements ISerializable<MoexChartSnapshot> {
this.drawingToolbarRenderer.renderComponent(
<FloatingDrawingToolbar
selectedDrawing$={drawingsManager.selectedDrawing()}
+ onUpdateSettings={drawingsManager.updateSelectedDrawingSettings}
onToggleLock={() => drawingsManager.toggleSelectedDrawingLock()}
onOpenSettings={() => drawingsManager.openSelectedDrawingSettings()}
onDelete={() => drawingsManager.deleteSelectedDrawing()}
diff --git a/src/core/UndoRedo.ts b/src/core/UndoRedo.ts
index 25bab25..85ec5cf 100644
--- a/src/core/UndoRedo.ts
+++ b/src/core/UndoRedo.ts
@@ -1,8 +1,15 @@
+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;
@@ -19,110 +26,169 @@ interface HistoryItem {
next: unknown;
}
+interface HistoryCommand {
+ kind: 'command';
+ undo: () => void;
+ redo: () => void;
+}
+
interface HistoryGroup {
kind: 'group';
entries: HistoryEntry[];
}
-type HistoryEntry = HistoryItem | HistoryGroup;
+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 {
+ 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 group<T>(callback: () => T): T {
this.groupStack.push([]);
+
+ try {
+ return callback();
+ } finally {
+ this.endGroup();
+ }
}
- private endGroup(): void {
- const entries = this.groupStack.pop();
- if (!entries || entries.length === 0) return;
+ public undo = (): void => {
+ const entry = this.undoStack.pop();
- const group: HistoryGroup = { kind: 'group', entries };
+ if (!entry) {
+ return;
+ }
+
+ this.applyEntry(entry, 'undo');
+ this.redoStack.push(entry);
+ this.updateState();
+ };
- const parent = this.groupStack[this.groupStack.length - 1];
- if (parent) {
- parent.push(group);
+ public redo = (): void => {
+ const entry = this.redoStack.pop();
+
+ if (!entry) {
return;
}
- this.undoStack.push(group);
- this.redoStack = [];
+ this.applyEntry(entry, 'redo');
+ this.undoStack.push(entry);
+ this.updateState();
+ };
+
+ public canUndo(): boolean {
+ return this.undoStack.length > 0;
}
- public group<T>(fn: () => T): T {
- this.beginGroup();
- try {
- return fn();
- } finally {
- this.endGroup();
- }
+ public canRedo(): boolean {
+ return this.redoStack.length > 0;
}
- public push(key: UndoKey, prev: unknown, next: unknown): void {
- if (Object.is(prev, next)) return;
+ public getState(): Observable<UndoRedoState> {
+ return this.state$.asObservable();
+ }
- const item: HistoryItem = { kind: 'item', key, prev, next };
+ public clear(): void {
+ this.undoStack = [];
+ this.redoStack = [];
+ this.groupStack = [];
+
+ this.updateState();
+ }
+
+ private endGroup(): void {
+ const entries = this.groupStack.pop();
+
+ if (!entries?.length) {
+ return;
+ }
+ this.addEntry({
+ kind: 'group',
+ entries,
+ });
+ }
+
+ private addEntry(entry: HistoryEntry): void {
if (this.redoStack.length > 0) {
this.redoStack = [];
}
const currentGroup = this.groupStack[this.groupStack.length - 1];
+
if (currentGroup) {
- currentGroup.push(item);
+ currentGroup.push(entry);
+
return;
}
- this.undoStack.push(item);
+ this.undoStack.push(entry);
+ this.updateState();
}
- public undo(): void {
- const entry = this.undoStack.pop();
- if (!entry) return;
+ private applyEntry(entry: HistoryEntry, direction: 'undo' | 'redo'): void {
+ if (entry.kind === 'group') {
+ const entries = direction === 'undo' ? [...entry.entries].reverse() : entry.entries;
- this.applyEntry(entry, 'undo');
- this.redoStack.push(entry);
- }
+ entries.forEach((groupEntry) => {
+ this.applyEntry(groupEntry, direction);
+ });
- public redo(): void {
- const entry = this.redoStack.pop();
- if (!entry) return;
+ return;
+ }
- this.applyEntry(entry, 'redo');
- this.undoStack.push(entry);
- }
+ if (entry.kind === 'command') {
+ entry[direction]();
- private applyEntry(entry: HistoryEntry, direction: 'undo' | 'redo'): void {
- if (entry.kind === 'group') {
- const list = entry.entries;
- if (direction === 'undo') {
- for (let i = list.length - 1; i >= 0; i -= 1) this.applyEntry(list[i], direction);
- } else {
- for (let i = 0; i < list.length; i += 1) this.applyEntry(list[i], direction);
- }
return;
}
- const apply = this.config[entry.key] as (v: unknown) => void;
+ const apply = this.config[entry.key] as (value: unknown) => void;
+
apply(direction === 'undo' ? entry.prev : entry.next);
}
- public canUndo(): boolean {
- return this.undoStack.length > 0;
- }
+ private updateState(): void {
+ const nextState: UndoRedoState = {
+ canUndo: this.undoStack.length > 0,
+ canRedo: this.redoStack.length > 0,
+ };
- public canRedo(): boolean {
- return this.redoStack.length > 0;
- }
+ const currentState = this.state$.value;
- public clear(): void {
- this.undoStack = [];
- this.redoStack = [];
- this.groupStack = [];
+ if (currentState.canUndo === nextState.canUndo && currentState.canRedo === nextState.canRedo) {
+ return;
+ }
+
+ this.state$.next(nextState);
}
}