diff --git a/apps/docs/src/content/docs/api/Types/EnvironmentInfo.md b/apps/docs/src/content/docs/api/Types/EnvironmentInfo.md index 944a69f04..47152c173 100644 --- a/apps/docs/src/content/docs/api/Types/EnvironmentInfo.md +++ b/apps/docs/src/content/docs/api/Types/EnvironmentInfo.md @@ -11,14 +11,46 @@ Interface representing environment information ### browser -> **browser**: `"Other"` \| `"Chrome"` \| `"Firefox"` \| `"Safari"` \| `"Edge"` \| `"Opera"` \| `"IE"` +> **browser**: `null` \| `LooseAutocomplete`\<`"Chrome"` \| `"Firefox"` \| `"Safari"` \| `"Edge"` \| `"Opera"` \| `"IE"` \| `"Other"`\> -User Browser name +User Browser name (when applicable) + +*** + +### generateId() + +> **generateId**: () => `string` + +Generates a unique ID + +#### Returns + +`string` + +*** + +### now() + +> **now**: () => `number` + +Current timestamp in ms + +#### Returns + +`number` *** ### os -> **os**: `"MacOS"` \| `"Windows"` \| `"Linux"` \| `"iOS"` \| `"Android"` \| `"Other"` +> **os**: `null` \| `LooseAutocomplete`\<`"MacOS"` \| `"Windows"` \| `"Linux"` \| `"iOS"` \| `"Android"` \| `"Unknown"`\> User Operating system name + +*** + +### runtime + +> **runtime**: `null` \| `LooseAutocomplete`\<`"node"` \| `"web"` \| `"other"`\> + +Platform identity for high-level adapter routing diff --git a/apps/docs/src/content/docs/api/Types/PointerInputEvent.md b/apps/docs/src/content/docs/api/Types/PointerInputEvent.md index d3e1f5fdd..374dd1ca8 100644 --- a/apps/docs/src/content/docs/api/Types/PointerInputEvent.md +++ b/apps/docs/src/content/docs/api/Types/PointerInputEvent.md @@ -193,7 +193,7 @@ Returns the object whose event listener's callback is currently being invoked. #### Inherited from -[`PointerInputEvent`](/docs/api/types/pointerinputevent/).[`currentTarget`](/docs/api/types/pointerinputevent/#currenttarget) +`PointerEvent.currentTarget` *** @@ -491,7 +491,7 @@ Returns true if event was dispatched by the user agent, and false otherwise. #### Inherited from -[`PointerInputEvent`](/docs/api/types/pointerinputevent/).[`srcElement`](/docs/api/types/pointerinputevent/#srcelement) +`PointerEvent.srcElement` *** @@ -517,7 +517,7 @@ Returns the object to which event is dispatched (its target). #### Inherited from -[`PointerInputEvent`](/docs/api/types/pointerinputevent/).[`target`](/docs/api/types/pointerinputevent/#target) +`PointerEvent.target` *** diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts index 14a5197dc..53355837a 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts @@ -1,3 +1,4 @@ +import { FlowCore } from '../flow-core'; import type { Edge } from '../types/edge.interface'; import type { BackgroundConfig, @@ -12,18 +13,11 @@ import type { ZIndexConfig, ZoomConfig, } from '../types/flow-config.interface'; -import { Point, Size } from '../types/utils'; +import { DeepPartial, Point, Size } from '../types/utils'; +import { deepMerge } from '../utils'; export const DEFAULT_NODE_MIN_SIZE = { width: 20, height: 20 }; -const defaultComputeNodeId = (): string => { - return crypto.randomUUID(); -}; - -const defaultComputeEdgeId = (): string => { - return crypto.randomUUID(); -}; - const defaultResizeConfig: ResizeConfig = { getMinNodeSize: (): Size => { return { ...DEFAULT_NODE_MIN_SIZE }; @@ -120,18 +114,22 @@ const defaultZIndexConfig: ZIndexConfig = { /** * Default configuration for the flow system. */ -export const defaultFlowConfig: FlowConfig = { - computeNodeId: defaultComputeNodeId, - computeEdgeId: defaultComputeEdgeId, - resize: defaultResizeConfig, - linking: defaultLinkingConfig, - grouping: defaultGroupingConfig, - zoom: defaultZoomConfig, - background: defaultBackgroundConfig, - nodeRotation: defaultNodeRotationConfig, - snapping: defaultNodeDraggingConfig, - selectionMoving: defaultSelectionMovingConfig, - edgeRouting: defaultEdgeRoutingConfig, - zIndex: defaultZIndexConfig, - debugMode: false, -}; +export const createFlowConfig = (config: DeepPartial, flowCore: FlowCore): FlowConfig => + deepMerge( + { + computeNodeId: () => flowCore.environment.generateId(), + computeEdgeId: () => flowCore.environment.generateId(), + resize: defaultResizeConfig, + linking: defaultLinkingConfig, + grouping: defaultGroupingConfig, + zoom: defaultZoomConfig, + background: defaultBackgroundConfig, + nodeRotation: defaultNodeRotationConfig, + snapping: defaultNodeDraggingConfig, + selectionMoving: defaultSelectionMovingConfig, + edgeRouting: defaultEdgeRoutingConfig, + zIndex: defaultZIndexConfig, + debugMode: false, + }, + config + ); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts index 78af7e687..524667b3a 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts @@ -1,11 +1,10 @@ -import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; import { CommandHandler } from './command-handler/command-handler'; import { FlowCore } from './flow-core'; -import { InputEventsRouter } from './input-events'; +import type { InputEventsRouter } from './input-events'; import { MiddlewareManager } from './middleware-manager/middleware-manager'; -import { mockEdge, mockMetadata, mockNode } from './test-utils'; -import { Edge } from './types/edge.interface'; -import type { EnvironmentInfo } from './types/environment.interface'; +import { mockEdge, mockEnvironment, mockMetadata, mockNode } from './test-utils'; +import type { Edge } from './types/edge.interface'; import type { FlowConfig } from './types/flow-config.interface'; import type { Metadata } from './types/metadata.interface'; import type { Middleware } from './types/middleware.interface'; @@ -77,7 +76,6 @@ describe('FlowCore', () => { let mockGetEdges: Mock<() => Edge[]>; let mockGetMetadata: Mock<() => Metadata>; let mockEventRouter: InputEventsRouter; - const mockEnvironment: EnvironmentInfo = { os: 'MacOS', browser: 'Chrome' }; beforeEach(() => { mockGetNodes = vi.fn().mockReturnValue([]); @@ -260,7 +258,9 @@ describe('FlowCore', () => { await flowCore.applyUpdate({ nodesToUpdate: [mockNode] }, 'changeSelection'); - expect(mockModelAdapter.updateMetadata).toHaveBeenCalledWith({ test: 'abc' }); + expect(mockModelAdapter.updateMetadata).toHaveBeenCalledWith({ + test: 'abc', + }); expect(mockModelAdapter.updateNodes).toHaveBeenCalledWith([mockNode]); expect(mockModelAdapter.updateEdges).toHaveBeenCalledWith([mockEdge]); }); @@ -316,7 +316,10 @@ describe('FlowCore', () => { describe('clientToFlowPosition', () => { it('should convert client position to flow position', () => { - mockGetMetadata.mockReturnValue({ ...mockMetadata, viewport: { x: 200, y: 200, scale: 2 } }); + mockGetMetadata.mockReturnValue({ + ...mockMetadata, + viewport: { x: 200, y: 200, scale: 2 }, + }); const clientPosition = { x: 30, y: 30 }; const flowPosition = flowCore.clientToFlowPosition(clientPosition); @@ -334,7 +337,10 @@ describe('FlowCore', () => { customGetFlowOffset ); - mockGetMetadata.mockReturnValue({ ...mockMetadata, viewport: { x: 200, y: 200, scale: 2 } }); + mockGetMetadata.mockReturnValue({ + ...mockMetadata, + viewport: { x: 200, y: 200, scale: 2 }, + }); const clientPosition = { x: 30, y: 30 }; const flowPosition = flowCore.clientToFlowPosition(clientPosition); @@ -345,7 +351,10 @@ describe('FlowCore', () => { describe('flowToClientPosition', () => { it('should convert flow position to client position', () => { - mockGetMetadata.mockReturnValue({ ...mockMetadata, viewport: { x: 200, y: 200, scale: 2 } }); + mockGetMetadata.mockReturnValue({ + ...mockMetadata, + viewport: { x: 200, y: 200, scale: 2 }, + }); const flowPosition = { x: -85, y: -85 }; const clientPosition = flowCore.flowToClientPosition(flowPosition); @@ -363,7 +372,10 @@ describe('FlowCore', () => { customGetFlowOffset ); - mockGetMetadata.mockReturnValue({ ...mockMetadata, viewport: { x: 200, y: 200, scale: 2 } }); + mockGetMetadata.mockReturnValue({ + ...mockMetadata, + viewport: { x: 200, y: 200, scale: 2 }, + }); const flowPosition = { x: -110, y: -135 }; const clientPosition = flowCore.flowToClientPosition(flowPosition); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts index d854e3d9d..5770495c7 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts @@ -2,7 +2,7 @@ import { ActionStateManager } from './action-state-manager/action-state-manager' import { CommandHandler } from './command-handler/command-handler'; import { EdgeRoutingManager } from './edge-routing-manager'; import { EventManager } from './event-manager'; -import { defaultFlowConfig } from './flow-config/default-flow-config'; +import { createFlowConfig } from './flow-config/default-flow-config'; import { InputEventsRouter } from './input-events'; import { LabelBatchProcessor } from './label-batch-processor/label-batch-processor'; import { MiddlewareManager } from './middleware-manager/middleware-manager'; @@ -45,7 +45,6 @@ export class FlowCore { readonly commandHandler: CommandHandler; readonly middlewareManager: MiddlewareManager; - readonly environment: EnvironmentInfo; readonly spatialHash: SpatialHash; readonly modelLookup: ModelLookup; readonly transactionManager: TransactionManager; @@ -61,13 +60,13 @@ export class FlowCore { modelAdapter: ModelAdapter, private readonly renderer: Renderer, public readonly inputEventsRouter: InputEventsRouter, - environment: EnvironmentInfo, + public readonly environment: EnvironmentInfo, middlewares?: MiddlewareChain, getFlowOffset?: () => Point, config: DeepPartial = {} ) { this._model = modelAdapter; - this._config = deepMerge(defaultFlowConfig, config); + this._config = createFlowConfig(config, this); this.environment = environment; this.commandHandler = new CommandHandler(this); this.spatialHash = new SpatialHash(); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middleware-executor.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middleware-executor.ts index 8126becd8..ff64381b3 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middleware-executor.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middleware-executor.ts @@ -93,6 +93,7 @@ export class MiddlewareExecutor { history: this.history, initialUpdate: this.initialStateUpdate, config: this.flowCore.config, + environment: this.flowCore.environment, }); private resolveMiddlewares = (): Promise => { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/internal-id-assignment/internal-id-assignment.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/internal-id-assignment/internal-id-assignment.test.ts index b509f3b3c..bdc2eae77 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/internal-id-assignment/internal-id-assignment.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/internal-id-assignment/internal-id-assignment.test.ts @@ -1,24 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockEnvironment } from '../../../test-utils'; import type { MiddlewareContext } from '../../../types'; import { internalIdMiddleware } from './internal-id-assignment'; -let originalCrypto: Crypto | undefined; - describe('InternalIdMiddleware', () => { let context: MiddlewareContext; let nextMock: ReturnType; beforeEach(() => { - // Ensure crypto.randomUUID exists in test environment - originalCrypto = globalThis.crypto as Crypto | undefined; - if (!originalCrypto || typeof originalCrypto.randomUUID !== 'function') { - // @ts-expect-error Partial stub for tests - globalThis.crypto = { - // Return a valid RFC4122 v4 UUID string - randomUUID: vi.fn().mockReturnValue('550e8400-e29b-41d4-a716-446655440000'), - } as Crypto; - } - nextMock = vi.fn(); context = { @@ -47,15 +36,15 @@ describe('InternalIdMiddleware', () => { getAffectedNodeIds: vi.fn().mockReturnValue([]), getAffectedEdgeIds: vi.fn().mockReturnValue([]), }, + environment: { + ...mockEnvironment, + generateId: vi.fn().mockReturnValue('550e8400-e29b-41d4-a716-446655440000'), + }, } as unknown as MiddlewareContext; }); afterEach(() => { - // Restore original crypto if we replaced it - if (originalCrypto) { - globalThis.crypto = originalCrypto; - originalCrypto = undefined; - } + vi.restoreAllMocks(); }); it('should not modify state when no nodes are added', async () => { @@ -131,7 +120,7 @@ describe('InternalIdMiddleware', () => { // Mock different UUIDs for sequential calls const randomUUIDMock = vi - .spyOn(globalThis.crypto!, 'randomUUID') + .spyOn(context.environment, 'generateId') .mockReturnValueOnce('550e8400-e29b-41d4-a716-446655440000') .mockReturnValueOnce('6ba7b810-9dad-11d1-80b4-00c04fd430c8'); @@ -199,7 +188,7 @@ describe('InternalIdMiddleware', () => { // Use a predictable UUID so the regex is deterministic const randomUUIDMock = vi - .spyOn(globalThis.crypto!, 'randomUUID') + .spyOn(context.environment, 'generateId') .mockReturnValue('550e8400-e29b-41d4-a716-446655440000'); await internalIdMiddleware.execute(context, nextMock, () => null); @@ -275,7 +264,7 @@ describe('InternalIdMiddleware', () => { // Mock different UUIDs const randomUUIDMock = vi - .spyOn(globalThis.crypto!, 'randomUUID') + .spyOn(context.environment, 'generateId') .mockReturnValueOnce('550e8400-e29b-41d4-a716-446655440000') .mockReturnValueOnce('6ba7b810-9dad-11d1-80b4-00c04fd430c8'); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/internal-id-assignment/internal-id-assignment.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/internal-id-assignment/internal-id-assignment.ts index 3280fbcf9..234905fb1 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/internal-id-assignment/internal-id-assignment.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/internal-id-assignment/internal-id-assignment.ts @@ -11,7 +11,7 @@ import { FlowStateUpdate, Middleware } from '../../../types'; export const internalIdMiddleware: Middleware = { name: 'internal-id-assignment', execute: async (context, next) => { - const { helpers, initialUpdate } = context; + const { helpers, initialUpdate, environment } = context; if (!helpers.anyNodesAdded()) { next(); @@ -22,7 +22,7 @@ export const internalIdMiddleware: Middleware = { ...node, _internalId: // eslint-disable-next-line @typescript-eslint/no-explicit-any - (node as any)._internalId || `${node.id}-${crypto.randomUUID()}`, + (node as any)._internalId || `${node.id}-${environment.generateId()}`, })); const stateUpdate: FlowStateUpdate = { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/test-utils.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/test-utils.ts index dff855d4b..e5b27c1bc 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/test-utils.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/test-utils.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest'; import type { Edge, EdgeLabel, EnvironmentInfo, GroupNode, Metadata, Node, Port } from './types'; export const mockNode: Node = { @@ -39,6 +40,9 @@ export const mockMetadata: Metadata = { export const mockEnvironment: EnvironmentInfo = { os: 'MacOS', browser: 'Chrome', + runtime: 'web', + now: vi.fn(), + generateId: vi.fn(), }; export const mockPort: Port = { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/environment.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/environment.interface.ts index 409f8af51..e64fd9649 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/environment.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/environment.interface.ts @@ -1,15 +1,19 @@ +import type { LooseAutocomplete } from './utils'; + /** * Interface representing environment information * * @category Types */ export interface EnvironmentInfo { - /** - * User Operating system name - */ - os: 'MacOS' | 'Windows' | 'Linux' | 'iOS' | 'Android' | 'Other'; - /** - * User Browser name - */ - browser: 'Chrome' | 'Firefox' | 'Safari' | 'Edge' | 'Opera' | 'IE' | 'Other'; + /** User Operating system name */ + os: LooseAutocomplete<'MacOS' | 'Windows' | 'Linux' | 'iOS' | 'Android' | 'Unknown'> | null; + /** User Browser name (when applicable) */ + browser: LooseAutocomplete<'Chrome' | 'Firefox' | 'Safari' | 'Edge' | 'Opera' | 'IE' | 'Other'> | null; + /** Platform identity for high-level adapter routing */ + runtime: LooseAutocomplete<'web' | 'node' | 'other'> | null; + /** Current timestamp in ms */ + now: () => number; + /** Generates a unique ID */ + generateId: () => string; } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/middleware.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/middleware.interface.ts index ee60f3554..c30b841ee 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/middleware.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/middleware.interface.ts @@ -2,6 +2,7 @@ import type { ActionStateManager } from '../action-state-manager/action-state-ma import type { EdgeRoutingManager } from '../edge-routing-manager'; import type { MiddlewareExecutor } from '../middleware-manager/middleware-executor'; import type { Edge } from './edge.interface'; +import { EnvironmentInfo } from './environment.interface'; import { FlowConfig } from './flow-config.interface'; import type { Metadata } from './metadata.interface'; import type { Node } from './node.interface'; @@ -101,6 +102,8 @@ export interface MiddlewareContext { initialUpdate: FlowStateUpdate; /** The configuration for the flow diagram */ config: FlowConfig; + /** The environment information */ + environment: EnvironmentInfo; } /** diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/palette/item-preview/ng-diagram-palette-item-preview.component.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/palette/item-preview/ng-diagram-palette-item-preview.component.ts index ad3802f89..c24949afa 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/palette/item-preview/ng-diagram-palette-item-preview.component.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/palette/item-preview/ng-diagram-palette-item-preview.component.ts @@ -1,7 +1,15 @@ -import { ChangeDetectionStrategy, Component, computed, ElementRef, inject, Signal, viewChild } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + type ElementRef, + inject, + type Signal, + viewChild, +} from '@angular/core'; import { NgDiagramViewportService } from '../../../public-services/ng-diagram-viewport.service'; import { PaletteService } from '../../../services'; -import { detectEnvironment } from '../../../utils/detect-environment'; +import { EnvironmentProviderService } from '../../../services/environment-provider/environment-provider.service'; /** * The `NgDiagramPaletteItemPreviewComponent` is responsible for rendering a live preview of a palette item @@ -15,8 +23,7 @@ import { detectEnvironment } from '../../../utils/detect-environment'; * ``` * * @category Components - */ -@Component({ + */ @Component({ selector: 'ng-diagram-palette-item-preview', standalone: true, templateUrl: './ng-diagram-palette-item-preview.component.html', @@ -25,9 +32,10 @@ import { detectEnvironment } from '../../../utils/detect-environment'; }) export class NgDiagramPaletteItemPreviewComponent { private paletteService = inject(PaletteService); - private browser = detectEnvironment().browser; + private environment = inject(EnvironmentProviderService); + private browser = this.environment.browser; - readonly id = crypto.randomUUID(); + readonly id = this.environment.generateId(); readonly preview: Signal | undefined> = viewChild('preview'); protected readonly scale = inject(NgDiagramViewportService).scale; diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-actions/copy.action.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-actions/copy.action.ts index 23b8a8544..55a2aa397 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-actions/copy.action.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-actions/copy.action.ts @@ -1,5 +1,4 @@ -import { inject, Injectable } from '@angular/core'; -import { BrowserInputsHelpers } from '../../../../services/input-events/browser-inputs-helpers'; +import { Injectable, inject } from '@angular/core'; import { InputEventsRouterService } from '../../../../services/input-events/input-events-router.service'; import { KeyboardAction } from './keyboard-action'; @@ -8,7 +7,7 @@ export class CopyAction extends KeyboardAction { private readonly inputEventsRouter = inject(InputEventsRouterService); override matches(event: KeyboardEvent): boolean { - return BrowserInputsHelpers.isKeyComboPressed('c', 'primary')(event); + return this.inputEventsRouter.eventGuards.isKeyComboPressed('c', 'primary')(event); } override handle(event: KeyboardEvent): void { diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-actions/cut.action.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-actions/cut.action.ts index 801be1281..5e5eca4ba 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-actions/cut.action.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-actions/cut.action.ts @@ -1,5 +1,4 @@ -import { inject, Injectable } from '@angular/core'; -import { BrowserInputsHelpers } from '../../../../services/input-events/browser-inputs-helpers'; +import { Injectable, inject } from '@angular/core'; import { InputEventsRouterService } from '../../../../services/input-events/input-events-router.service'; import { KeyboardAction } from './keyboard-action'; @@ -8,7 +7,7 @@ export class CutAction extends KeyboardAction { private readonly inputEventsRouter = inject(InputEventsRouterService); override matches(event: KeyboardEvent): boolean { - return BrowserInputsHelpers.isKeyComboPressed('x', 'primary')(event); + return this.inputEventsRouter.eventGuards.isKeyComboPressed('x', 'primary')(event); } override handle(event: KeyboardEvent): void { diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-actions/delete-selection.action.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-actions/delete-selection.action.ts index 1263b0c2d..4cac8876f 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-actions/delete-selection.action.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-actions/delete-selection.action.ts @@ -1,5 +1,4 @@ -import { inject, Injectable } from '@angular/core'; -import { BrowserInputsHelpers } from '../../../../services/input-events/browser-inputs-helpers'; +import { Injectable, inject } from '@angular/core'; import { InputEventsRouterService } from '../../../../services/input-events/input-events-router.service'; import { KeyboardAction } from './keyboard-action'; @@ -8,7 +7,7 @@ export class DeleteSelectionAction extends KeyboardAction { private readonly inputEventsRouter = inject(InputEventsRouterService); override matches(event: KeyboardEvent): boolean { - return BrowserInputsHelpers.isDeleteKeyPressed(event); + return this.inputEventsRouter.eventGuards.isDeleteKeyPressed(event); } override handle(event: KeyboardEvent): void { diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-actions/move-selection.action.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-actions/move-selection.action.ts index ecd5729d6..80c99f735 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-actions/move-selection.action.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-actions/move-selection.action.ts @@ -1,7 +1,6 @@ -import { inject, Injectable } from '@angular/core'; -import { Direction } from '../../../../../core/src'; -import { FlowCoreProviderService } from '../../../../services/flow-core-provider/flow-core-provider.service'; -import { BrowserInputsHelpers } from '../../../../services/input-events/browser-inputs-helpers'; +import { Injectable, inject } from '@angular/core'; +import type { Direction } from '../../../../../core/src'; +import { FlowCoreProviderService } from '../../../../services'; import { InputEventsRouterService } from '../../../../services/input-events/input-events-router.service'; import { KeyboardAction } from './keyboard-action'; @@ -15,7 +14,7 @@ export class MoveSelectionAction extends KeyboardAction { return false; } - return BrowserInputsHelpers.isArrowKeyPressed(event); + return this.inputEventsRouter.eventGuards.isArrowKeyPressed(event); } handle(event: KeyboardEvent): void { diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-actions/pan-with-arrows.action.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-actions/pan-with-arrows.action.ts index 3d4d62fc7..ba0ee9270 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-actions/pan-with-arrows.action.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-actions/pan-with-arrows.action.ts @@ -1,9 +1,7 @@ -import { inject, Injectable } from '@angular/core'; -import { Direction } from '../../../../../core/src'; -import { BrowserInputsHelpers } from '../../../../services/input-events/browser-inputs-helpers'; +import { Injectable, inject } from '@angular/core'; +import type { Direction } from '../../../../../core/src'; +import { FlowCoreProviderService } from '../../../../services'; import { InputEventsRouterService } from '../../../../services/input-events/input-events-router.service'; - -import { FlowCoreProviderService } from '../../../../services/flow-core-provider/flow-core-provider.service'; import { KeyboardAction } from './keyboard-action'; @Injectable() @@ -16,7 +14,7 @@ export class PanWithArrowsAction extends KeyboardAction { return false; } - return BrowserInputsHelpers.isArrowKeyPressed(event); + return this.inputEventsRouter.eventGuards.isArrowKeyPressed(event); } handle(event: KeyboardEvent): void { diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-actions/paste.action.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-actions/paste.action.ts index cc6d4fed5..0da3cad58 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-actions/paste.action.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-actions/paste.action.ts @@ -1,6 +1,5 @@ -import { inject, Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { CursorPositionTrackerService } from '../../../../services/cursor-position-tracker/cursor-position-tracker.service'; -import { BrowserInputsHelpers } from '../../../../services/input-events/browser-inputs-helpers'; import { InputEventsRouterService } from '../../../../services/input-events/input-events-router.service'; import { KeyboardAction } from './keyboard-action'; @@ -10,7 +9,7 @@ export class PasteAction extends KeyboardAction { private readonly cursorPositionTrackerService = inject(CursorPositionTrackerService); override matches(event: KeyboardEvent): boolean { - return BrowserInputsHelpers.isKeyComboPressed('v', 'primary')(event); + return this.inputEventsRouter.eventGuards.isKeyComboPressed('v', 'primary')(event); } override handle(event: KeyboardEvent): void { diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/object-selection/object-selection.directive.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/object-selection/object-selection.directive.ts index ca0f32de1..975fd6ed8 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/object-selection/object-selection.directive.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/object-selection/object-selection.directive.ts @@ -1,8 +1,7 @@ import { Directive, HostListener, inject, input } from '@angular/core'; -import { BasePointerInputEvent, Edge, Node } from '../../../../core/src'; -import { BrowserInputsHelpers } from '../../../services/input-events/browser-inputs-helpers'; +import type { BasePointerInputEvent, Edge, Node } from '../../../../core/src'; import { InputEventsRouterService } from '../../../services/input-events/input-events-router.service'; -import { PointerInputEvent } from '../../../types'; +import type { PointerInputEvent } from '../../../types'; @Directive() abstract class ObjectSelectionDirective { @@ -13,7 +12,7 @@ abstract class ObjectSelectionDirective { @HostListener('pointerdown', ['$event']) onPointerDown(event: PointerInputEvent) { - if (!BrowserInputsHelpers.withPrimaryButton(event)) { + if (!this.inputEventsRouter.eventGuards.withPrimaryButton(event)) { return; } diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/panning/panning.directive.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/panning/panning.directive.ts index 2197a4517..058ffab31 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/panning/panning.directive.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/panning/panning.directive.ts @@ -1,7 +1,6 @@ -import { Directive, inject, OnDestroy } from '@angular/core'; -import { BrowserInputsHelpers } from '../../../services/input-events/browser-inputs-helpers'; +import { Directive, inject, type OnDestroy } from '@angular/core'; import { InputEventsRouterService } from '../../../services/input-events/input-events-router.service'; -import { PointerInputEvent } from '../../../types/event'; +import type { PointerInputEvent } from '../../../types/event'; import { shouldDiscardEvent } from '../utils/should-discard-event'; import { ZoomingPointerDirective } from '../zooming/zooming-pointer.directive'; @@ -21,7 +20,7 @@ export class PanningDirective implements OnDestroy { } onPointerDown(event: PointerInputEvent): void { - if (!BrowserInputsHelpers.withPrimaryButton(event) || !this.shouldHandle(event)) { + if (!this.inputEventsRouter.eventGuards.withPrimaryButton(event) || !this.shouldHandle(event)) { return; } @@ -46,7 +45,7 @@ export class PanningDirective implements OnDestroy { } onPointerUp = (event: PointerEvent): void => { - if (!BrowserInputsHelpers.withPrimaryButton(event)) { + if (!this.inputEventsRouter.eventGuards.withPrimaryButton(event)) { return; } diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/pointer-move-selection/pointer-move-selection.directive.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/pointer-move-selection/pointer-move-selection.directive.ts index 1feeb2297..4b15544aa 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/pointer-move-selection/pointer-move-selection.directive.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/pointer-move-selection/pointer-move-selection.directive.ts @@ -1,10 +1,9 @@ -import { Directive, inject, input, OnDestroy } from '@angular/core'; -import { ContainerEdge, FPS_60, NgDiagramMath, Node } from '../../../../core/src'; +import { Directive, inject, input, type OnDestroy } from '@angular/core'; +import { type ContainerEdge, FPS_60, NgDiagramMath, type Node } from '../../../../core/src'; import { NgDiagramComponent } from '../../../components/diagram/ng-diagram.component'; import { FlowCoreProviderService } from '../../../services'; -import { BrowserInputsHelpers } from '../../../services/input-events/browser-inputs-helpers'; import { InputEventsRouterService } from '../../../services/input-events/input-events-router.service'; -import { PointerInputEvent } from '../../../types/event'; +import type { PointerInputEvent } from '../../../types/event'; import { shouldDiscardEvent } from '../utils/should-discard-event'; @Directive({ @@ -35,7 +34,7 @@ export class PointerMoveSelectionDirective implements OnDestroy { return; } - if (!BrowserInputsHelpers.withPrimaryButton(event)) { + if (!this.inputEventsRouter.eventGuards.withPrimaryButton(event)) { return; } @@ -65,7 +64,7 @@ export class PointerMoveSelectionDirective implements OnDestroy { } onPointerUp = (event: PointerEvent): void => { - if (!BrowserInputsHelpers.withPrimaryButton(event)) { + if (!this.inputEventsRouter.eventGuards.withPrimaryButton(event)) { return; } diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/environment-provider/environment-provider.service.spec.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/environment-provider/environment-provider.service.spec.ts new file mode 100644 index 000000000..053337399 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/environment-provider/environment-provider.service.spec.ts @@ -0,0 +1,164 @@ +import { TestBed } from '@angular/core/testing'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { EnvironmentProviderService } from './environment-provider.service'; + +describe('EnvironmentProviderService', () => { + let service: EnvironmentProviderService; + const spyUserAgent = (userAgent: string) => vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(userAgent); + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(EnvironmentProviderService); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('OS detection', () => { + const testCases = [ + { + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + expected: 'MacOS', + }, + { + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Mobile/15E148 Safari/604.1', + expected: 'iOS', + }, + { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0', + expected: 'Windows', + }, + { + userAgent: + 'Mozilla/5.0 (Linux; Android 10; SM-A505F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36 OPR/63.3.3216.58675', + expected: 'Android', + }, + { + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.59', + expected: 'Linux', + }, + ]; + + it.each(testCases)(`Should detect %s`, ({ userAgent, expected }) => { + spyUserAgent(userAgent); + expect(service.os).toBe(expected); + }); + }); + + describe('Browser detection', () => { + const testCases = [ + { + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36 OPR/119.0.0.0', + expected: 'Opera', + }, + { + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36 Edg/136.0.3240.50', + expected: 'Edge', + }, + { + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + expected: 'Chrome', + }, + { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0', + expected: 'Firefox', + }, + { + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15', + expected: 'Safari', + }, + { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0) like Gecko', + expected: 'IE', + }, + { + userAgent: 'SomeUnknownBrowser/1.0', + expected: 'Unknown', + }, + ]; + + it.each(testCases)(`Should detect %s`, ({ userAgent, expected }) => { + spyUserAgent(userAgent); + expect(service.browser).toBe(expected); + }); + }); + + describe('SSR behavior (isClient = false)', () => { + it('should set runtime to node and os/browser to null in SSR', () => { + vi.spyOn(EnvironmentProviderService.prototype, 'isClient', 'get').mockReturnValue(false); + + const ssrService = new EnvironmentProviderService(); + expect(ssrService.runtime).toBe('node'); + expect(ssrService.os).toBe(null); + expect(ssrService.browser).toBe(null); + }); + + it('now() should use Date.now in SSR', () => { + vi.spyOn(EnvironmentProviderService.prototype, 'isClient', 'get').mockReturnValue(false); + const mockDateNow = vi.spyOn(Date, 'now').mockReturnValue(987654321); + + const ssrService = new EnvironmentProviderService(); + const value = ssrService.now(); + + expect(mockDateNow).toHaveBeenCalled(); + expect(value).toBe(987654321); + }); + + it('generateId() should fallback to timestamp-based in SSR', () => { + vi.spyOn(EnvironmentProviderService.prototype, 'isClient', 'get').mockReturnValue(false); + const mockDateNow = vi.spyOn(Date, 'now').mockReturnValue(1234567890); + const mockMathRandom = vi.spyOn(Math, 'random').mockReturnValue(0.123456789); + + const ssrService = new EnvironmentProviderService(); + const id = ssrService.generateId(); + + expect(mockDateNow).toHaveBeenCalled(); + expect(mockMathRandom).toHaveBeenCalled(); + expect(id).toMatch(/^1234567890-[a-z0-9]+$/); + }); + }); + + describe('now() method', () => { + it('should return a number', () => { + const result = service.now(); + expect(typeof result).toBe('number'); + expect(result).toBeGreaterThan(0); + }); + + it('should return different values on subsequent calls', () => { + const result1 = service.now(); + const result2 = service.now(); + expect(result2).toBeGreaterThanOrEqual(result1); + }); + }); + + describe('generateId() method', () => { + it('should return a string', () => { + const result = service.generateId(); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); + + it('should return unique values on subsequent calls', () => { + const result1 = service.generateId(); + const result2 = service.generateId(); + expect(result1).not.toBe(result2); + }); + + it('should return values with expected format', () => { + const result = service.generateId(); + // Should be either a UUID format (from crypto.randomUUID) or timestamp-based format + const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(result); + const isTimestampBased = /^\d+-[a-z0-9]+$/.test(result); + expect(isUUID || isTimestampBased).toBe(true); + }); + }); +}); diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/environment-provider/environment-provider.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/environment-provider/environment-provider.service.ts new file mode 100644 index 000000000..e9148efe2 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/environment-provider/environment-provider.service.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@angular/core'; +import type { EnvironmentInfo } from '../../../core/src'; + +@Injectable({ + providedIn: 'root', +}) +export class EnvironmentProviderService implements EnvironmentInfo { + get os(): EnvironmentInfo['os'] | null { + return this.detectOS(); + } + + get browser(): EnvironmentInfo['browser'] | null { + return this.detectBrowser(); + } + + get runtime(): EnvironmentInfo['runtime'] { + return this.isClient ? 'web' : 'node'; + } + + get isClient(): boolean { + return this.checkIfClient(); + } + + private detectOS(): EnvironmentInfo['os'] { + if (!this.isClient) return null; + + const ua = window.navigator.userAgent; + if (/iPhone|iPad|iPod/.test(ua)) return 'iOS'; + if (/Mac/.test(ua)) return 'MacOS'; + if (/Win/.test(ua)) return 'Windows'; + if (/Android/.test(ua)) return 'Android'; + if (/Linux/.test(ua)) return 'Linux'; + return 'Other'; + } + + private detectBrowser(): EnvironmentInfo['browser'] { + if (!this.isClient) return null; + + const ua = window.navigator.userAgent; + + if (/OPR/.test(ua)) return 'Opera'; + if (/Edge|Edg/.test(ua)) return 'Edge'; + if (/Chrome/.test(ua)) return 'Chrome'; + if (/Firefox/.test(ua)) return 'Firefox'; + if (/Safari/.test(ua)) return 'Safari'; + if (/Trident/.test(ua)) return 'IE'; + + return 'Unknown'; + } + + private checkIfClient(): boolean { + return typeof window !== 'undefined' && typeof document !== 'undefined'; + } + + public now(): number { + return this.isClient ? performance.now() : Date.now(); + } + + public generateId(): string { + const hasCrypto = this.isClient && typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'; + + return hasCrypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`; + } +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/flow-core-provider/flow-core-provider.service.spec.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/flow-core-provider/flow-core-provider.service.spec.ts index 93ccced9c..4693cc3fe 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/services/flow-core-provider/flow-core-provider.service.spec.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/flow-core-provider/flow-core-provider.service.spec.ts @@ -1,7 +1,7 @@ import { TestBed } from '@angular/core/testing'; -import { FlowCore, Middleware, ModelAdapter } from '../../../core/src'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { detectEnvironment } from '../../utils/detect-environment'; +import { FlowCore, type Middleware, type ModelAdapter } from '../../../core/src'; +import { EnvironmentProviderService } from '../environment-provider/environment-provider.service'; import { InputEventsRouterService } from '../input-events/input-events-router.service'; import { RendererService } from '../renderer/renderer.service'; import { FlowCoreProviderService } from './flow-core-provider.service'; @@ -17,7 +17,10 @@ describe('FlowCoreProviderService', () => { destroy: vi.fn(), getNodes: vi.fn().mockReturnValue([]), getEdges: vi.fn().mockReturnValue([]), - getMetadata: vi.fn().mockReturnValue({ viewport: { x: 0, y: 0, scale: 1 }, middlewaresConfig: {} }), + getMetadata: vi.fn().mockReturnValue({ + viewport: { x: 0, y: 0, scale: 1 }, + middlewaresConfig: {}, + }), updateNodes: vi.fn(), updateEdges: vi.fn(), updateMetadata: vi.fn(), @@ -30,7 +33,7 @@ describe('FlowCoreProviderService', () => { beforeEach(() => { TestBed.configureTestingModule({ - providers: [FlowCoreProviderService, RendererService, InputEventsRouterService], + providers: [FlowCoreProviderService, RendererService, InputEventsRouterService, EnvironmentProviderService], }); service = TestBed.inject(FlowCoreProviderService); }); @@ -46,12 +49,6 @@ describe('FlowCoreProviderService', () => { expect(service.provide()).toBeInstanceOf(FlowCore); }); - it('should call detectEnvironment method', () => { - service.init(mockModelAdapter, [], mockOffset); - - expect(detectEnvironment).toHaveBeenCalled(); - }); - it('should initialize FlowCore with provided middlewares', () => { service.init(mockModelAdapter, mockMiddlewares, mockOffset); diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/flow-core-provider/flow-core-provider.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/flow-core-provider/flow-core-provider.service.ts index 0d2ce6bf3..556c8caec 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/services/flow-core-provider/flow-core-provider.service.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/flow-core-provider/flow-core-provider.service.ts @@ -1,7 +1,13 @@ -import { inject, Injectable, signal } from '@angular/core'; -import { DeepPartial, FlowConfig, FlowCore, MiddlewareChain, ModelAdapter, Point } from '../../../core/src'; - -import { detectEnvironment } from '../../utils/detect-environment'; +import { Injectable, inject, signal } from '@angular/core'; +import { + type DeepPartial, + type FlowConfig, + FlowCore, + type MiddlewareChain, + type ModelAdapter, + type Point, +} from '../../../core/src'; +import { EnvironmentProviderService } from '../environment-provider/environment-provider.service'; import { InputEventsRouterService } from '../input-events/input-events-router.service'; import { RendererService } from '../renderer/renderer.service'; @@ -9,6 +15,7 @@ import { RendererService } from '../renderer/renderer.service'; export class FlowCoreProviderService { private readonly renderer = inject(RendererService); private readonly inputEventsRouter = inject(InputEventsRouterService); + private readonly environment = inject(EnvironmentProviderService); private flowCore: FlowCore | null = null; private _isInitialized = signal(false); @@ -24,7 +31,7 @@ export class FlowCoreProviderService { model, this.renderer, this.inputEventsRouter, - detectEnvironment(), + this.environment, middlewares, getFlowOffset, config diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/browser-inputs-helpers.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/browser-inputs-helpers.ts deleted file mode 100644 index ccae8b2c0..000000000 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/browser-inputs-helpers.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { InputModifiers } from '../../../core/src'; -import { getOS } from '../../utils/detect-environment'; - -type DomEvent = KeyboardEvent | WheelEvent | PointerEvent | DragEvent | TouchEvent; - -// General type guards -const isPointerEvent = (event: Event): event is PointerEvent => event instanceof PointerEvent; -const isKeyboardEvent = (event: Event): event is KeyboardEvent => event instanceof KeyboardEvent; -const isWheelEvent = (event: Event): event is WheelEvent => event instanceof WheelEvent; -const isDomEvent = (event: Event): event is DomEvent => - isPointerEvent(event) || isKeyboardEvent(event) || isWheelEvent(event); - -// Modifier predicates -const getModifiers = (event: DomEvent): InputModifiers => { - const isMac = getOS() === 'MacOS'; - - return { - primary: isMac ? event.metaKey : event.ctrlKey, - secondary: event.altKey, - shift: event.shiftKey, - meta: event.metaKey, - }; -}; -const withPrimaryModifier = (event: DomEvent): boolean => getModifiers(event).primary; -const withSecondaryModifier = (event: DomEvent): boolean => getModifiers(event).secondary; -const withShiftModifier = (event: DomEvent): boolean => getModifiers(event).shift; -const withMetaModifier = (event: DomEvent): boolean => getModifiers(event).meta; -const withoutModifiers = (event: DomEvent): boolean => { - const modifiers = getModifiers(event); - return !modifiers.primary && !modifiers.secondary && !modifiers.shift && !modifiers.meta; -}; - -// Button predicates -const withPrimaryButton = (event: Event) => isPointerEvent(event) && (event.button === undefined || event.button === 0); -const isArrowKeyPressed = (event: Event): boolean => - isKeyboardEvent(event) && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key); -const isKeyPressed = (key: string) => (event: Event) => isKeyboardEvent(event) && event.key === key; -const isKeyComboPressed = - (key: string, ...modifiers: (keyof InputModifiers)[]) => - (event: Event): boolean => { - const isKeyboard = isKeyboardEvent(event); - if (!isKeyboard) return false; - - const modifiersPressed = getModifiers(event); - const requiredModsPressed = modifiers.every((mod) => modifiersPressed[mod]); - if (!requiredModsPressed) return false; - - return isKeyPressed(key)(event); - }; -const isDeleteKeyPressed = (event: Event): boolean => { - if (!isKeyboardEvent(event)) return false; - - const target = event.target as HTMLElement | null; - const isEditable = - target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable); - - if (isEditable) return false; - - return event.key === 'Delete' || event.key === 'Backspace'; -}; - -export const BrowserInputsHelpers = { - isPointerEvent, - isKeyboardEvent, - isWheelEvent, - isDomEvent, - - getModifiers, - withPrimaryModifier, - withSecondaryModifier, - withShiftModifier, - withMetaModifier, - withoutModifiers, - - withPrimaryButton, - isArrowKeyPressed, - isKeyPressed, - isKeyComboPressed, - isDeleteKeyPressed, - // Add other helper functions as needed -}; diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/input-events-router.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/input-events-router.service.ts index 0961db4fe..ee1d18d56 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/input-events-router.service.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/input-events-router.service.ts @@ -1,25 +1,167 @@ -import { Injectable } from '@angular/core'; -import { InputEventsRouter } from '../../../core/src'; -import { BrowserInputsHelpers } from './browser-inputs-helpers'; +import { Injectable, inject } from '@angular/core'; +import { BaseInputEvent, InputEventsRouter, type InputModifiers } from '../../../core/src'; +import { EnvironmentProviderService } from '../environment-provider/environment-provider.service'; +/** + * Union of browser events that this router understands and can normalize. + */ type DomEvent = KeyboardEvent | WheelEvent | PointerEvent | DragEvent | TouchEvent; +/** + * Bridges Angular DOM events to the agnostic ng-diagram core. + * + * It listens to events, normalizes them and adds common details (id, timestamp, + * modifier keys), and forwards them to the core input handlers. + * This is why it touches both DOM specifics and core event data. + */ @Injectable() export class InputEventsRouterService extends InputEventsRouter { - getBaseEvent(event: DomEvent) { + private readonly environment = inject(EnvironmentProviderService); + + /** + * Creates a minimal core event payload from a DOM event. + * + * - Adds a stable `id` and `timestamp` from the environment provider + * - Normalizes modifier keys across OSes/browsers + */ + getBaseEvent(event: DomEvent): Omit { return { - modifiers: BrowserInputsHelpers.getModifiers(event), - id: this.generateEventId(), - timestamp: performance.now(), + modifiers: this.getModifiers(event), + id: this.environment.generateId(), + timestamp: this.environment.now(), + }; + } + + /** + * Guards for common event properties like modifiers, buttons, keys, etc. + */ + readonly eventGuards = { + isPointerEvent: this.isPointerEvent.bind(this), + isKeyboardEvent: this.isKeyboardEvent.bind(this), + isWheelEvent: this.isWheelEvent.bind(this), + isDomEvent: this.isDomEvent.bind(this), + withPrimaryModifier: this.withPrimaryModifier.bind(this), + withSecondaryModifier: this.withSecondaryModifier.bind(this), + withShiftModifier: this.withShiftModifier.bind(this), + withMetaModifier: this.withMetaModifier.bind(this), + withoutModifiers: this.withoutModifiers.bind(this), + withPrimaryButton: this.withPrimaryButton.bind(this), + isArrowKeyPressed: this.isArrowKeyPressed.bind(this), + isKeyPressed: this.isKeyPressed.bind(this), + isKeyComboPressed: this.isKeyComboPressed.bind(this), + isDeleteKeyPressed: this.isDeleteKeyPressed.bind(this), + }; + + private keysMap = { + arrows: { + up: 'ArrowUp', + down: 'ArrowDown', + left: 'ArrowLeft', + right: 'ArrowRight', + }, + keys: { + delete: 'Delete', + backspace: 'Backspace', + }, + }; + + // Utilities + private getPrimaryModifierKey() { + if (!this.environment.isClient) return 'ctrlKey'; + + return this.environment.os === 'MacOS' ? 'metaKey' : 'ctrlKey'; + } + + private isPointerEvent(event: Event): event is PointerEvent { + return this.environment.isClient && event instanceof PointerEvent; + } + + private isKeyboardEvent(event: Event): event is KeyboardEvent { + return this.environment.isClient && event instanceof KeyboardEvent; + } + + private isWheelEvent(event: Event): event is WheelEvent { + return this.environment.isClient && event instanceof WheelEvent; + } + + private isDomEvent(event: Event): event is KeyboardEvent | WheelEvent | PointerEvent | DragEvent | TouchEvent { + return this.isPointerEvent(event) || this.isKeyboardEvent(event) || this.isWheelEvent(event); + } + + private getModifiers(event: Event): InputModifiers { + if (!this.isDomEvent(event)) + return { + primary: false, + secondary: false, + shift: false, + meta: false, + }; + + return { + primary: event[this.getPrimaryModifierKey()], + secondary: event.altKey, + shift: event.shiftKey, + meta: event.metaKey, + }; + } + + private withPrimaryModifier(event: Event): boolean { + return this.getModifiers(event).primary; + } + + private withSecondaryModifier(event: Event): boolean { + return this.getModifiers(event).secondary; + } + + private withShiftModifier(event: Event): boolean { + return this.getModifiers(event).shift; + } + + private withMetaModifier(event: Event): boolean { + return this.getModifiers(event).meta; + } + + private withoutModifiers(event: Event): boolean { + const modifiers = this.getModifiers(event); + + return Object.values(modifiers).every((modifier) => modifier === false); + } + + private withPrimaryButton(event: Event): boolean { + return this.isPointerEvent(event) && (event.button === undefined || event.button === 0); + } + + private isArrowKeyPressed(event: Event): boolean { + return this.isKeyboardEvent(event) && Object.values(this.keysMap.arrows).includes(event.key); + } + + private isKeyPressed(key: string) { + return (event: Event) => this.isKeyboardEvent(event) && event.key === key; + } + + private isKeyComboPressed(key: string, ...mods: (keyof InputModifiers)[]) { + return (event: Event) => { + if (!this.isKeyboardEvent(event)) return false; + + const modifiersPressed = this.getModifiers(event); + const requiredModsPressed = mods.every((mod) => modifiersPressed[mod]); + + if (!requiredModsPressed) return false; + + return this.isKeyPressed(key)(event); }; } - private generateEventId(): string { - if (!crypto.randomUUID) { - console.warn('crypto.randomUUID is not supported, using fallback ID generation'); - return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`; - } - // NOTE: Works only in https - return crypto.randomUUID(); + private isDeleteKeyPressed(event: Event): boolean { + if (!this.isKeyboardEvent(event)) return false; + + const target = event.target as HTMLElement | null; + + const isEditableTarget = (el: HTMLElement | null) => + el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable); + + if (isEditableTarget(target)) return false; + + return event.key === this.keysMap.keys.delete || event.key === this.keysMap.keys.backspace; } } diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/utils/detect-environment.spec.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/utils/detect-environment.spec.ts deleted file mode 100644 index 4dfafd24f..000000000 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/utils/detect-environment.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { detectEnvironment } from './detect-environment'; - -describe('detectEnvironment', () => { - describe('OS detection', () => { - it('should detect MacOS', () => { - vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue( - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' - ); - - expect(detectEnvironment()).toEqual(expect.objectContaining({ os: 'MacOS' })); - }); - - it('should detect iOS', () => { - vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue( - 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Mobile/15E148 Safari/604.1' - ); - - expect(detectEnvironment()).toEqual(expect.objectContaining({ os: 'iOS' })); - }); - - it('should detect Windows', () => { - vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0' - ); - - expect(detectEnvironment()).toEqual(expect.objectContaining({ os: 'Windows' })); - }); - - it('should detect Android', () => { - vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue( - 'Mozilla/5.0 (Linux; Android 10; SM-A505F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36 OPR/63.3.3216.58675' - ); - - expect(detectEnvironment()).toEqual(expect.objectContaining({ os: 'Android' })); - }); - - it('should detect Linux', () => { - vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue( - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.59' - ); - - expect(detectEnvironment()).toEqual(expect.objectContaining({ os: 'Linux' })); - }); - }); - - describe('Browser detection', () => { - it('should detect Opera', () => { - vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue( - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36 OPR/119.0.0.0' - ); - - expect(detectEnvironment()).toEqual(expect.objectContaining({ browser: 'Opera' })); - }); - - it('should detect Edge', () => { - vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36 Edg/136.0.3240.50' - ); - - expect(detectEnvironment()).toEqual(expect.objectContaining({ browser: 'Edge' })); - }); - - it('should detect Chrome', () => { - vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue( - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' - ); - - expect(detectEnvironment()).toEqual(expect.objectContaining({ browser: 'Chrome' })); - }); - - it('should detect Firefox', () => { - vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0' - ); - - expect(detectEnvironment()).toEqual(expect.objectContaining({ browser: 'Firefox' })); - }); - - it('should detect Safari', () => { - vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue( - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15' - ); - - expect(detectEnvironment()).toEqual(expect.objectContaining({ browser: 'Safari' })); - }); - - it('should detect IE', () => { - vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue( - 'Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0) like Gecko' - ); - - expect(detectEnvironment()).toEqual(expect.objectContaining({ browser: 'IE' })); - }); - }); -}); diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/utils/detect-environment.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/utils/detect-environment.ts deleted file mode 100644 index 5199a3c1b..000000000 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/utils/detect-environment.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { EnvironmentInfo } from '../../core/src'; - -export function getOS(): EnvironmentInfo['os'] { - const userAgent = window.navigator.userAgent; - if (/iPhone|iPad|iPod/.test(userAgent)) { - return 'iOS'; - } else if (/Mac/.test(userAgent)) { - return 'MacOS'; - } else if (/Win/.test(userAgent)) { - return 'Windows'; - } else if (/Android/.test(userAgent)) { - return 'Android'; - } else if (/Linux/.test(userAgent)) { - return 'Linux'; - } - - return 'Other'; -} - -function getBrowser(): EnvironmentInfo['browser'] { - const userAgent = window.navigator.userAgent; - if (/OPR/.test(userAgent)) { - return 'Opera'; - } else if (/Edge|Edg/.test(userAgent)) { - return 'Edge'; - } else if (/Chrome/.test(userAgent)) { - return 'Chrome'; - } else if (/Firefox/.test(userAgent)) { - return 'Firefox'; - } else if (/Safari/.test(userAgent)) { - return 'Safari'; - } else if (/Trident/.test(userAgent)) { - return 'IE'; - } - - return 'Other'; -} - -export function detectEnvironment(): EnvironmentInfo { - return { os: getOS(), browser: getBrowser() }; -}