import { ChangeDetectionStrategy, Component, computed, effect, inject, Injectable, Injector, OnInit, signal, untracked, viewChild, } from '@angular/core'; import { EFMarkerType, FCanvasChangeEvent, FCanvasComponent, FCreateConnectionEvent, FFlowComponent, FFlowModule, FMoveNodesEvent, FReassignConnectionEvent, FSelectionChangeEvent, } from '@foblex/flow'; import { IPoint } from '@foblex/2d'; import { Mutator } from '@foblex/mutator'; import { generateGuid } from '@foblex/utils'; interface INode { id: string; position: IPoint; text: string; } interface IConnection { id: string; source: string; target: string; } interface IState { nodes: Record; connections: Record; selection?: { nodes: string[]; connections: string[]; }; transform?: { position: IPoint; scale: number; }; } @Injectable() class FlowState extends Mutator {} const DEFAULT_STATE: IState = { nodes: { ['node1']: { id: 'node1', position: { x: 0, y: 200 }, text: 'Node 1', }, ['node2']: { id: 'node2', position: { x: 200, y: 200 }, text: 'Node 2', }, }, connections: { ['connection1']: { id: 'connection1', source: 'node1-output-0', target: 'node2-input-1', }, }, transform: { position: { x: 0, y: 0 }, scale: 1, }, }; @Component({ selector: 'undo-redo-v2', styleUrls: ['./undo-redo-v2.scss'], templateUrl: './undo-redo-v2.html', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, providers: [FlowState], imports: [FFlowModule], }) export class UndoRedoV2 implements OnInit { protected readonly state = inject(FlowState); private readonly _injector = inject(Injector); private readonly _flow = viewChild(FFlowComponent); private readonly _canvas = viewChild.required(FCanvasComponent); protected readonly eMarkerType = EFMarkerType; private _isChangeAfterLoadedResetAndCenter = true; // Debounce time for canvas change events. It helps to prevent excessive updates when zooming; protected fCanvasChangeEventDebounce = 200; // milliseconds protected readonly viewModel = signal(undefined); protected readonly nodes = computed(() => { return Object.values(this.viewModel()?.nodes || {}); }); protected readonly connections = computed(() => { return Object.values(this.viewModel()?.connections || {}); }); public ngOnInit(): void { this.state.initialize(DEFAULT_STATE); this._listenStateChanges(); } private _listenStateChanges(): void { effect( () => { this.state.changes(); untracked(() => this._applyChanges()); }, { injector: this._injector }, ); } private _applyChanges(): void { this.viewModel.set(this.state.getSnapshot()); if (!this.viewModel()) { return; } this._reCenterCanvasIfUndedToFirstStep(); this._applySelectionChanges(this.viewModel()!); } private _reCenterCanvasIfUndedToFirstStep(): void { if (!this.state.canUndo() && !this._isChangeAfterLoadedResetAndCenter) { this.editorLoaded(); } } private _applySelectionChanges({ selection }: IState): void { this._flow()?.select(selection?.nodes || [], selection?.connections || [], false); } protected editorLoaded(): void { this._isChangeAfterLoadedResetAndCenter = true; this._canvas()?.resetScaleAndCenter(false); } protected changeCanvasTransform(event: FCanvasChangeEvent): void { this._ifCanvasChangedFromInitialReCenterUpdateInitialState(event); } private _ifCanvasChangedFromInitialReCenterUpdateInitialState(event: FCanvasChangeEvent): void { if (this._isChangeAfterLoadedResetAndCenter) { this._isChangeAfterLoadedResetAndCenter = false; this.state.patchBase({ transform: { ...event } }); return; } this.state.update({ transform: createTransformObject(event), }); } protected createConnection(event: FCreateConnectionEvent): void { if (event.fInputId) { const connection = createConnectionObject(event); this.state.create({ connections: { [connection.id]: connection, }, }); } } protected reassignConnection(event: FReassignConnectionEvent): void { if (event.newTargetId) { this.state.update({ connections: { [event.connectionId]: { target: event.newTargetId }, }, }); } } protected moveNodes(event: FMoveNodesEvent): void { this.state.update({ nodes: createMoveNodesChangeObject(event.fNodes), }); } protected changeSelection(event: FSelectionChangeEvent): void { this.state.update({ selection: { nodes: [...event.fNodeIds], connections: [...event.fConnectionIds], }, }); } } function createTransformObject({ position, scale }: FCanvasChangeEvent) { return { position, scale }; } function createConnectionObject({ fOutputId, fInputId }: FCreateConnectionEvent) { return { id: generateGuid(), source: fOutputId, target: fInputId!, }; } function createMoveNodesChangeObject(nodes: { id: string; position: IPoint }[]) { return Object.fromEntries(nodes.map(({ id, position }) => [id, { position }])); }