Загрузка данных
export type SettingValue = string | number | boolean | any;
export type SettingsValues = Record<string, SettingValue>;
interface BaseSettingField {
key: string;
label: string;
}
export interface NumberSettingField extends BaseSettingField {
type: 'number';
defaultValue: number;
min?: number;
max?: number;
}
export interface RangeSettingField extends BaseSettingField {
type: 'range';
defaultValue: number;
min?: number;
max?: number;
step?: number;
color?: string;
suffix?: string;
}
export interface SelectSettingField extends BaseSettingField {
type: 'select';
defaultValue: string | number;
options: { label: string; value: string | number }[];
}
export interface ColorSettingField extends BaseSettingField {
type: 'color';
defaultValue: string;
}
export interface TextSettingField extends BaseSettingField {
type: 'text';
defaultValue: string;
placeholder?: string;
}
export interface TextAreaSettingField extends BaseSettingField {
type: 'textarea';
defaultValue: string;
placeholder?: string;
}
export interface BooleanSettingField extends BaseSettingField {
type: 'boolean';
defaultValue: boolean;
}
export type SettingField =
| NumberSettingField
| RangeSettingField
| SelectSettingField
| ColorSettingField
| TextSettingField
| TextAreaSettingField
| BooleanSettingField;
export interface SettingsTab<TField extends SettingField = SettingField> {
key: string;
label: string;
fields: TField[];
}
import {
AutoscaleInfo,
CrosshairMode,
IChartApi,
IPrimitivePaneView,
ISeriesApi,
ISeriesPrimitive,
ISeriesPrimitiveAxisView,
Logical,
PrimitiveHoveredItem,
SeriesAttachedParameter,
SeriesOptionsMap,
SeriesType,
Time,
} from 'lightweight-charts';
import { BehaviorSubject, distinctUntilChanged, Subscription } from 'rxjs';
import { getPointerPoint as getPointerPointFromEvent } from '@core/Drawings/helpers';
import { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import { SettingsTab, SettingsValues } from '@src/types';
export interface ISeriesDrawing extends ISeriesPrimitive<Time> {
show(): void;
hide(): void;
rebind(series: ISeriesApi<SeriesType>): void;
destroy(): void;
isCreationPending(): boolean;
waitTillReady(): Promise<void>;
shouldShowInObjectTree(): boolean;
getState(): unknown;
setState(state: unknown): void;
getSettings(): SettingsValues;
updateSettings(settings: SettingsValues): void;
getSettingsTabs(): SettingsTab[];
subscribeIsSelected(callback: (isSelected: boolean) => void): Subscription;
subscribeIsLocked(callback: (isLocked: boolean) => void): Subscription;
isSelected(): boolean;
isLocked(): boolean;
setLocked(isLocked: boolean): void;
getRenderData(): unknown;
}
interface SeriesDrawingBaseParams {
container: HTMLElement;
chart: IChartApi;
series: SeriesApi;
}
export abstract class SeriesDrawingBase<TSettings extends SettingsValues = SettingsValues> implements ISeriesDrawing {
protected hidden = false;
protected chart: IChartApi;
protected series: SeriesApi;
protected subscriptions = new Subscription();
protected abstract mode: unknown; // todo: хочется иметь единый mode
protected abstract settings: TSettings;
protected readonly container: HTMLElement;
protected isActive = new BehaviorSubject(false);
protected isBound = false;
private readonly isLockedSubject = new BehaviorSubject(false);
protected readyPromise: Promise<void> | null = null;
protected resolveReady: (() => void) | null = null;
protected requestUpdate: (() => void) | null = null;
constructor({ chart, series, container }: SeriesDrawingBaseParams) {
this.chart = chart;
this.series = series;
this.container = container;
}
public subscribeIsSelected(callback: (isSelected: boolean) => void): Subscription {
return this.isActive.pipe(distinctUntilChanged()).subscribe(callback);
}
public subscribeIsLocked(callback: (isLocked: boolean) => void): Subscription {
return this.isLockedSubject.pipe(distinctUntilChanged()).subscribe(callback);
}
public isSelected(): boolean {
return this.isActive.value;
}
public isLocked(): boolean {
return this.isLockedSubject.value;
}
public setLocked(isLocked: boolean): void {
if (this.isLockedSubject.value === isLocked) {
return;
}
this.isLockedSubject.next(isLocked);
if (isLocked) {
this.showCrosshair();
}
this.render();
}
public show(): void {
this.hidden = false;
this.render();
}
public hide(): void {
this.hidden = true;
this.showCrosshair();
this.render();
}
public rebind(series: SeriesApi): void {
if (this.series === series) {
return;
}
this.showCrosshair();
this.unbindEvents();
this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.series = series;
this.requestUpdate = null;
this.series.attachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.render();
}
public destroy(): void {
this.showCrosshair();
this.unbindEvents();
this.subscriptions.unsubscribe();
this.isLockedSubject.complete();
this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.requestUpdate = null;
this.resolveReady?.();
}
public waitTillReady(): Promise<void> {
if (this.mode === 'ready') {
return Promise.resolve();
}
if (!this.readyPromise) {
this.readyPromise = new Promise((resolve) => {
this.resolveReady = resolve;
});
}
return this.readyPromise;
}
public shouldShowInObjectTree(): boolean {
return this.mode !== 'idle';
}
public getSettings(): SettingsValues {
return { ...this.settings };
}
public updateSettings(settings: SettingsValues): void {
this.settings = {
...this.settings,
...settings,
};
this.render();
}
public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
this.requestUpdate = param.requestUpdate;
this.bindEvents();
}
public detached(): void {
this.showCrosshair();
this.unbindEvents();
this.requestUpdate = null;
}
public autoscaleInfo(_start: Logical, _end: Logical): AutoscaleInfo | null {
return null;
}
public hitTest(x: number, y: number): PrimitiveHoveredItem | null {
const hoveredItem = this.getHoveredItem(x, y);
if (!hoveredItem || !this.isLocked()) {
return hoveredItem;
}
return {
...hoveredItem,
cursorStyle: 'pointer',
};
}
public abstract getRenderData(): unknown;
public abstract getState(): unknown;
public abstract getSettingsTabs(): SettingsTab[];
public abstract isCreationPending(): boolean;
public abstract setState(state: unknown): void;
public abstract updateAllViews(): void;
public abstract paneViews(): readonly IPrimitivePaneView[];
public abstract priceAxisPaneViews(): readonly IPrimitivePaneView[];
public abstract priceAxisViews(): readonly ISeriesPrimitiveAxisView[];
public abstract timeAxisPaneViews(): readonly IPrimitivePaneView[];
public abstract timeAxisViews(): readonly ISeriesPrimitiveAxisView[];
protected shouldShowHandles(): boolean {
return this.isActive.value && !this.isLocked();
}
protected render(): void {
this.updateAllViews();
this.requestUpdate?.();
}
protected hideCrosshair(): void {
this.chart.applyOptions({
crosshair: {
mode: CrosshairMode.Hidden,
},
});
}
protected showCrosshair(): void {
this.chart.applyOptions({
crosshair: {
mode: CrosshairMode.Normal,
},
});
}
protected getEventPoint(event: PointerEvent): Point {
return getPointerPointFromEvent(this.container, event);
}
protected bindEvents(): void {
if (this.isBound) {
return;
}
this.isBound = true;
this.container.addEventListener('dblclick', this.handleDoubleClick);
this.container.addEventListener('pointerdown', this.handlePointerDownEvent);
this.container.addEventListener('contextmenu', this.handleContextMenu);
window.addEventListener('pointermove', this.handlePointerMove);
window.addEventListener('pointerup', this.handlePointerUp);
window.addEventListener('pointercancel', this.handlePointerUp);
}
protected unbindEvents(): void {
if (!this.isBound) {
return;
}
this.isBound = false;
this.container.removeEventListener('dblclick', this.handleDoubleClick);
this.container.removeEventListener('pointerdown', this.handlePointerDownEvent);
this.container.removeEventListener('contextmenu', this.handleContextMenu);
window.removeEventListener('pointermove', this.handlePointerMove);
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointercancel', this.handlePointerUp);
}
protected handleContextMenu(event: MouseEvent): void {}
protected handleDoubleClick(event: MouseEvent): void {}
protected handlePointerDown(event: PointerEvent): void {}
protected handlePointerMove(event: PointerEvent): void {}
protected handlePointerUp(event: PointerEvent): void {}
protected abstract getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null;
protected abstract getGeometry(): unknown; // todo: make proper type
protected abstract getTimeAxisSegments(): AxisSegment[];
protected abstract getPriceAxisSegments(): AxisSegment[];
protected abstract getTimeAxisLabel(kind: string): AxisLabel | null;
protected abstract getPriceAxisLabel(kind: string): AxisLabel | null;
private handlePointerDownEvent = (event: PointerEvent): void => {
if (!this.isLocked() || this.isCreationPending() || event.button !== 0) {
this.handlePointerDown(event);
return;
}
const point = this.getEventPoint(event);
const isDrawingHit = this.getHoveredItem(point.x, point.y) !== null;
if (this.isActive.value === isDrawingHit) {
return;
}
this.isActive.next(isDrawingHit);
this.render();
};
}
import {
AutoscaleInfo,
CrosshairMode,
IChartApi,
IPrimitivePaneView,
ISeriesApi,
ISeriesPrimitive,
ISeriesPrimitiveAxisView,
Logical,
PrimitiveHoveredItem,
SeriesAttachedParameter,
SeriesOptionsMap,
SeriesType,
Time,
} from 'lightweight-charts';
import { BehaviorSubject, distinctUntilChanged, Subscription } from 'rxjs';
import { getPointerPoint as getPointerPointFromEvent } from '@core/Drawings/helpers';
import { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import { SettingsTab, SettingsValues } from '@src/types';
export interface ISeriesDrawing extends ISeriesPrimitive<Time> {
show(): void;
hide(): void;
rebind(series: ISeriesApi<SeriesType>): void;
destroy(): void;
isCreationPending(): boolean;
waitTillReady(): Promise<void>;
shouldShowInObjectTree(): boolean;
getState(): unknown;
setState(state: unknown): void;
getSettings(): SettingsValues;
updateSettings(settings: SettingsValues): void;
getSettingsTabs(): SettingsTab[];
subscribeIsSelected(callback: (isSelected: boolean) => void): Subscription;
subscribeIsLocked(callback: (isLocked: boolean) => void): Subscription;
isSelected(): boolean;
isLocked(): boolean;
setLocked(isLocked: boolean): void;
getRenderData(): unknown;
}
interface SeriesDrawingBaseParams {
container: HTMLElement;
chart: IChartApi;
series: SeriesApi;
}
export abstract class SeriesDrawingBase<TSettings extends SettingsValues = SettingsValues> implements ISeriesDrawing {
protected hidden = false;
protected chart: IChartApi;
protected series: SeriesApi;
protected subscriptions = new Subscription();
protected abstract mode: unknown; // todo: хочется иметь единый mode
protected abstract settings: TSettings;
protected readonly container: HTMLElement;
protected isActive = new BehaviorSubject(false);
protected isBound = false;
private readonly isLockedSubject = new BehaviorSubject(false);
protected readyPromise: Promise<void> | null = null;
protected resolveReady: (() => void) | null = null;
protected requestUpdate: (() => void) | null = null;
constructor({ chart, series, container }: SeriesDrawingBaseParams) {
this.chart = chart;
this.series = series;
this.container = container;
}
public subscribeIsSelected(callback: (isSelected: boolean) => void): Subscription {
return this.isActive.pipe(distinctUntilChanged()).subscribe(callback);
}
public subscribeIsLocked(callback: (isLocked: boolean) => void): Subscription {
return this.isLockedSubject.pipe(distinctUntilChanged()).subscribe(callback);
}
public isSelected(): boolean {
return this.isActive.value;
}
public isLocked(): boolean {
return this.isLockedSubject.value;
}
public setLocked(isLocked: boolean): void {
if (this.isLockedSubject.value === isLocked) {
return;
}
this.isLockedSubject.next(isLocked);
if (isLocked) {
this.showCrosshair();
}
this.render();
}
public show(): void {
this.hidden = false;
this.render();
}
public hide(): void {
this.hidden = true;
this.showCrosshair();
this.render();
}
public rebind(series: SeriesApi): void {
if (this.series === series) {
return;
}
this.showCrosshair();
this.unbindEvents();
this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.series = series;
this.requestUpdate = null;
this.series.attachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.render();
}
public destroy(): void {
this.showCrosshair();
this.unbindEvents();
this.subscriptions.unsubscribe();
this.isLockedSubject.complete();
this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.requestUpdate = null;
this.resolveReady?.();
}
public waitTillReady(): Promise<void> {
if (this.mode === 'ready') {
return Promise.resolve();
}
if (!this.readyPromise) {
this.readyPromise = new Promise((resolve) => {
this.resolveReady = resolve;
});
}
return this.readyPromise;
}
public shouldShowInObjectTree(): boolean {
return this.mode !== 'idle';
}
public getSettings(): SettingsValues {
return { ...this.settings };
}
public updateSettings(settings: SettingsValues): void {
this.settings = {
...this.settings,
...settings,
};
this.render();
}
public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
this.requestUpdate = param.requestUpdate;
this.bindEvents();
}
public detached(): void {
this.showCrosshair();
this.unbindEvents();
this.requestUpdate = null;
}
public autoscaleInfo(_start: Logical, _end: Logical): AutoscaleInfo | null {
return null;
}
public hitTest(x: number, y: number): PrimitiveHoveredItem | null {
const hoveredItem = this.getHoveredItem(x, y);
if (!hoveredItem || !this.isLocked()) {
return hoveredItem;
}
return {
...hoveredItem,
cursorStyle: 'pointer',
};
}
public abstract getRenderData(): unknown;
public abstract getState(): unknown;
public abstract getSettingsTabs(): SettingsTab[];
public abstract isCreationPending(): boolean;
public abstract setState(state: unknown): void;
public abstract updateAllViews(): void;
public abstract paneViews(): readonly IPrimitivePaneView[];
public abstract priceAxisPaneViews(): readonly IPrimitivePaneView[];
public abstract priceAxisViews(): readonly ISeriesPrimitiveAxisView[];
public abstract timeAxisPaneViews(): readonly IPrimitivePaneView[];
public abstract timeAxisViews(): readonly ISeriesPrimitiveAxisView[];
protected shouldShowHandles(): boolean {
return this.isActive.value && !this.isLocked();
}
protected render(): void {
this.updateAllViews();
this.requestUpdate?.();
}
protected hideCrosshair(): void {
this.chart.applyOptions({
crosshair: {
mode: CrosshairMode.Hidden,
},
});
}
protected showCrosshair(): void {
this.chart.applyOptions({
crosshair: {
mode: CrosshairMode.Normal,
},
});
}
protected getEventPoint(event: PointerEvent): Point {
return getPointerPointFromEvent(this.container, event);
}
protected bindEvents(): void {
if (this.isBound) {
return;
}
this.isBound = true;
this.container.addEventListener('dblclick', this.handleDoubleClick);
this.container.addEventListener('pointerdown', this.handlePointerDownEvent);
this.container.addEventListener('contextmenu', this.handleContextMenu);
window.addEventListener('pointermove', this.handlePointerMove);
window.addEventListener('pointerup', this.handlePointerUp);
window.addEventListener('pointercancel', this.handlePointerUp);
}
protected unbindEvents(): void {
if (!this.isBound) {
return;
}
this.isBound = false;
this.container.removeEventListener('dblclick', this.handleDoubleClick);
this.container.removeEventListener('pointerdown', this.handlePointerDownEvent);
this.container.removeEventListener('contextmenu', this.handleContextMenu);
window.removeEventListener('pointermove', this.handlePointerMove);
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointercancel', this.handlePointerUp);
}
protected handleContextMenu(event: MouseEvent): void {}
protected handleDoubleClick(event: MouseEvent): void {}
protected handlePointerDown(event: PointerEvent): void {}
protected handlePointerMove(event: PointerEvent): void {}
protected handlePointerUp(event: PointerEvent): void {}
protected abstract getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null;
protected abstract getGeometry(): unknown; // todo: make proper type
protected abstract getTimeAxisSegments(): AxisSegment[];
protected abstract getPriceAxisSegments(): AxisSegment[];
protected abstract getTimeAxisLabel(kind: string): AxisLabel | null;
protected abstract getPriceAxisLabel(kind: string): AxisLabel | null;
private handlePointerDownEvent = (event: PointerEvent): void => {
if (!this.isLocked() || this.isCreationPending() || event.button !== 0) {
this.handlePointerDown(event);
return;
}
const point = this.getEventPoint(event);
const isDrawingHit = this.getHoveredItem(point.x, point.y) !== null;
if (this.isActive.value === isDrawingHit) {
return;
}
this.isActive.next(isDrawingHit);
this.render();
};
}
import classNames from 'classnames';
import { Divider } from 'exchange-elements/v2';
import { ChangeEvent } from 'react';
import { RangeField } from '@src/components/FormFields';
import { t } from '@src/translations';
import { normalizeColor } from '@src/utils';
import { COLOR_PALETTE } from './constants';
import styles from './index.module.scss';
interface ColorPickerProps {
value: string;
onChange: (value: string) => void;
}
export function normalizeHexColor(value: string): string {
const normalized = value.trim().startsWith('#') ? normalizeColor(value) : `#${normalizeColor(value)}`;
if (/^#[0-9a-f]{6}$/i.test(normalized)) {
return `${normalized}ff`;
}
if (/^#[0-9a-f]{8}$/i.test(normalized)) {
return normalized;
}
return '#000000ff';
}
export function ColorPicker({ value, onChange }: ColorPickerProps) {
const currentColor = normalizeHexColor(value);
const rgb = currentColor.slice(0, 7).toLowerCase();
const alpha = currentColor.slice(7, 9);
const opacity = Math.round((parseInt(alpha, 16) / 255) * 100);
const handleColorChange = (color: string): void => {
onChange(`${color}${alpha}`);
};
const handleOpacityChange = (event: ChangeEvent<HTMLInputElement>): void => {
const nextOpacity = Number(event.target.value);
const nextAlpha = Math.round((nextOpacity / 100) * 255)
.toString(16)
.padStart(2, '0');
onChange(`${rgb}${nextAlpha}`);
};
return (
<div className={styles.picker}>
<div className={styles.picker_pallete}>
{COLOR_PALETTE.flatMap((row) =>
row.map((color) => {
const isSelected = color.toLowerCase() === rgb;
return (
<button
key={color}
type="button"
className={classNames(styles.picker_option, {
[styles.selected]: isSelected,
})}
style={{ backgroundColor: color }}
onClick={() => handleColorChange(color)}
/>
);
}),
)}
</div>
<Divider
pt={{ divider: { className: styles.picker_divider } }}
direction="horizontal"
/>
<RangeField
label={t('Opacity')}
value={opacity}
color={rgb}
suffix="%"
size="sm"
onChange={handleOpacityChange}
/>
</div>
);
}
import { ColorPicker, normalizeHexColor } from '@src/components/ColorPicker';
import { Dropdown } from '@src/components/Dropdown';
import styles from './index.module.scss';
interface ColorFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
}
export function ColorField({ label, value, onChange }: ColorFieldProps) {
const currentColor = normalizeHexColor(value);
return (
<div className={styles.color}>
<span className={styles.color_label}>{label}</span>
<Dropdown
selectedValue={currentColor}
position="bottom"
horizontalAlign="right"
menuClassName={styles.color_menu}
renderTrigger={({ onToggle }) => (
<button
type="button"
className={styles.color_trigger}
onClick={onToggle}
>
<span
className={styles.color_preview}
style={{ backgroundColor: currentColor }}
/>
</button>
)}
>
<ColorPicker
value={currentColor}
onChange={onChange}
/>
</Dropdown>
</div>
);
}
import { Button } from 'exchange-elements/v2';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Portal } from '@src/components/Portal';
import styles from './index.module.scss';
import { getDropdownCoords } from './utils';
import type { Coords, DropdownProps } from './types';
export function Dropdown({
children,
selectedValue,
position = 'bottom',
horizontalAlign = 'auto',
className = '',
buttonClassName = '',
menuClassName = '',
anchorRef,
renderTrigger,
}: DropdownProps) {
const [isOpenMenu, setIsOpenMenu] = useState(false);
const [coords, setCoords] = useState<Coords | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const handleToggle = (): void => {
if (isOpenMenu) {
handleClose();
return;
}
setIsOpenMenu(true);
};
const handleClose = (): void => {
setIsOpenMenu(false);
setCoords(null);
};
useLayoutEffect(() => {
if (!isOpenMenu) {
return;
}
const updatePosition = (): void => {
const triggerElement = triggerRef.current;
const menuElement = menuRef.current;
if (!triggerElement || !menuElement) {
return;
}
const triggerRect = triggerElement.getBoundingClientRect();
const anchorRect = anchorRef?.current?.getBoundingClientRect() ?? triggerRect;
const menuRect = menuElement.getBoundingClientRect();
setCoords(
getDropdownCoords({
position,
horizontalAlign,
triggerRect,
anchorRect,
menuRect,
}),
);
};
updatePosition();
const resizeObserver = new ResizeObserver(updatePosition);
if (menuRef.current) {
resizeObserver.observe(menuRef.current);
}
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
resizeObserver.disconnect();
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [anchorRef, isOpenMenu, position]);
useEffect(() => {
if (!isOpenMenu) {
return;
}
const handlePointerDown = (event: PointerEvent): void => {
const target = event.target as Node;
const isInsideRoot = rootRef.current?.contains(target);
const isInsideMenu = menuRef.current?.contains(target);
if (!isInsideRoot && !isInsideMenu) {
handleClose();
}
};
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
handleClose();
}
};
document.addEventListener('pointerdown', handlePointerDown, true);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('pointerdown', handlePointerDown, true);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpenMenu]);
return (
<div
ref={rootRef}
className={`${styles.dropdown} ${className}`}
>
<div ref={triggerRef}>
{renderTrigger ? (
renderTrigger({
isOpen: isOpenMenu,
onToggle: handleToggle,
onClose: handleClose,
})
) : (
<Button
size="sm"
onClick={handleToggle}
className={`${styles.button} ${isOpenMenu ? styles.pressed : ''} ${buttonClassName}`}
label={selectedValue}
/>
)}
</div>
{isOpenMenu && (
<Portal>
<div
ref={menuRef}
className={`${styles.menu} ${menuClassName}`}
style={{
top: coords?.top,
left: coords?.left,
visibility: coords ? 'visible' : 'hidden',
}}
>
{children}
</div>
</Portal>
)}
</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)),
};
}