ctx.fillRect(
x - labelInnerLeftPadding,
y - labelInnerTopPadding,
- width + labelInnerLeftPadding + labelInnerRightPadding,
- height + labelInnerTopPadding + labelInnerBottomPadding
+ labelWidth + labelInnerLeftPadding + labelInnerRightPadding,
+ labelHeight + labelInnerTopPadding + labelInnerBottomPadding
);
});
diff --git a/src/components/canvas/groups/BlockGroups.ts b/src/components/canvas/groups/BlockGroups.ts
index 28d64c09..6dcb7b4e 100644
--- a/src/components/canvas/groups/BlockGroups.ts
+++ b/src/components/canvas/groups/BlockGroups.ts
@@ -3,10 +3,12 @@ import { ReadonlySignal, Signal, computed } from "@preact/signals-core";
import { TBlock } from "../../..";
import { Graph } from "../../../graph";
import { CoreComponent } from "../../../lib";
+import type { Component } from "../../../lib/Component";
import { TComponentState } from "../../../lib/Component";
import { Layer, LayerContext, LayerProps } from "../../../services/Layer";
import { BlockState } from "../../../store/block/Block";
import { GroupState, TGroup, TGroupId } from "../../../store/group/Group";
+import { logDev } from "../../../utils/devLog";
import { getBlocksRect } from "../../../utils/functions";
import { TRect } from "../../../utils/types/shapes";
@@ -39,6 +41,14 @@ export class BlockGroups extends
BlockGroupsContext,
BlockGroupsState
> {
+ public override getCanvas(): HTMLCanvasElement {
+ const el = super.getCanvas();
+ if (!el) {
+ throw new Error("BlockGroups: expected canvas to exist");
+ }
+ return el;
+ }
+
public static withBlockGrouping
>(
this: new (props: P) => Instance,
{
@@ -182,9 +192,15 @@ export class BlockGroups
extends
const canvas = this.getCanvas();
+ const ctx2d = canvas.getContext("2d");
+ if (!ctx2d) {
+ logDev("BlockGroups: Canvas 2D context is not available");
+ throw new Error("BlockGroups: Canvas 2D context is not available");
+ }
+
this.setContext({
canvas,
- ctx: canvas.getContext("2d"),
+ ctx: ctx2d,
root: this.props.root,
camera: this.props.camera,
constants: this.props.graph.graphConstants,
@@ -264,6 +280,8 @@ export class BlockGroups
extends
* Find a Group component by its ID
*/
public getGroupById(groupId: string): Group | null {
- return this.$?.[groupId];
+ const children = this.$ as Record;
+ const instance = children[groupId];
+ return instance instanceof Group ? instance : null;
}
}
diff --git a/src/components/canvas/groups/Group.ts b/src/components/canvas/groups/Group.ts
index 2a28c19d..4cd9fcfb 100644
--- a/src/components/canvas/groups/Group.ts
+++ b/src/components/canvas/groups/Group.ts
@@ -109,9 +109,12 @@ export class Group extends GraphComponent {
+ this.addEventListener("click", (event: Event) => {
+ if (!(event instanceof MouseEvent)) {
+ return;
+ }
event.stopPropagation();
- this.groupState.setSelection(
+ this.groupState?.setSelection(
true,
!isMetaKeyEvent(event) ? ESelectionStrategy.REPLACE : ESelectionStrategy.APPEND
);
@@ -175,7 +178,7 @@ export class Group extends GraphComponent extends GraphComponent {
+ protected subscribeToGroup(): void {
+ const nextGroupState = this.context.graph.rootStore.groupsList.getGroupState(this.props.id);
+ this.groupState = nextGroupState;
+ if (!nextGroupState) {
+ return;
+ }
+
+ this.subscribeSignal(nextGroupState.$selected, (selected) => {
this.setState({
selected,
});
});
- return this.subscribeSignal(this.groupState.$state, (group) => {
- if (group) {
- this.setState({
- ...this.state,
- ...group,
- } as T);
+ this.subscribeSignal(nextGroupState.$state, (group) => {
+ if (!group) {
+ return;
+ }
+ this.setState({
+ ...this.state,
+ ...group,
+ } as T);
+ if (group.rect) {
this.updateHitBox(this.getRect(group.rect));
}
});
diff --git a/src/components/canvas/layers/belowLayer/PointerGrid.ts b/src/components/canvas/layers/belowLayer/PointerGrid.ts
index 3489f15b..076ecdb0 100644
--- a/src/components/canvas/layers/belowLayer/PointerGrid.ts
+++ b/src/components/canvas/layers/belowLayer/PointerGrid.ts
@@ -11,12 +11,12 @@ export class PointerGrid extends Component {
private fakeCanvasContext?: CanvasRenderingContext2D;
- private pattern: {
+ private pattern!: {
normal: CanvasPattern;
simple: CanvasPattern;
};
- private activePattern: CanvasPattern;
+ private activePattern!: CanvasPattern;
// need to understand when should remake pattern
private currentDotsColor: string;
@@ -60,10 +60,12 @@ export class PointerGrid extends Component {
private initPattern() {
this.fakeCanvasContext = this.createFakeCanvasContext();
- this.pattern = {
- normal: this.createPattern(false, this.fakeCanvasContext),
- simple: this.createPattern(true, this.fakeCanvasContext),
- };
+ const normal = this.createPattern(false, this.fakeCanvasContext);
+ const simple = this.createPattern(true, this.fakeCanvasContext);
+ if (!normal || !simple) {
+ throw new Error("PointerGrid: failed to create canvas pattern");
+ }
+ this.pattern = { normal, simple };
this.activePattern = this.pattern.normal;
@@ -98,6 +100,10 @@ export class PointerGrid extends Component {
}
private createFakeCanvasContext(): CanvasRenderingContext2D {
- return document.createElement("canvas").getContext("2d");
+ const ctx = document.createElement("canvas").getContext("2d");
+ if (!ctx) {
+ throw new Error("PointerGrid: 2d context is required");
+ }
+ return ctx;
}
}
diff --git a/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts b/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts
index f56df6a7..99420059 100644
--- a/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts
+++ b/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts
@@ -84,7 +84,7 @@ declare module "../../../../graphEvents" {
"connection-create-drop": (
event: CustomEvent<{
sourceBlockId: TBlockId;
- sourceAnchorId: string;
+ sourceAnchorId: string | undefined;
targetBlockId?: TBlockId;
targetAnchorId?: string;
point: Point;
@@ -135,10 +135,18 @@ export class ConnectionLayer extends Layer<
...props,
});
+ const canvas = this.getCanvas();
+ if (!canvas) {
+ throw new Error("ConnectionLayer: canvas is required");
+ }
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ throw new Error("ConnectionLayer: 2d context is required");
+ }
this.setContext({
- canvas: this.getCanvas(),
+ canvas,
graphCanvas: props.graph.getGraphCanvas(),
- ctx: this.getCanvas().getContext("2d"),
+ ctx,
camera: props.camera,
constants: this.props.graph.graphConstants,
colors: this.props.graph.graphColors,
@@ -184,7 +192,11 @@ export class ConnectionLayer extends Layer<
if (!isTargetAllowed) {
return false;
}
- if (this.props.isConnectionAllowed && !this.props.isConnectionAllowed(target.connectedState)) {
+ const connected = target.connectedState;
+ if (!connected) {
+ return false;
+ }
+ if (this.props.isConnectionAllowed && !this.props.isConnectionAllowed(connected)) {
return false;
}
return true;
@@ -222,8 +234,13 @@ export class ConnectionLayer extends Layer<
const scale = this.context.camera.getCameraScale();
const iconSize = 24 / scale;
const iconOffset = 12 / scale;
+ const endState = this.endState;
+ if (!endState) {
+ ctx.closePath();
+ return;
+ }
- if (!this.target && this.props.createIcon && this.endState) {
+ if (!this.target && this.props.createIcon) {
renderSVG(
{
path: this.props.createIcon.path,
@@ -233,7 +250,7 @@ export class ConnectionLayer extends Layer<
initialHeight: this.props.createIcon.viewHeight,
},
ctx,
- { x: this.endState.x, y: this.endState.y - iconOffset, width: iconSize, height: iconSize }
+ { x: endState.x, y: endState.y - iconOffset, width: iconSize, height: iconSize }
);
} else if (this.props.point) {
ctx.fillStyle = this.props.point.fill || this.context.colors.canvas.belowLayerBackground;
@@ -250,7 +267,7 @@ export class ConnectionLayer extends Layer<
initialHeight: this.props.point.viewHeight,
},
ctx,
- { x: this.endState.x, y: this.endState.y - iconOffset, width: iconSize, height: iconSize }
+ { x: endState.x, y: endState.y - iconOffset, width: iconSize, height: iconSize }
);
}
ctx.closePath();
@@ -304,7 +321,11 @@ export class ConnectionLayer extends Layer<
if (!sourceComponent) {
return;
}
- this.sourceComponent = sourceComponent.connectedState;
+ const sourceState = sourceComponent.connectedState;
+ if (!sourceState) {
+ return;
+ }
+ this.sourceComponent = sourceState;
if (sourceComponent instanceof Block) {
this.startState = new Point(worldCoords.x, worldCoords.y);
} else if (sourceComponent instanceof Anchor) {
@@ -315,15 +336,12 @@ export class ConnectionLayer extends Layer<
this.context.graph.executеDefaultEventAction(
"connection-create-start",
{
- blockId:
- sourceComponent instanceof Anchor
- ? sourceComponent.connectedState.blockId
- : sourceComponent.connectedState.id,
- anchorId: sourceComponent instanceof Anchor ? sourceComponent.connectedState.id : undefined,
+ blockId: sourceState instanceof AnchorState ? sourceState.blockId : sourceState.id,
+ anchorId: sourceState instanceof AnchorState ? String(sourceState.id) : undefined,
},
() => {
if (sourceComponent instanceof Block) {
- this.context.graph.api.selectBlocks([this.sourceComponent.id], true, ESelectionStrategy.REPLACE);
+ this.context.graph.api.selectBlocks([sourceState.id], true, ESelectionStrategy.REPLACE);
} else if (sourceComponent instanceof Anchor) {
this.context.graph.api.setAnchorSelection(sourceComponent.props.blockId, sourceComponent.props.id, true);
}
@@ -371,7 +389,7 @@ export class ConnectionLayer extends Layer<
targetBlockId: target instanceof AnchorState ? target.blockId : target.id,
},
() => {
- this.target.connectedState.setSelection(true);
+ this.target?.connectedState?.setSelection(true);
}
);
}
@@ -428,13 +446,14 @@ export class ConnectionLayer extends Layer<
targetComponent.connectedState.setSelection(false);
}
+ const dropTargetState = targetComponent.connectedState;
this.context.graph.executеDefaultEventAction(
"connection-create-drop",
{
sourceBlockId: this.getBlockId(this.sourceComponent),
sourceAnchorId: this.getAnchorId(this.sourceComponent),
- targetBlockId: this.getBlockId(targetComponent.connectedState),
- targetAnchorId: this.getAnchorId(targetComponent.connectedState),
+ targetBlockId: dropTargetState ? this.getBlockId(dropTargetState) : undefined,
+ targetAnchorId: dropTargetState ? this.getAnchorId(dropTargetState) : undefined,
point,
},
() => {}
diff --git a/src/components/canvas/layers/cursorLayer/CursorLayer.ts b/src/components/canvas/layers/cursorLayer/CursorLayer.ts
index 7ea3a575..6a97760b 100644
--- a/src/components/canvas/layers/cursorLayer/CursorLayer.ts
+++ b/src/components/canvas/layers/cursorLayer/CursorLayer.ts
@@ -113,7 +113,15 @@ export class CursorLayer extends Layer {
private currentTarget?: GraphComponent;
/** Debounced cursor update function to prevent flickering on large scales */
- private debouncedUpdateCursor: ReturnType void>>;
+ private debouncedUpdateCursor = debounce(
+ (target?: EventedComponent) => {
+ this.updateCursorForTarget(target);
+ },
+ {
+ frameInterval: 3,
+ priority: ESchedulerPriority.LOW,
+ }
+ );
/**
* Creates a new CursorLayer instance.
@@ -125,17 +133,6 @@ export class CursorLayer extends Layer {
// No HTML element needed - we'll apply cursor to the root element
...props,
});
-
- // Create debounced version with 10 frames delay to prevent cursor flickering
- this.debouncedUpdateCursor = debounce(
- (target?: EventedComponent) => {
- this.updateCursorForTarget(target);
- },
- {
- frameInterval: 3,
- priority: ESchedulerPriority.LOW,
- }
- );
}
/**
diff --git a/src/components/canvas/layers/graphLayer/GraphLayer.ts b/src/components/canvas/layers/graphLayer/GraphLayer.ts
index 2dfbfbf0..9c46b7be 100644
--- a/src/components/canvas/layers/graphLayer/GraphLayer.ts
+++ b/src/components/canvas/layers/graphLayer/GraphLayer.ts
@@ -42,13 +42,21 @@ export type GraphMouseEvent = CustomEvent<{
}>;
export class GraphLayer extends Layer {
+ public override getCanvas(): HTMLCanvasElement {
+ const el = super.getCanvas();
+ if (!el) {
+ throw new Error("GraphLayer: expected canvas to exist");
+ }
+ return el;
+ }
+
public declare $: Component & { camera: Camera };
private camera: ICamera;
- private targetComponent: EventedComponent;
+ private targetComponent!: EventedComponent;
- private prevTargetComponent: EventedComponent;
+ private prevTargetComponent!: EventedComponent;
private canEmulateClick?: boolean;
@@ -79,10 +87,17 @@ export class GraphLayer extends Layer {
});
const canvas = this.getCanvas();
+ if (!canvas) {
+ throw new Error("GraphLayer: canvas was not created");
+ }
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ throw new Error("GraphLayer: failed to acquire Canvas 2D context");
+ }
this.setContext({
- canvas: canvas,
- ctx: canvas.getContext("2d"),
+ canvas,
+ ctx,
root: this.props.root,
camera: this.props.camera,
ownerDocument: canvas.ownerDocument,
@@ -101,6 +116,8 @@ export class GraphLayer extends Layer {
}
protected afterInit(): void {
+ this.targetComponent = this.$.camera;
+ this.prevTargetComponent = this.$.camera;
this.setContext({
root: this.root as HTMLDivElement,
});
diff --git a/src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts b/src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts
index 1b055454..1e4abb6a 100644
--- a/src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts
+++ b/src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts
@@ -49,7 +49,7 @@ export class NewBlockLayer extends Layer<
LayerContext & { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D }
> {
private copyBlocks: BlockState[] = [];
- private initialPoint: TPoint;
+ private initialPoint: TPoint | null = null;
private blockStates: Array<{
x: number;
y: number;
@@ -69,10 +69,18 @@ export class NewBlockLayer extends Layer<
...props,
});
+ const canvas = this.getCanvas();
+ if (!canvas) {
+ throw new Error("NewBlockLayer: canvas is required");
+ }
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ throw new Error("NewBlockLayer: 2d context is required");
+ }
this.setContext({
- canvas: this.getCanvas(),
+ canvas,
graphCanvas: props.graph.getGraphCanvas(),
- ctx: this.getCanvas().getContext("2d"),
+ ctx,
camera: props.camera,
constants: this.props.graph.graphConstants,
colors: this.props.graph.graphColors,
@@ -156,10 +164,12 @@ export class NewBlockLayer extends Layer<
const selectedBlockStates = this.context.graph.rootStore.blocksList.$selectedBlocks.value;
// If we have a validation function, filter out blocks that can't be duplicated
- if (this.props.isDuplicateAllowed) {
- blockStates = selectedBlockStates.filter((blockState) =>
- this.props.isDuplicateAllowed(blockState.getViewComponent())
- );
+ const isDuplicateAllowed = this.props.isDuplicateAllowed;
+ if (isDuplicateAllowed) {
+ blockStates = selectedBlockStates.filter((blockState) => {
+ const view = blockState.getViewComponent();
+ return view !== undefined && isDuplicateAllowed(view);
+ });
// If no blocks can be duplicated, exit
if (blockStates.length === 0) return;
@@ -172,7 +182,9 @@ export class NewBlockLayer extends Layer<
}
// Map BlockState to Block for the event
- const blocks = isBlockSelected ? blockStates.map((blockState) => blockState.getViewComponent()) : [block];
+ const blocks: Block[] = isBlockSelected
+ ? blockStates.map((blockState) => blockState.getViewComponent()).filter((b): b is Block => b !== undefined)
+ : [block];
// Use the already filtered blockStates
this.copyBlocks = blockStates;
@@ -218,8 +230,8 @@ export class NewBlockLayer extends Layer<
});
}
- private lastMouseX: number;
- private lastMouseY: number;
+ private lastMouseX?: number;
+ private lastMouseY?: number;
private onMoveNewBlock(event: MouseEvent) {
if (!this.copyBlocks.length) {
@@ -231,15 +243,18 @@ export class NewBlockLayer extends Layer<
const mouseY = xy[1];
// If this is the first move event, initialize the last mouse position
- if (this.lastMouseX === undefined) {
+ if (this.lastMouseX === undefined || this.lastMouseY === undefined) {
this.lastMouseX = mouseX;
this.lastMouseY = mouseY;
return;
}
+ const prevX = this.lastMouseX;
+ const prevY = this.lastMouseY;
+
// Calculate the movement delta
- const deltaX = mouseX - this.lastMouseX;
- const deltaY = mouseY - this.lastMouseY;
+ const deltaX = mouseX - prevX;
+ const deltaY = mouseY - prevY;
// Update positions of all ghost blocks by applying the delta
this.blockStates = this.blockStates.map((blockState) => {
@@ -258,10 +273,12 @@ export class NewBlockLayer extends Layer<
}
private onEndNewBlock(event: MouseEvent, point: TPoint) {
- if (!this.copyBlocks.length) {
+ if (!this.copyBlocks.length || this.initialPoint === null) {
return;
}
+ const initialPoint = this.initialPoint;
+
// Clear the block states and reset mouse tracking
this.blockStates = [];
this.lastMouseX = undefined;
@@ -269,22 +286,24 @@ export class NewBlockLayer extends Layer<
this.performRender();
// Calculate the offset from the initial point to the final point
- const offsetX = point.x - this.initialPoint.x;
- const offsetY = point.y - this.initialPoint.y;
+ const offsetX = point.x - initialPoint.x;
+ const offsetY = point.y - initialPoint.y;
// Collect all blocks and their new coordinates as items
- const items = this.copyBlocks.map((blockState) => {
- // Calculate the new position for each block based on its original position plus the offset
- const newCoord = {
- x: blockState.x + offsetX,
- y: blockState.y + offsetY,
- };
-
- return {
- block: blockState.getViewComponent(),
- coord: newCoord,
- };
- });
+ const items: Array<{ block: Block; coord: TPoint }> = [];
+ for (const blockState of this.copyBlocks) {
+ const view = blockState.getViewComponent();
+ if (!view) {
+ continue;
+ }
+ items.push({
+ block: view,
+ coord: {
+ x: blockState.x + offsetX,
+ y: blockState.y + offsetY,
+ },
+ });
+ }
// Calculate the delta between start and end positions
const delta = {
@@ -340,23 +359,27 @@ export class NewBlockLayer extends Layer<
connections.forEach((connection) => {
const sourceId = connection.sourceBlockId;
const targetId = connection.targetBlockId;
+ if (sourceId === undefined || targetId === undefined) {
+ return;
+ }
// If both source and target blocks were duplicated, create a new connection
if (blockIdMap.has(sourceId.toString()) && blockIdMap.has(targetId.toString())) {
const newSourceId = blockIdMap.get(sourceId.toString());
const newTargetId = blockIdMap.get(targetId.toString());
-
- // Create a new connection between the duplicated blocks
- this.context.graph.api.addConnection({
- sourceBlockId: newSourceId,
- targetBlockId: newTargetId,
- sourceAnchorId: connection.sourceAnchorId,
- targetAnchorId: connection.targetAnchorId,
- // Copy any other connection properties
- styles: connection.$state.value.styles,
- dashed: connection.$state.value.dashed,
- label: connection.$state.value.label,
- });
+ if (newSourceId !== undefined && newTargetId !== undefined) {
+ // Create a new connection between the duplicated blocks
+ this.context.graph.api.addConnection({
+ sourceBlockId: newSourceId,
+ targetBlockId: newTargetId,
+ sourceAnchorId: connection.sourceAnchorId,
+ targetAnchorId: connection.targetAnchorId,
+ // Copy any other connection properties
+ styles: connection.$state.value.styles,
+ dashed: connection.$state.value.dashed,
+ label: connection.$state.value.label,
+ });
+ }
}
});
}
diff --git a/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts b/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts
index 03dd2cb3..5fd3514f 100644
--- a/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts
+++ b/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts
@@ -66,6 +66,11 @@ type SnappingPortBox = {
port: PortState;
};
+type TPortEventIds = {
+ blockId: TBlockId;
+ anchorId: string | undefined;
+};
+
type PortConnectionLayerProps = LayerProps & {
createIcon?: TIcon;
point?: TIcon;
@@ -121,8 +126,8 @@ declare module "../../../../graphEvents" {
"port-connection-cancel": (
event: CustomEvent<{
- sourcePort: PortState;
- targetPort: PortState;
+ sourcePort?: PortState;
+ targetPort?: PortState;
}>
) => void;
@@ -133,7 +138,7 @@ declare module "../../../../graphEvents" {
"port-connection-create-drop": (
event: CustomEvent<{
sourceBlockId: TBlockId;
- sourceAnchorId: string;
+ sourceAnchorId: string | undefined;
targetBlockId?: TBlockId;
targetAnchorId?: string;
point: Point;
@@ -203,10 +208,18 @@ export class PortConnectionLayer extends Layer<
...props,
});
+ const canvas = this.getCanvas();
+ if (!canvas) {
+ throw new Error("PortConnectionLayer: canvas is required");
+ }
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ throw new Error("PortConnectionLayer: 2d context is required");
+ }
this.setContext({
- canvas: this.getCanvas(),
+ canvas,
graphCanvas: props.graph.getGraphCanvas(),
- ctx: this.getCanvas().getContext("2d"),
+ ctx,
camera: props.camera,
constants: this.props.graph.graphConstants,
colors: this.props.graph.graphColors,
@@ -304,8 +317,13 @@ export class PortConnectionLayer extends Layer<
const scale = this.context.camera.getCameraScale();
const iconSize = 24 / scale;
const iconOffset = 12 / scale;
+ const endState = this.endState;
+ if (!endState) {
+ ctx.closePath();
+ return;
+ }
- if (!this.targetPort && this.props.createIcon && this.endState) {
+ if (!this.targetPort && this.props.createIcon) {
renderSVG(
{
path: this.props.createIcon.path,
@@ -315,7 +333,7 @@ export class PortConnectionLayer extends Layer<
initialHeight: this.props.createIcon.viewHeight,
},
ctx,
- { x: this.endState.x, y: this.endState.y - iconOffset, width: iconSize, height: iconSize }
+ { x: endState.x, y: endState.y - iconOffset, width: iconSize, height: iconSize }
);
} else if (this.props.point) {
ctx.fillStyle = this.props.point.fill || this.context.colors.canvas.belowLayerBackground;
@@ -332,7 +350,7 @@ export class PortConnectionLayer extends Layer<
initialHeight: this.props.point.viewHeight,
},
ctx,
- { x: this.endState.x, y: this.endState.y - iconOffset, width: iconSize, height: iconSize }
+ { x: endState.x, y: endState.y - iconOffset, width: iconSize, height: iconSize }
);
}
ctx.closePath();
@@ -371,7 +389,7 @@ export class PortConnectionLayer extends Layer<
return;
}
- const params = this.getEventParams(port);
+ const params = this.getRequiredEventParams(port);
this.sourcePort = port;
this.startState = new Point(port.x, port.y);
@@ -423,8 +441,8 @@ export class PortConnectionLayer extends Layer<
this.targetPort = newTargetPort;
- const sourceParams = this.getEventParams(this.sourcePort);
- const targetParams = this.getEventParams(newTargetPort);
+ const sourceParams = this.getRequiredEventParams(this.sourcePort);
+ const targetParams = newTargetPort ? this.getRequiredEventParams(newTargetPort) : undefined;
this.context.graph.executеDefaultEventAction(
"port-connection-create-hover",
@@ -445,7 +463,7 @@ export class PortConnectionLayer extends Layer<
}
}
- protected selectPort(port: PortState, select: boolean): void {
+ protected selectPort(port: PortState | undefined, select: boolean): void {
if (!port) return;
const component = port.owner;
if (component instanceof GraphComponent) {
@@ -506,7 +524,7 @@ export class PortConnectionLayer extends Layer<
this.endState = null;
this.performRender();
- const sourceParams = this.getEventParams(this.sourcePort);
+ const sourceParams = this.getRequiredEventParams(this.sourcePort);
if (!targetPort) {
// Drop without target
@@ -541,8 +559,8 @@ export class PortConnectionLayer extends Layer<
actualTargetPort = this.sourcePort;
}
- const actualSourceParams = this.getEventParams(actualSourcePort);
- const actualTargetParams = this.getEventParams(actualTargetPort);
+ const actualSourceParams = this.getRequiredEventParams(actualSourcePort);
+ const actualTargetParams = this.getRequiredEventParams(actualTargetPort);
// Create connection
this.context.graph.executеDefaultEventAction(
@@ -568,7 +586,7 @@ export class PortConnectionLayer extends Layer<
this.selectPort(this.sourcePort, false);
this.selectPort(targetPort, false);
- const targetParams = this.getEventParams(targetPort);
+ const targetParams = this.getRequiredEventParams(targetPort);
// Drop event
this.context.graph.executеDefaultEventAction(
@@ -627,7 +645,8 @@ export class PortConnectionLayer extends Layer<
const distance = vectorDistance(point, port);
// Check custom condition if provided
- const meta = port.meta?.[PortConnectionLayer.PortMetaKey] as IPortConnectionMeta | undefined;
+ const portMeta = port.meta as Record | undefined;
+ const meta = portMeta?.[PortConnectionLayer.PortMetaKey] as IPortConnectionMeta | undefined;
if (meta?.snapCondition && sourcePort) {
const canSnap = meta.snapCondition({
sourcePort: sourcePort,
@@ -681,7 +700,8 @@ export class PortConnectionLayer extends Layer<
// Skip ports in lookup state (no valid coordinates)
if (port.lookup) continue;
- const meta = port.meta?.[PortConnectionLayer.PortMetaKey] as IPortConnectionMeta | undefined;
+ const portMeta = port.meta as Record | undefined;
+ const meta = portMeta?.[PortConnectionLayer.PortMetaKey] as IPortConnectionMeta | undefined;
if (meta?.snappable) {
snappingBoxes.push({
@@ -717,7 +737,11 @@ export class PortConnectionLayer extends Layer<
// For Anchor components, get the anchor type
if (component instanceof Anchor) {
- const anchorType = component.connectedState.state.type;
+ const connected = component.connectedState;
+ if (!connected) {
+ return null;
+ }
+ const anchorType = connected.state.type;
if (anchorType === EAnchorType.IN || anchorType === EAnchorType.OUT) {
return anchorType;
}
@@ -745,9 +769,13 @@ export class PortConnectionLayer extends Layer<
}
if (component instanceof Anchor) {
+ const connected = component.connectedState;
+ if (!connected) {
+ throw new Error("Port has anchor owner without connected state");
+ }
return {
- blockId: component.connectedState.blockId,
- anchorId: component.connectedState.id,
+ blockId: connected.blockId,
+ anchorId: connected.id,
};
}
@@ -760,6 +788,17 @@ export class PortConnectionLayer extends Layer<
return {};
}
+ /**
+ * Same as {@link getEventParams} but ensures {@link TBlockId} is present (throws if missing).
+ */
+ protected getRequiredEventParams(port: PortState): TPortEventIds {
+ const params = this.getEventParams(port);
+ if (params.blockId === undefined) {
+ throw new Error("Port connection: missing block id for port owner");
+ }
+ return { blockId: params.blockId, anchorId: params.anchorId };
+ }
+
public override unmount(): void {
if (this.portsUnsubscribe) {
this.portsUnsubscribe();
diff --git a/src/components/canvas/layers/selectionLayer/SelectionLayer.ts b/src/components/canvas/layers/selectionLayer/SelectionLayer.ts
index 127dccdd..d9daa29a 100644
--- a/src/components/canvas/layers/selectionLayer/SelectionLayer.ts
+++ b/src/components/canvas/layers/selectionLayer/SelectionLayer.ts
@@ -29,9 +29,17 @@ export class SelectionLayer extends Layer<
...props,
});
+ const canvas = this.getCanvas();
+ if (!canvas) {
+ throw new Error("SelectionLayer: canvas is required");
+ }
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ throw new Error("SelectionLayer: 2d context is required");
+ }
this.setContext({
- canvas: this.getCanvas(),
- ctx: this.getCanvas().getContext("2d"),
+ canvas,
+ ctx,
});
}
diff --git a/src/graph.ts b/src/graph.ts
index 1fb92510..49487d92 100644
--- a/src/graph.ts
+++ b/src/graph.ts
@@ -1,5 +1,4 @@
import { batch, signal } from "@preact/signals-core";
-import merge from "lodash/merge";
import { PublicGraphApi, ZoomConfig } from "./api/PublicGraphApi";
import { GraphComponent } from "./components/canvas/GraphComponent";
@@ -8,13 +7,19 @@ import { BelowLayer } from "./components/canvas/layers/belowLayer/BelowLayer";
import { CursorLayer, CursorLayerCursorTypes } from "./components/canvas/layers/cursorLayer";
import { GraphLayer } from "./components/canvas/layers/graphLayer/GraphLayer";
import { SelectionLayer } from "./components/canvas/layers/selectionLayer/SelectionLayer";
-import { TGraphColors, TGraphConstants, initGraphColors, initGraphConstants } from "./graphConfig";
-import { GraphEvent, GraphEventParams, GraphEventsDefinitions, isGraphEvent } from "./graphEvents";
+import {
+ createInitialResolvedGraphColors,
+ initGraphConstants,
+ mergeResolvedGraphColors,
+ mergeResolvedGraphConstants,
+} from "./graphConfig";
+import type { TGraphColors, TGraphConstants, TResolvedGraphColors } from "./graphConfig";
+import { GraphEvent, GraphEventListener, GraphEventParams, GraphEventsDefinitions, isGraphEvent } from "./graphEvents";
import { scheduler } from "./lib/Scheduler";
import { HitTest } from "./services/HitTest";
import { KeyboardService } from "./services/KeyboardService";
import { Layer, LayerPublicProps } from "./services/Layer";
-import { Layers } from "./services/LayersService";
+import { Layers, TLayersRootSize } from "./services/LayersService";
import { CameraService } from "./services/camera/CameraService";
import { DragService } from "./services/drag";
import { RootStore } from "./store";
@@ -24,7 +29,7 @@ import { TGraphSettingsConfig } from "./store/settings";
import { clearColorCache, getXY } from "./utils/functions";
import { clearTextCache } from "./utils/renderers/text";
import { RecursivePartial } from "./utils/types/helpers";
-import { IPoint, IRect, Point, TPoint, TRect, isTRect } from "./utils/types/shapes";
+import { IPoint, Point, TPoint, TRect, isTRect } from "./utils/types/shapes";
export type LayerConfig = Constructor> = [T, LayerPublicProps];
export type TGraphConfig = {
@@ -86,7 +91,7 @@ export class Graph {
protected readonly cursorLayer: CursorLayer;
- public getGraphCanvas() {
+ public getGraphCanvas(): HTMLCanvasElement {
return this.graphLayer.getCanvas();
}
@@ -94,7 +99,7 @@ export class Graph {
return this.$graphColors.value;
}
- public $graphColors = signal(initGraphColors);
+ public $graphColors = signal(createInitialResolvedGraphColors());
public get graphConstants() {
return this.$graphConstants.value;
@@ -104,7 +109,7 @@ export class Graph {
public state: GraphState = GraphState.INIT;
- protected config: TGraphConfig;
+ protected config!: TGraphConfig;
protected startRequested = false;
@@ -123,8 +128,8 @@ export class Graph {
constructor(
config: TGraphConfig,
rootEl?: HTMLDivElement,
- graphColors?: TGraphColors,
- graphConstants?: TGraphConstants
+ graphColors?: RecursivePartial,
+ graphConstants?: RecursivePartial
) {
this.belowLayer = this.addLayer(BelowLayer, {});
this.graphLayer = this.addLayer(GraphLayer, {});
@@ -152,21 +157,22 @@ export class Graph {
this.setupGraph(config);
}
- protected onUpdateSize = (event: IRect) => {
- this.cameraService.set(event);
+ protected onUpdateSize = (...args: unknown[]): void => {
+ const size = args[0] as TLayersRootSize;
+ this.cameraService.set({ width: size.width, height: size.height });
};
public getGraphLayer() {
return this.graphLayer;
}
- public setColors(colors: RecursivePartial) {
- this.$graphColors.value = merge({}, this.$graphColors.value, colors);
+ public setColors(colors: RecursivePartial): void {
+ this.$graphColors.value = mergeResolvedGraphColors(this.$graphColors.value, colors);
this.emit("colors-changed", { colors: this.graphColors });
}
- public setConstants(constants: RecursivePartial) {
- this.$graphConstants.value = merge({}, this.$graphConstants.value, constants);
+ public setConstants(constants: RecursivePartial): void {
+ this.$graphConstants.value = mergeResolvedGraphConstants(this.$graphConstants.value, constants);
this.emit("constants-changed", { constants: this.graphConstants });
}
@@ -265,7 +271,7 @@ export class Graph {
maxX: rect.x + rect.width,
maxY: rect.y + rect.height,
}) as InstanceType[] | [];
- if (filter.length && items.length > 0) {
+ if (filter?.length && items.length > 0) {
return items.filter((item: InstanceType) =>
filter.some((Component) => item instanceof Component)
) as InstanceType[];
@@ -315,19 +321,17 @@ export class Graph {
});
}
- public on<
- EventName extends keyof GraphEventsDefinitions = keyof GraphEventsDefinitions,
- Cb extends GraphEventsDefinitions[EventName] = GraphEventsDefinitions[EventName],
- >(type: EventName, cb: Cb, options?: AddEventListenerOptions | boolean) {
- this.eventEmitter.addEventListener(type, cb, options);
+ public on(
+ type: EventName,
+ cb: GraphEventListener,
+ options?: AddEventListenerOptions | boolean
+ ): () => void {
+ this.eventEmitter.addEventListener(type, cb as EventListener, options);
return () => this.off(type, cb);
}
- public off<
- EventName extends keyof GraphEventsDefinitions = keyof GraphEventsDefinitions,
- Cb extends GraphEventsDefinitions[EventName] = GraphEventsDefinitions[EventName],
- >(type: EventName, cb: Cb) {
- this.eventEmitter.removeEventListener(type, cb);
+ public off(type: EventName, cb: GraphEventListener): void {
+ this.eventEmitter.removeEventListener(type, cb as EventListener);
}
/*
@@ -398,7 +402,7 @@ export class Graph {
public setupGraph(config: TGraphConfig = {}) {
this.config = config;
- this.rootStore.configurationName = config.configurationName;
+ this.rootStore.configurationName = config.configurationName ?? "";
this.setEntities({
blocks: config.blocks,
connections: config.connections,
@@ -427,7 +431,12 @@ export class Graph {
if (this.state === GraphState.READY) {
return;
}
- rootEl[Symbol.for("graph")] = this;
+ Object.defineProperty(rootEl, Symbol.for("graph"), {
+ value: this,
+ enumerable: false,
+ writable: true,
+ configurable: true,
+ });
this.layers.attach(rootEl);
const { width: rootWidth, height: rootHeight } = this.layers.getRootSize();
@@ -442,7 +451,7 @@ export class Graph {
}
}
- public start(rootEl: HTMLDivElement = this.layers.$root): void {
+ public start(rootEl?: HTMLDivElement): void {
if (this.state !== GraphState.ATTACHED) {
this.startRequested = true;
return;
@@ -450,8 +459,9 @@ export class Graph {
if (this.state >= GraphState.READY) {
throw new Error("Graph already started");
}
- if (rootEl) {
- this.attach(rootEl);
+ const attachRoot = rootEl ?? this.layers.$root;
+ if (attachRoot) {
+ this.attach(attachRoot);
}
this.layers.on("update-size", this.onUpdateSize);
this.layers.start();
diff --git a/src/graphConfig.ts b/src/graphConfig.ts
index 86b47c5d..08d87cf8 100644
--- a/src/graphConfig.ts
+++ b/src/graphConfig.ts
@@ -1,6 +1,11 @@
+import merge from "lodash/merge";
+
+import type { TGraphComponentProps } from "./components/canvas/GraphComponent";
import { GraphComponent } from "./components/canvas/GraphComponent";
import { Block } from "./components/canvas/blocks/Block";
+import type { Component } from "./lib/Component";
import { ESelectionStrategy } from "./services/selection";
+import type { RecursivePartial } from "./utils/types/helpers";
export type { TResolveWheelDevice } from "./utils/functions/isTrackpadDetector";
export { defaultResolveWheelDevice, EWheelDeviceKind } from "./utils/functions/isTrackpadDetector";
@@ -52,6 +57,18 @@ export type TCanvasColors = {
border: string;
};
+/**
+ * Colors after merge with defaults (`initGraphColors`). All sections and keys are defined.
+ */
+export type TResolvedGraphColors = {
+ canvas: TCanvasColors;
+ block: TBlockColors;
+ anchor: TAnchorColors;
+ connection: TConnectionColors;
+ connectionLabel: TConnectionLabelColors;
+ selection: TSelectionColors;
+};
+
export type TMouseWheelBehavior = "zoom" | "scroll";
export const initGraphColors: TGraphColors = {
@@ -89,10 +106,21 @@ export const initGraphColors: TGraphColors = {
},
};
+export function createInitialResolvedGraphColors(): TResolvedGraphColors {
+ return merge({}, initGraphColors) as TResolvedGraphColors;
+}
+
+export function mergeResolvedGraphColors(
+ current: TResolvedGraphColors,
+ patch: RecursivePartial
+): TResolvedGraphColors {
+ return merge({}, current, patch) as TResolvedGraphColors;
+}
+
/**
* Constructor type for any class that extends GraphComponent
*/
-export type GraphComponentConstructor = new (...args: unknown[]) => GraphComponent;
+export type GraphComponentConstructor = new (props: TGraphComponentProps, parent: Component) => GraphComponent;
export type TGraphConstants = {
/**
@@ -256,7 +284,8 @@ export type TGraphConstants = {
export const initGraphConstants: TGraphConstants = {
selectionLayer: {
- SELECTABLE_ENTITY_TYPES: [Block],
+ /* Block ctor is stricter (TBlockProps) than GraphComponentConstructor's first arg; safe for selection. */
+ SELECTABLE_ENTITY_TYPES: [Block as GraphComponentConstructor],
STRATEGY: ESelectionStrategy.REPLACE,
},
system: {
@@ -303,3 +332,10 @@ export const initGraphConstants: TGraphConstants = {
PADDING: 10,
},
};
+
+export function mergeResolvedGraphConstants(
+ current: TGraphConstants,
+ patch: RecursivePartial
+): TGraphConstants {
+ return merge({}, current, patch) as TGraphConstants;
+}
diff --git a/src/graphEvents.ts b/src/graphEvents.ts
index c68b641f..ad4325f9 100644
--- a/src/graphEvents.ts
+++ b/src/graphEvents.ts
@@ -1,6 +1,6 @@
import { EventedComponent } from "./components/canvas/EventedComponent/EventedComponent";
import { GraphState } from "./graph";
-import { TGraphColors, TGraphConstants } from "./graphConfig";
+import { TGraphConstants, TResolvedGraphColors } from "./graphConfig";
import { TCameraState } from "./services/camera/CameraService";
import { TSelectionDiff, TSelectionEntityId } from "./services/selection/types";
@@ -40,7 +40,7 @@ export type UnwrapBaseGraphEventsDetail<
export interface GraphEventsDefinitions extends BaseGraphEventDefinition {
"camera-change": (event: CustomEvent) => void;
"constants-changed": (event: CustomEvent<{ constants: TGraphConstants }>) => void;
- "colors-changed": (event: CustomEvent<{ colors: TGraphColors }>) => void;
+ "colors-changed": (event: CustomEvent<{ colors: TResolvedGraphColors }>) => void;
"state-change": (event: CustomEvent<{ state: GraphState }>) => void;
}
const graphMouseEvents = ["mousedown", "click", "dblclick", "mouseenter", "mousemove", "mouseleave"];
@@ -50,6 +50,11 @@ export type UnwrapGraphEvents<
T extends GraphEventsDefinitions[Key] = GraphEventsDefinitions[Key],
P extends Parameters[0] = Parameters[0],
> = P extends CustomEvent ? P : never;
+
+/** Single-argument listener used by `Graph.on` / `Graph.off` and the DOM emitter. */
+export type GraphEventListener = (
+ event: UnwrapGraphEvents
+) => void;
export type UnwrapGraphEventsDetail<
Key extends keyof GraphEventsDefinitions,
T extends GraphEventsDefinitions[Key] = GraphEventsDefinitions[Key],
diff --git a/src/index.ts b/src/index.ts
index 429c1501..f0126b04 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -3,10 +3,10 @@ export { Block as CanvasBlock, type TBlock } from "./components/canvas/blocks/Bl
export { GraphComponent } from "./components/canvas/GraphComponent";
export * from "./components/canvas/connections";
export * from "./graph";
-export type { TGraphColors, TGraphConstants, TMouseWheelBehavior } from "./graphConfig";
+export type { TGraphColors, TGraphConstants, TMouseWheelBehavior, TResolvedGraphColors } from "./graphConfig";
export type { TResolveWheelDevice } from "./utils/functions/isTrackpadDetector";
export { defaultResolveWheelDevice, EWheelDeviceKind } from "./utils/functions/isTrackpadDetector";
-export { type UnwrapGraphEventsDetail, type SelectionEvent } from "./graphEvents";
+export { type GraphEventListener, type SelectionEvent, type UnwrapGraphEventsDetail } from "./graphEvents";
export * from "./plugins";
export {
defaultGetCameraBlockScaleLevel,
diff --git a/src/lib/Component.ts b/src/lib/Component.ts
index 36a24344..542cc13d 100644
--- a/src/lib/Component.ts
+++ b/src/lib/Component.ts
@@ -25,7 +25,7 @@ export class Component<
nextState: undefined,
};
- constructor(props: Props, parent: CoreComponent) {
+ constructor(props: Props, parent?: CoreComponent) {
super(props, parent);
this.state = {} as State;
}
diff --git a/src/lib/CoreComponent.ts b/src/lib/CoreComponent.ts
index f8688ee7..b0680592 100644
--- a/src/lib/CoreComponent.ts
+++ b/src/lib/CoreComponent.ts
@@ -9,6 +9,18 @@ type TOptions = {
export type TCoreComponent = CoreComponent;
+export type CoreComponentProps = Record;
+export type CoreComponentContext = Record;
+
+export type ComponentDescriptor<
+ Props extends CoreComponentProps = CoreComponentProps,
+ Context extends CoreComponentContext = CoreComponentContext,
+> = {
+ props: Props;
+ options: TOptions;
+ klass: Constructor>;
+};
+
type TPrivateComponentData = {
parent: CoreComponent | undefined;
treeNode: Tree;
@@ -16,24 +28,19 @@ type TPrivateComponentData = {
scheduler: Scheduler;
globalIterateId: number;
};
- children: object;
+ children: Record;
childrenKeys: string[];
- prevChildrenArr: object[];
+ prevChildrenArr: ComponentDescriptor[];
updated: boolean;
iterateId: number;
};
-export type CoreComponentProps = Record;
-export type CoreComponentContext = Record;
-
-export type ComponentDescriptor<
- Props extends CoreComponentProps = CoreComponentProps,
- Context extends CoreComponentContext = CoreComponentContext,
-> = {
- props: Props;
- options: TOptions;
- klass: Constructor>;
-};
+function resolveChildDescriptorKey(descriptor: ComponentDescriptor, index: number): string {
+ if (Object.prototype.hasOwnProperty.call(descriptor.options, "key") && descriptor.options.key !== undefined) {
+ return descriptor.options.key;
+ }
+ return `${descriptor.klass.name}|${index}|defaultKey`;
+}
function createDefaultPrivateContext() {
return {
@@ -75,7 +82,7 @@ export class CoreComponent<
parent,
context: parent ? parent.__comp.context : createDefaultPrivateContext(),
treeNode: new Tree(this),
- children: {},
+ children: {} as Record,
childrenKeys: [],
prevChildrenArr: [],
updated: false,
@@ -150,7 +157,8 @@ export class CoreComponent<
const __comp = this.__comp;
const children = __comp.children;
const childrenKeys = __comp.childrenKeys;
- const nextChildrenKeys = (__comp.childrenKeys = []);
+ const nextChildrenKeys: string[] = [];
+ __comp.childrenKeys = nextChildrenKeys;
if (nextChildrenArr === __comp.prevChildrenArr) return;
@@ -170,8 +178,10 @@ export class CoreComponent<
key = childrenKeys[i];
child = children[key];
- child.__unmount();
- children[key] = undefined;
+ if (child) {
+ child.__unmount();
+ children[key] = undefined;
+ }
}
}
@@ -182,33 +192,32 @@ export class CoreComponent<
if (nextChildrenArr.length > 0) {
for (let i = 0; i < nextChildrenArr.length; i += 1) {
child = nextChildrenArr[i];
- // eslint-disable-next-line no-prototype-builtins
- key = child.options.hasOwnProperty("key") ? child.options.key : `${child.klass.name}|${i}|defaultKey`;
+ key = resolveChildDescriptorKey(child, i);
ref = child.options.ref;
// eslint-disable-next-line new-cap
- children[key] = new child.klass(child.props, this);
+ const mounted = new child.klass(child.props, this);
+ children[key] = mounted;
if (typeof ref === "function") {
- ref(children[key]);
+ ref(mounted);
} else if (typeof ref === "string") {
- this.$[ref] = children[key];
+ (this.$ as Record)[ref] = mounted;
}
nextChildrenKeys.push(key);
- treeNode.append(children[key].__comp.treeNode);
+ treeNode.append(mounted.__comp.treeNode);
}
}
return;
}
- const childForMount = [];
- const keyForMount = [];
+ const childForMount: ComponentDescriptor[] = [];
+ const keyForMount: string[] = [];
for (let i = 0; i < nextChildrenArr.length; i += 1) {
child = nextChildrenArr[i];
- // eslint-disable-next-line no-prototype-builtins
- key = child.options.hasOwnProperty("key") ? child.options.key : `${child.klass.name}|${i}|defaultKey`;
+ key = resolveChildDescriptorKey(child, i);
currentChild = children[key];
nextChildrenKeys.push(key);
@@ -241,16 +250,17 @@ export class CoreComponent<
}
for (let i = 0; i < childForMount.length; i += 1) {
- child = childForMount[i];
+ const descriptor = childForMount[i];
key = keyForMount[i];
- ref = child.options.ref;
+ ref = descriptor.options.ref;
// eslint-disable-next-line new-cap
- child = children[key] = new child.klass(child.props, this);
+ const mounted = new descriptor.klass(descriptor.props, this);
+ children[key] = mounted;
if (typeof ref === "function") {
- ref(children[key]);
+ ref(mounted);
} else if (typeof ref === "string") {
- this.$[ref] = children[key];
+ (this.$ as Record)[ref] = mounted;
}
}
@@ -268,7 +278,10 @@ export class CoreComponent<
const childrenKeys = this.__comp.childrenKeys;
for (let i = 0; i < childrenKeys.length; i += 1) {
- children[childrenKeys[i]].__unmount();
+ const node = children[childrenKeys[i]];
+ if (node) {
+ node.__unmount();
+ }
}
}
@@ -293,7 +306,9 @@ export class CoreComponent<
return root;
}
- public static unmount(instance) {
+ public static unmount(
+ instance: CoreComponent
+ ): void {
instance.__unmount();
}
}
diff --git a/src/lib/Scheduler.ts b/src/lib/Scheduler.ts
index ed4742c7..b00e44bd 100644
--- a/src/lib/Scheduler.ts
+++ b/src/lib/Scheduler.ts
@@ -1,7 +1,23 @@
import { Tree } from "./Tree";
-const rAF: Function = typeof window !== "undefined" ? window.requestAnimationFrame : (fn) => global.setTimeout(fn, 16);
-const cAF: Function = typeof window !== "undefined" ? window.cancelAnimationFrame : global.clearTimeout;
+type TFrameCallback = (time: number) => void;
+
+const rAF =
+ typeof window !== "undefined"
+ ? (callback: TFrameCallback): number => window.requestAnimationFrame(callback)
+ : (callback: TFrameCallback): number =>
+ global.setTimeout(() => {
+ callback(0);
+ }, 16) as unknown as number;
+
+const cAF =
+ typeof window !== "undefined"
+ ? (id: number): void => {
+ window.cancelAnimationFrame(id);
+ }
+ : (id: number): void => {
+ global.clearTimeout(id);
+ };
const getNow =
typeof window !== "undefined" ? window.performance.now.bind(window.performance) : global.Date.now.bind(global.Date);
@@ -18,7 +34,7 @@ export enum ESchedulerPriority {
}
export class GlobalScheduler {
private schedulers: [IScheduler[], IScheduler[], IScheduler[], IScheduler[], IScheduler[]];
- private _cAFID: number;
+ private _cAFID: number | undefined;
private toRemove: Array<[IScheduler, ESchedulerPriority]> = [];
private visibilityChangeHandler: (() => void) | null = null;
@@ -85,8 +101,10 @@ export class GlobalScheduler {
}
}
- public stop() {
- cAF(this._cAFID);
+ public stop(): void {
+ if (this._cAFID !== undefined) {
+ cAF(this._cAFID);
+ }
this._cAFID = undefined;
}
@@ -131,18 +149,17 @@ export const globalScheduler = new GlobalScheduler();
export const scheduler = globalScheduler;
export class Scheduler {
- private sheduled: boolean;
- private root: Tree;
+ private sheduled = false;
+
+ private root!: Tree;
constructor() {
this.performUpdate = this.performUpdate.bind(this);
- this.sheduled = false;
-
globalScheduler.addScheduler(this);
}
- public setRoot(root: Tree) {
+ public setRoot(root: Tree): void {
this.root = root;
}
diff --git a/src/lib/Tree.ts b/src/lib/Tree.ts
index fea16e4e..fb3ec03b 100644
--- a/src/lib/Tree.ts
+++ b/src/lib/Tree.ts
@@ -8,7 +8,8 @@ export interface ITree {
export class Tree {
public data: T;
- public parent: Tree;
+
+ public parent?: Tree;
public children: Set = new Set();
@@ -31,7 +32,9 @@ export class Tree {
constructor(data: T, parent?: Tree) {
this.data = data;
- this.parent = parent;
+ if (parent !== undefined) {
+ this.parent = parent;
+ }
}
public append(node: Tree) {
@@ -45,7 +48,7 @@ export class Tree {
if (!this.zIndexGroups.has(node.zIndex)) {
this.zIndexGroups.set(node.zIndex, new Set());
}
- this.zIndexGroups.get(node.zIndex).add(node);
+ this.zIndexGroups.get(node.zIndex)?.add(node);
this.zIndexChildrenCache.reset();
}
@@ -61,7 +64,9 @@ export class Tree {
}
public remove(node: Tree = this) {
- if (node.parent === null) return;
+ if (node.parent === undefined) {
+ return;
+ }
this.children.delete(node);
this.childrenDirty = true;
this.removeZIndex(node);
@@ -100,12 +105,8 @@ export class Tree {
this.zIndexChildrenCache.clear();
}
- public traverseDown(iterator: TIterator) {
- this._traverse(iterator, "_walkDown");
- }
-
- private _traverse(iterator: TIterator, strategyName: string) {
- this[strategyName](iterator);
+ public traverseDown(iterator: TIterator): void {
+ this._walkDown(iterator, 0);
}
protected getChildrenArray() {
@@ -124,7 +125,10 @@ export class Tree {
}
const children = this.zIndexChildrenCache.get();
for (let i = 0; i < children.length; i++) {
- children[i]._walkDown(iterator, i);
+ const child = children[i];
+ if (child !== undefined) {
+ child._walkDown(iterator, i);
+ }
}
}
}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 8cc2fa76..6a9412a8 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -1,30 +1,31 @@
-export function assign(target: T, source: S): T & S {
- const props = Object.keys(source);
- let prop;
+export function assign(target: T, source: S): T & S {
+ const keys = Object.keys(source) as (keyof S & string)[];
+ const src = source as Record;
+ const dst = target as Record;
- for (let i = 0; i < props.length; i += 1) {
- prop = props[i];
- target[prop] = source[prop];
+ for (let i = 0; i < keys.length; i += 1) {
+ const key = keys[i];
+ dst[key] = src[key];
}
return target as T & S;
}
export function cache(fn: () => T) {
- let result: T;
+ let result: T | undefined;
let touched = true;
return {
- get: () => {
+ get: (): T => {
if (touched) {
result = fn();
touched = false;
}
- return result;
+ return result as T;
},
- reset() {
+ reset(): void {
touched = true;
},
- clear() {
+ clear(): void {
touched = true;
result = undefined;
},
diff --git a/src/plugins/cssVariables/CSSVariablesLayer.ts b/src/plugins/cssVariables/CSSVariablesLayer.ts
index e7057f1b..b03cbe6c 100644
--- a/src/plugins/cssVariables/CSSVariablesLayer.ts
+++ b/src/plugins/cssVariables/CSSVariablesLayer.ts
@@ -1,5 +1,6 @@
/* eslint-disable no-console */
import merge from "lodash/merge";
+
import StyleObserver from "style-observer";
import { Layer, LayerContext } from "../../services/Layer";
diff --git a/src/plugins/devtools/DevToolsLayer.ts b/src/plugins/devtools/DevToolsLayer.ts
index 70da5c62..6ad94c57 100644
--- a/src/plugins/devtools/DevToolsLayer.ts
+++ b/src/plugins/devtools/DevToolsLayer.ts
@@ -53,7 +53,7 @@ export class DevToolsLayer extends Layer = {
+/** Default theme/visual options for DevToolsLayer (excludes Layer canvas/html) */
+export const DEFAULT_DEVTOOLS_LAYER_PROPS: Required<
+ Pick<
+ TDevToolsLayerProps,
+ | "showRuler"
+ | "showCrosshair"
+ | "rulerSize"
+ | "minMajorTickDistance"
+ | "rulerBackgroundColor"
+ | "rulerTickColor"
+ | "rulerTextColor"
+ | "rulerTextFont"
+ | "crosshairColor"
+ | "crosshairTextColor"
+ | "crosshairTextFont"
+ | "crosshairTextBackgroundColor"
+ | "rulerBackdropBlur"
+ >
+> = {
showRuler: true,
showCrosshair: true,
rulerSize: 25,
diff --git a/src/plugins/minimap/layer.ts b/src/plugins/minimap/layer.ts
index 03866fec..a067e1e8 100644
--- a/src/plugins/minimap/layer.ts
+++ b/src/plugins/minimap/layer.ts
@@ -83,15 +83,23 @@ export class MiniMapLayer extends Layer {
}
protected updateCanvasSize(): void {
+ const canvas = this.canvas;
+ if (!canvas) {
+ return;
+ }
const dpr = this.getDRP();
- this.canvas.width = this.minimapWidth * dpr;
- this.canvas.height = this.minimapHeight * dpr;
+ canvas.width = this.minimapWidth * dpr;
+ canvas.height = this.minimapHeight * dpr;
}
protected willRender(): void {
+ const canvas = this.canvas;
+ if (!canvas) {
+ return;
+ }
if (this.firstRender) {
- this.canvas.style.width = `${this.minimapWidth}px`;
- this.canvas.style.height = `${this.minimapHeight}px`;
+ canvas.style.width = `${this.minimapWidth}px`;
+ canvas.style.height = `${this.minimapHeight}px`;
}
}
@@ -114,7 +122,7 @@ export class MiniMapLayer extends Layer {
}
private injectPositionStyle(): void {
- const minimapPosition = this.getPositionOfMiniMap(this.props.location);
+ const minimapPosition = this.getPositionOfMiniMap(this.props.location ?? "topLeft");
const style = document.createElement("style");
style.innerHTML = `
.layer.graph-minimap {
@@ -127,7 +135,11 @@ export class MiniMapLayer extends Layer {
border: 2px solid var(--g-color-private-cool-grey-1000-solid);
background: lightgrey;
}`;
- this.root.appendChild(style);
+ const rootEl = this.root;
+ if (!rootEl) {
+ return;
+ }
+ rootEl.appendChild(style);
}
private calculateViewPortCoords(): void {
diff --git a/src/react-components/Anchor.tsx b/src/react-components/Anchor.tsx
index dcd4f336..4f9baf68 100644
--- a/src/react-components/Anchor.tsx
+++ b/src/react-components/Anchor.tsx
@@ -4,7 +4,7 @@ import { TAnchor } from "../components/canvas/anchors";
import { Graph } from "../graph";
import { AnchorState } from "../store/anchor/Anchor";
-import { useSignal } from "./hooks";
+import { useComputedSignal } from "./hooks";
import { useBlockAnchorPosition, useBlockAnchorState } from "./hooks/useBlockAnchorState";
import { cn } from "./utils/cn";
@@ -25,7 +25,7 @@ export function GraphBlockAnchor({
}) {
const anchorContainerRef = React.useRef(null);
const anchorState = useBlockAnchorState(graph, anchor);
- const selected = useSignal(anchorState?.$selected);
+ const selected = useComputedSignal(() => anchorState?.$selected.value ?? false, [anchorState]);
const [raised, setRaised] = React.useState(false);
useBlockAnchorPosition(anchorState, anchorContainerRef);
diff --git a/src/react-components/Block.tsx b/src/react-components/Block.tsx
index 774c9b78..a06c8015 100644
--- a/src/react-components/Block.tsx
+++ b/src/react-components/Block.tsx
@@ -98,7 +98,7 @@ function GraphBlockInner(
ref: ForwardedRef
) {
const containerRef = useRef(null);
- useImperativeHandle(ref, () => containerRef.current);
+ useImperativeHandle(ref, () => containerRef.current as HTMLDivElement);
const lastStateRef = useRef({ x: 0, y: 0, width: 0, height: 0, zIndex: 0 });
const state = useBlockState(graph, block);
@@ -169,10 +169,10 @@ function GraphBlockInner(
*/
useEffect(() => {
if (!viewState) return;
- setInteractive(viewState.isInteractive());
+ setInteractive(Boolean(viewState.isInteractive()));
// eslint-disable-next-line consistent-return
return viewState.onChange(() => {
- setInteractive(viewState.isInteractive());
+ setInteractive(Boolean(viewState.isInteractive()));
});
}, [viewState]);
diff --git a/src/react-components/GraphCanvas.tsx b/src/react-components/GraphCanvas.tsx
index 62ebe7a6..175d2ee2 100644
--- a/src/react-components/GraphCanvas.tsx
+++ b/src/react-components/GraphCanvas.tsx
@@ -1,10 +1,11 @@
import React, { useEffect, useLayoutEffect, useRef } from "react";
-import { TGraphColors } from "..";
import { Graph } from "../graph";
+import type { TResolvedGraphColors } from "../graphConfig";
import { setCssProps } from "../utils/functions/cssProp";
import { TBlockListProps } from "./BlocksList";
+import type { TRenderBlockFn } from "./BlocksList";
import { GraphContextProvider } from "./GraphContext";
import { TGraphEventCallbacks } from "./events";
import { useLayer } from "./hooks";
@@ -15,6 +16,8 @@ import { useFn } from "./utils/hooks/useFn";
import "./graph-canvas.css";
+const fallbackRenderBlock: TRenderBlockFn = () => <>>;
+
export type GraphProps = Pick, "renderBlock"> &
Partial & {
className?: string;
@@ -33,7 +36,7 @@ export function GraphCanvas({
children,
...cbs
}: GraphProps) {
- const containerRef = useRef();
+ const containerRef = useRef(null);
const reactLayer = useLayer(graph, ReactLayer, {
blockListClassName,
@@ -55,8 +58,8 @@ export function GraphCanvas({
useGraphEvents(graph, cbs);
- const setColors = useFn((colors: TGraphColors) => {
- setCssProps(containerRef.current, {
+ const setColors = useFn((colors: TResolvedGraphColors) => {
+ setCssProps(containerRef.current ?? null, {
"--graph-block-bg": colors.block.background,
"--graph-block-border": colors.block.border,
"--graph-block-border-selected": colors.block.selectedBorder,
@@ -77,7 +80,7 @@ export function GraphCanvas({
- {graph && reactLayer && reactLayer.renderPortal(renderBlock)}
+ {graph && reactLayer && reactLayer.renderPortal(renderBlock ?? fallbackRenderBlock)}
{children}
diff --git a/src/react-components/GraphLayer.tsx b/src/react-components/GraphLayer.tsx
index 0eff3798..d7a9d9a5 100644
--- a/src/react-components/GraphLayer.tsx
+++ b/src/react-components/GraphLayer.tsx
@@ -34,10 +34,10 @@ export interface GraphLayerProps = Constructor
/**
* GraphLayer component provides declarative way to add existing Layer classes to the graph
*/
-export const GraphLayer = forwardRef>, GraphLayerProps>(
+export const GraphLayer = forwardRef> | null, GraphLayerProps>(
function GraphLayer>(
{ layer: LayerClass, props }: GraphLayerProps,
- ref
+ ref: React.ForwardedRef> | null>
): React.ReactElement | null {
// Get graph from context
const { graph } = useGraphContext();
@@ -51,10 +51,14 @@ export const GraphLayer = forwardRef));
// Expose layer through ref
- useImperativeHandle(ref, () => layer, [layer]);
+ useImperativeHandle(
+ ref as React.Ref> | null>,
+ (): LayerInstanceForConstructor> | null => layer,
+ [layer]
+ );
// GraphLayer doesn't render any visible content
return null;
diff --git a/src/react-components/GraphPortal.tsx b/src/react-components/GraphPortal.tsx
index 2ccf5c18..f2794983 100644
--- a/src/react-components/GraphPortal.tsx
+++ b/src/react-components/GraphPortal.tsx
@@ -50,7 +50,7 @@ class GraphPortalLayer extends Layer
* ```
*/
-export const GraphPortal = forwardRef(function GraphPortal(
+export const GraphPortal = forwardRef(function GraphPortal(
{ className, zIndex, transformByCameraPosition = false, children }: GraphPortalProps,
ref
): React.ReactElement | null {
@@ -145,7 +145,7 @@ export const GraphPortal = forwardRef(functi
});
// Expose layer through ref
- useImperativeHandle(ref, () => layer, [layer]);
+ useImperativeHandle(ref as React.Ref, (): GraphPortalLayer | null => layer, [layer]);
// If graph is not ready or layer not yet created, don't render portal
if (!graph || graphState < GraphState.ATTACHED || !layer) {
diff --git a/src/react-components/elk/converters/eklConverter.ts b/src/react-components/elk/converters/eklConverter.ts
index 4e85c274..89899794 100644
--- a/src/react-components/elk/converters/eklConverter.ts
+++ b/src/react-components/elk/converters/eklConverter.ts
@@ -3,10 +3,11 @@ import { ElkExtendedEdge, ElkNode } from "elkjs";
import { ConverterResult } from "../types";
const convertElkEdges = (edges?: ElkExtendedEdge[]): ConverterResult["edges"] => {
- return edges.reduce((acc, edge) => {
- if ("sections" in edge) {
+ return (edges ?? []).reduce((acc, edge) => {
+ const firstSection = "sections" in edge ? edge.sections?.[0] : undefined;
+ if (firstSection) {
acc[edge.id] = {
- points: [edge.sections[0].startPoint, ...(edge.sections[0].bendPoints || []), edge.sections[0].endPoint],
+ points: [firstSection.startPoint, ...(firstSection.bendPoints ?? []), firstSection.endPoint],
labels: edge.labels,
};
}
@@ -16,10 +17,10 @@ const convertElkEdges = (edges?: ElkExtendedEdge[]): ConverterResult["edges"] =>
};
const convertElkChildren = (childrens: ElkNode[]): ConverterResult["blocks"] => {
- return childrens.reduce((acc, children) => {
+ return childrens.reduce((acc, children) => {
acc[children.id] = {
- x: children.x,
- y: children.y,
+ x: children.x ?? 0,
+ y: children.y ?? 0,
};
return acc;
}, {});
@@ -28,6 +29,6 @@ const convertElkChildren = (childrens: ElkNode[]): ConverterResult["blocks"] =>
export const elkConverter = (node: ElkNode): ConverterResult => {
return {
edges: convertElkEdges(node.edges),
- blocks: convertElkChildren(node.children),
+ blocks: convertElkChildren(node.children ?? []),
};
};
diff --git a/src/react-components/elk/hooks/useElk.ts b/src/react-components/elk/hooks/useElk.ts
index d42f53a1..9367cc10 100644
--- a/src/react-components/elk/hooks/useElk.ts
+++ b/src/react-components/elk/hooks/useElk.ts
@@ -27,9 +27,9 @@ export const useElk = (
setResult(elkConverter(data));
setIsLoading(false);
})
- .catch((error) => {
+ .catch((error: unknown) => {
if (!isCancelled) {
- args?.onError(error);
+ args?.onError?.(error instanceof Error ? error : new Error(String(error)));
setIsLoading(false);
}
});
diff --git a/src/react-components/events.ts b/src/react-components/events.ts
index 0fcd9a92..08456b43 100644
--- a/src/react-components/events.ts
+++ b/src/react-components/events.ts
@@ -1,33 +1,10 @@
import { GraphEventsDefinitions, UnwrapGraphEvents, UnwrapGraphEventsDetail } from "../graphEvents";
-export type TGraphEventCallbacks = {
- click: (data: UnwrapGraphEventsDetail<"click">, event: UnwrapGraphEvents<"click">) => void;
- dblclick: (data: UnwrapGraphEventsDetail<"dblclick">, event: UnwrapGraphEvents<"dblclick">) => void;
- onCameraChange: (data: UnwrapGraphEventsDetail<"camera-change">, event: UnwrapGraphEvents<"camera-change">) => void;
- onBlockDragStart: (
- data: UnwrapGraphEventsDetail<"block-drag-start">,
- event: UnwrapGraphEvents<"block-drag-start">
- ) => void;
- onBlockDrag: (data: UnwrapGraphEventsDetail<"block-drag">, event: UnwrapGraphEvents<"block-drag">) => void;
- onBlockDragEnd: (data: UnwrapGraphEventsDetail<"block-drag-end">, event: UnwrapGraphEvents<"block-drag-end">) => void;
- onBlockSelectionChange: (
- data: UnwrapGraphEventsDetail<"blocks-selection-change">,
- event: UnwrapGraphEvents<"blocks-selection-change">
- ) => void;
- onBlockAnchorSelectionChange: (
- data: UnwrapGraphEventsDetail<"block-anchor-selection-change">,
- event: UnwrapGraphEvents<"block-anchor-selection-change">
- ) => void;
- onBlockChange: (data: UnwrapGraphEventsDetail<"block-change">, event: UnwrapGraphEvents<"block-change">) => void;
- onConnectionSelectionChange: (
- data: UnwrapGraphEventsDetail<"connection-selection-change">,
- event: UnwrapGraphEvents<"connection-selection-change">
- ) => void;
- onStateChanged: (data: UnwrapGraphEventsDetail<"state-change">, event: UnwrapGraphEvents<"state-change">) => void;
-};
-
-export type GraphEventDetail = Parameters[0];
-export type GraphEvent = Parameters[0];
+/** React-style handler: `(detail, fullEvent)` for the graph event named `N`. */
+export type GraphReactHandler = (
+ data: UnwrapGraphEventsDetail,
+ event: UnwrapGraphEvents
+) => void;
export const GraphCallbacksMap = {
click: "click",
@@ -41,4 +18,14 @@ export const GraphCallbacksMap = {
onBlockChange: "block-change",
onConnectionSelectionChange: "connection-selection-change",
onStateChanged: "state-change",
-} as const satisfies Record;
+} as const;
+
+export type GraphCallbackKey = keyof typeof GraphCallbacksMap;
+
+export type TGraphEventCallbacks = {
+ [K in GraphCallbackKey]: GraphReactHandler<(typeof GraphCallbacksMap)[K]>;
+};
+
+export type GraphEventDetail = Parameters[0];
+/** Full `CustomEvent` instance passed as the second argument to React-style graph callbacks. */
+export type GraphEvent = Parameters[1];
diff --git a/src/react-components/hooks/schedulerHooks.test.ts b/src/react-components/hooks/schedulerHooks.test.ts
index 73acc1f4..b94f6b74 100644
--- a/src/react-components/hooks/schedulerHooks.test.ts
+++ b/src/react-components/hooks/schedulerHooks.test.ts
@@ -20,18 +20,15 @@ describe("useSchedulerDebounce hook", () => {
/**
* Advance animation frames. By default, each frame is 16ms (~60fps).
- * Jest fake timers automatically sync performance.now() with timer advancement.
- * We manually trigger scheduler.performUpdate() because our Scheduler
- * uses a custom rAF implementation.
+ * Jest fake timers advance `performance.now()` and run `requestAnimationFrame` callbacks,
+ * which drives `GlobalScheduler.tick()` → one `performUpdate()` per frame.
+ * Avoid calling `scheduler.performUpdate()` here — it would double-count frames.
* @param count - Number of frames to advance
* @param timePerFrame - Time per frame in milliseconds (default: 16ms)
*/
const advanceFrames = (count: number, timePerFrame = 16) => {
for (let i = 0; i < count; i++) {
- // Advance Jest timers - this also advances performance.now() automatically
jest.advanceTimersByTime(timePerFrame);
- // Manually trigger scheduler update (our Scheduler uses custom rAF)
- scheduler.performUpdate();
}
};
@@ -163,7 +160,7 @@ describe("useSchedulerDebounce hook", () => {
* NOTE: Skipped due to complex timing synchronization between frameInterval and frameTimeout.
* The debounce/throttle logic requires BOTH conditions to be met (frames AND time),
* but testing this reliably with Jest fake timers is challenging due to the interaction
- * between scheduler.performUpdate() and performance.now() timing.
+ * between rAF-driven scheduler ticks and performance.now() timing.
* Frame-only tests (frameTimeout: 0) work correctly and cover the main use cases.
*/
it.skip("should respect frameTimeout option", () => {
@@ -529,18 +526,15 @@ describe("useSchedulerThrottle hook", () => {
/**
* Advance animation frames. By default, each frame is 16ms (~60fps).
- * Jest fake timers automatically sync performance.now() with timer advancement.
- * We manually trigger scheduler.performUpdate() because our Scheduler
- * uses a custom rAF implementation.
+ * Jest fake timers advance `performance.now()` and run `requestAnimationFrame` callbacks,
+ * which drives `GlobalScheduler.tick()` → one `performUpdate()` per frame.
+ * Avoid calling `scheduler.performUpdate()` here — it would double-count frames.
* @param count - Number of frames to advance
* @param timePerFrame - Time per frame in milliseconds (default: 16ms)
*/
const advanceFrames = (count: number, timePerFrame = 16) => {
for (let i = 0; i < count; i++) {
- // Advance Jest timers - this also advances performance.now() automatically
jest.advanceTimersByTime(timePerFrame);
- // Manually trigger scheduler update (our Scheduler uses custom rAF)
- scheduler.performUpdate();
}
};
diff --git a/src/react-components/hooks/useBlockAnchorState.ts b/src/react-components/hooks/useBlockAnchorState.ts
index 37f87db5..9831f983 100644
--- a/src/react-components/hooks/useBlockAnchorState.ts
+++ b/src/react-components/hooks/useBlockAnchorState.ts
@@ -17,7 +17,7 @@ export function useBlockAnchorState(graph: Graph, anchor: TAnchor): AnchorState
export function useBlockAnchorPosition(
state: AnchorState | undefined,
- anchorContainerRef: React.MutableRefObject | undefined
+ anchorContainerRef: React.RefObject | undefined
) {
useSignalEffect(() => {
if (!state || !anchorContainerRef?.current) {
diff --git a/src/react-components/hooks/useGraph.ts b/src/react-components/hooks/useGraph.ts
index b914dbad..bc6d2513 100644
--- a/src/react-components/hooks/useGraph.ts
+++ b/src/react-components/hooks/useGraph.ts
@@ -7,6 +7,7 @@ import type { TGraphZoomTarget } from "../../graph";
import type { TGraphColors, TGraphConstants } from "../../graphConfig";
import type { Layer, LayerPublicProps } from "../../services/Layer";
import type { TConnection } from "../../store/connection/ConnectionState";
+import type { TGraphSettingsConfig } from "../../store/settings";
import { RecursivePartial } from "../../utils/types/helpers";
import { useFn } from "../utils/hooks/useFn";
@@ -43,16 +44,18 @@ export function useGraph(config: HookGraphParams) {
}, [graph]);
const setViewConfiguration = useFn((viewConfig: HookGraphParams["viewConfiguration"]) => {
- if (viewConfig.colors) {
- graph.setColors(config.viewConfiguration.colors);
+ if (viewConfig?.colors) {
+ graph.setColors(viewConfig.colors);
}
- if (viewConfig.constants) {
- graph.setConstants(config.viewConfiguration.constants);
+ if (viewConfig?.constants) {
+ graph.setConstants(viewConfig.constants);
}
});
useLayoutEffect(() => {
- graph.updateSettings(config.settings);
+ if (config.settings !== undefined) {
+ graph.updateSettings(config.settings);
+ }
}, [graph, config.settings]);
useLayoutEffect(() => {
@@ -64,7 +67,7 @@ export function useGraph(config: HookGraphParams) {
return {
graph,
api: graph.api,
- setSettings: useFn((settings) => graph.updateSettings(settings)),
+ setSettings: useFn((settings: Partial) => graph.updateSettings(settings)),
start: useFn(() => {
if (graph.state !== GraphState.READY) {
graph.start();
diff --git a/src/react-components/hooks/useGraphEvents.ts b/src/react-components/hooks/useGraphEvents.ts
index 6151e27f..8c3ef85a 100644
--- a/src/react-components/hooks/useGraphEvents.ts
+++ b/src/react-components/hooks/useGraphEvents.ts
@@ -1,7 +1,8 @@
import { useLayoutEffect, useMemo, useRef } from "react";
import { Graph } from "../../graph";
-import { GraphEventsDefinitions, UnwrapGraphEvents, UnwrapGraphEventsDetail } from "../../graphEvents";
+import type { GraphEventListener, GraphEventsDefinitions } from "../../graphEvents";
+import { UnwrapGraphEvents, UnwrapGraphEventsDetail } from "../../graphEvents";
import { ESchedulerPriority } from "../../lib";
import { debounce } from "../../utils/utils/schedule";
import { GraphCallbacksMap, TGraphEventCallbacks } from "../events";
@@ -11,21 +12,30 @@ type TDebouncedFn = (() => void) & {
cancel?: () => void;
};
-type TEventNameForCallback = (typeof GraphCallbacksMap)[K];
-type TEventForCallback = UnwrapGraphEvents>;
-type TEventDetailForCallback = UnwrapGraphEventsDetail>;
+function bindReactGraphCallback(
+ graph: Graph,
+ key: K,
+ cb: TGraphEventCallbacks[K]
+): () => void {
+ type EvName = (typeof GraphCallbacksMap)[K];
+ const eventName = GraphCallbacksMap[key];
+ const listener: GraphEventListener = (event) => {
+ cb(event.detail, event);
+ };
+ return graph.on(eventName, listener);
+}
-export function useGraphEvent(
+export function useGraphEvent(
graph: Graph | null,
- event: Event,
- cb: (data: UnwrapGraphEventsDetail, event: UnwrapGraphEvents) => void,
+ event: EvName,
+ cb: (data: UnwrapGraphEventsDetail, graphEvent: UnwrapGraphEvents) => void,
debounceParams?: {
priority?: ESchedulerPriority;
frameInterval?: number;
frameTimeout?: number;
}
): void {
- const lastEventRef = useRef | null>(null);
+ const lastEventRef = useRef | null>(null);
const fn = useMemo(() => {
const invoke = () => {
const lastEvent = lastEventRef.current;
@@ -44,7 +54,7 @@ export function useGraphEvent(
...debounceParams,
});
}, [cb, debounceParams]);
- const onEvent = useFn((e: UnwrapGraphEvents) => {
+ const onEvent = useFn<[UnwrapGraphEvents], void>((e) => {
lastEventRef.current = e;
fn();
});
@@ -64,26 +74,16 @@ export function useGraphEvents(graph: Graph | null, events: Partial {
if (!graph) return undefined;
- const unsubscribe = [];
- const subscribe = (
- key: K,
- cb: (data: TEventDetailForCallback, event: TEventForCallback) => void
- ): (() => void) => {
- const eventName = GraphCallbacksMap[key];
- return graph.on(eventName, (event: TEventForCallback) => {
- cb(event.detail, event);
- });
- };
+ const unsubscribeFns: Array<() => void> = [];
const eventKeys = Object.keys(events) as Array;
- eventKeys.forEach((key: K) => {
+ for (const key of eventKeys) {
const cb = events[key];
- if (!cb) {
- return;
+ if (cb) {
+ unsubscribeFns.push(bindReactGraphCallback(graph, key, cb));
}
- unsubscribe.push(subscribe(key, cb));
- });
+ }
return () => {
- unsubscribe.forEach((unsubscribe) => unsubscribe());
+ unsubscribeFns.forEach((unsub) => unsub());
};
}, [graph, events]);
}
diff --git a/src/react-components/hooks/useLayer.test.ts b/src/react-components/hooks/useLayer.test.ts
index 6df8fa0f..9f99beb8 100644
--- a/src/react-components/hooks/useLayer.test.ts
+++ b/src/react-components/hooks/useLayer.test.ts
@@ -107,9 +107,13 @@ describe("useLayer hook", () => {
it("should handle null graph safely on unmount", () => {
// First render with a graph
- const { rerender, unmount } = renderHook(({ g }) => useLayer(g, TestLayer, createValidLayerProps()), {
- initialProps: { g: graph },
- });
+ const initialProps: { g: Graph | null } = { g: graph };
+ const { rerender, unmount } = renderHook(
+ ({ g }: { g: Graph | null }) => useLayer(g, TestLayer, createValidLayerProps()),
+ {
+ initialProps,
+ }
+ );
// Then change to null graph
act(() => {
@@ -156,6 +160,9 @@ describe("useLayer hook", () => {
// Get the layer instance
const layer = result.current;
+ if (!layer) {
+ throw new Error("Expected layer instance");
+ }
// Reset the calls to setProps after initial render
(layer.setProps as jest.Mock).mockClear();
@@ -184,6 +191,9 @@ describe("useLayer hook", () => {
// Get the layer instance
const layer = result.current;
+ if (!layer) {
+ throw new Error("Expected layer instance");
+ }
// Reset the calls to setProps after initial render
(layer.setProps as jest.Mock).mockClear();
@@ -260,6 +270,9 @@ describe("useLayer hook", () => {
// Get the layer instance
const layer = result.current;
+ if (!layer) {
+ throw new Error("Expected layer instance");
+ }
// Clear calls from initial render
mockedIsEqual.mockClear();
diff --git a/src/react-components/hooks/useLayer.ts b/src/react-components/hooks/useLayer.ts
index 66bf9bd3..66c09346 100644
--- a/src/react-components/hooks/useLayer.ts
+++ b/src/react-components/hooks/useLayer.ts
@@ -31,7 +31,7 @@ import { usePrevious } from "./usePrevious";
export function useLayer = Constructor>(
graph: Graph | null,
layerCtor: T,
- props: LayerPublicProps
+ props: LayerPublicProps = {} as LayerPublicProps
) {
const [layer, setLayer] = useState | null>(null);
const deferredLayer = useDeferredValue(layer);
diff --git a/src/react-components/layer/ReactLayer.test.ts b/src/react-components/layer/ReactLayer.test.ts
index 7ee66a12..88097d04 100644
--- a/src/react-components/layer/ReactLayer.test.ts
+++ b/src/react-components/layer/ReactLayer.test.ts
@@ -34,10 +34,11 @@ describe("ReactLayer", () => {
// Helper function to create ReactLayer without root (unattached)
const createUnattachedLayer = (blockListClassName?: string) => {
+ const detachedRoot = document.createElement("div");
return new ReactLayer({
graph,
camera,
- root: undefined as unknown as HTMLDivElement,
+ root: detachedRoot,
blockListClassName,
});
};
@@ -47,7 +48,7 @@ describe("ReactLayer", () => {
layer.attachLayer(rootElement);
const htmlElement = layer.getHTML();
expect(htmlElement).toBeTruthy();
- return htmlElement;
+ return htmlElement as HTMLElement;
};
// Helper function to check if element has only default classes
@@ -83,11 +84,12 @@ describe("ReactLayer", () => {
const htmlElement = layer.getHTML();
expect(htmlElement).toBeTruthy();
- expect(htmlElement.parentNode).toBeNull(); // Not attached to DOM
+ const el = htmlElement as HTMLElement;
+ expect(el.parentNode).toBeNull(); // Not attached to DOM
// blockListClassName is not applied until attachLayer is called (which calls afterInit)
- expect(htmlElement.classList.contains("test-class")).toBe(false);
- expect(hasOnlyDefaultClasses(htmlElement, layer)).toBe(true);
+ expect(el.classList.contains("test-class")).toBe(false);
+ expect(hasOnlyDefaultClasses(el, layer)).toBe(true);
});
it("should initialize with correct z-index", () => {
@@ -251,7 +253,7 @@ describe("ReactLayer", () => {
const renderBlock = jest.fn();
// Mock getHTML to return null
- jest.spyOn(layer, "getHTML").mockReturnValue(null);
+ jest.spyOn(layer, "getHTML").mockReturnValue(undefined);
const portal = layer.renderPortal(renderBlock);
expect(portal).toBeNull();
@@ -291,6 +293,9 @@ describe("ReactLayer", () => {
expect(portal).toHaveProperty("containerInfo", _htmlElement);
expect(portal).toHaveProperty("key", "graph-blocks-list");
// Check that children is a React element (BlocksList)
+ if (!portal) {
+ throw new Error("Expected portal");
+ }
expect(portal.children).toBeTruthy();
});
});
@@ -330,15 +335,17 @@ describe("ReactLayer", () => {
// because afterInit() hasn't been called yet
const htmlElementBefore = layer.getHTML();
expect(htmlElementBefore).toBeTruthy();
- expect(htmlElementBefore.parentNode).toBeNull();
- expect(htmlElementBefore.classList.contains(className)).toBe(false);
+ const beforeEl = htmlElementBefore as HTMLElement;
+ expect(beforeEl.parentNode).toBeNull();
+ expect(beforeEl.classList.contains(className)).toBe(false);
// After attachLayer - HTML element should be in DOM and have the class
// because attachLayer calls afterInit() which applies blockListClassName
layer.attachLayer(rootElement);
const htmlElementAfter = layer.getHTML();
- expect(htmlElementAfter.classList.contains(className)).toBe(true);
- expect(htmlElementAfter.parentNode).toBe(rootElement);
+ const afterEl = htmlElementAfter as HTMLElement;
+ expect(afterEl.classList.contains(className)).toBe(true);
+ expect(afterEl.parentNode).toBe(rootElement);
});
it("should handle detach and reattach correctly", () => {
@@ -347,7 +354,7 @@ describe("ReactLayer", () => {
// First attachment
layer.attachLayer(rootElement);
- let htmlElement = layer.getHTML();
+ let htmlElement = layer.getHTML() as HTMLElement;
expect(htmlElement.classList.contains(className)).toBe(true);
// Detach
@@ -358,7 +365,7 @@ describe("ReactLayer", () => {
document.body.appendChild(newRoot);
layer.attachLayer(newRoot);
- htmlElement = layer.getHTML();
+ htmlElement = layer.getHTML() as HTMLElement;
expect(htmlElement.classList.contains(className)).toBe(true);
// Cleanup
diff --git a/src/react-components/layer/ReactLayer.tsx b/src/react-components/layer/ReactLayer.tsx
index 797bb49c..d891f3a2 100644
--- a/src/react-components/layer/ReactLayer.tsx
+++ b/src/react-components/layer/ReactLayer.tsx
@@ -41,6 +41,9 @@ export class ReactLayer extends Layer {
this.setProps({
...this.props,
html: {
+ zIndex: 3,
+ classNames: ["no-user-select", "no-pointer-events"],
+ transformByCameraPosition: true,
...this.props.html,
activationScale: scales[2],
},
diff --git a/src/services/HitTest.ts b/src/services/HitTest.ts
index c6acd30e..ca812826 100644
--- a/src/services/HitTest.ts
+++ b/src/services/HitTest.ts
@@ -268,8 +268,8 @@ export class HitTest extends Emitter {
minY: point.y - 1,
maxX: point.x + 1,
maxY: point.y + 1,
- x: point.origPoint?.x * pixelRatio,
- y: point.origPoint?.y * pixelRatio,
+ x: (point.origPoint?.x ?? 0) * pixelRatio,
+ y: (point.origPoint?.y ?? 0) * pixelRatio,
});
}
@@ -350,17 +350,17 @@ export class HitTest extends Emitter {
export class HitBox implements IHitBox {
public destroyed = false;
- public maxX: number;
+ public maxX = 0;
- public maxY: number;
+ public maxY = 0;
- public minX: number;
+ public minX = 0;
- public minY: number;
+ public minY = 0;
- public x: number;
+ public x = 0;
- public y: number;
+ public y = 0;
/**
* AffectsUsableRect flag uses to determine if the element affects the usableRect
diff --git a/src/services/KeyboardService/index.ts b/src/services/KeyboardService/index.ts
index 91b33aa0..6f949b59 100644
--- a/src/services/KeyboardService/index.ts
+++ b/src/services/KeyboardService/index.ts
@@ -83,16 +83,22 @@ export class KeyboardService {
}
public onPress(key: string, cb: (event: CustomEvent) => void, options?: AddEventListenerOptions) {
- this.keybordEvents.addEventListener(`press-${key}`, cb, options);
+ const listener: EventListener = (evt) => {
+ cb(evt as CustomEvent);
+ };
+ this.keybordEvents.addEventListener(`press-${key}`, listener, options);
return () => {
- this.keybordEvents.removeEventListener(`press-${key}`, cb);
+ this.keybordEvents.removeEventListener(`press-${key}`, listener);
};
}
public onRelease(key: string, cb: (event: CustomEvent) => void, options?: AddEventListenerOptions) {
- this.keybordEvents.addEventListener(`release-${key}`, cb, options);
+ const listener: EventListener = (evt) => {
+ cb(evt as CustomEvent);
+ };
+ this.keybordEvents.addEventListener(`release-${key}`, listener, options);
return () => {
- this.keybordEvents.removeEventListener(`release-${key}`, cb);
+ this.keybordEvents.removeEventListener(`release-${key}`, listener);
};
}
diff --git a/src/services/Layer.ts b/src/services/Layer.ts
index 9f7f0e5d..cf03133a 100644
--- a/src/services/Layer.ts
+++ b/src/services/Layer.ts
@@ -1,6 +1,6 @@
import { Graph } from "../graph";
-import { TGraphColors, TGraphConstants } from "../graphConfig";
-import { GraphEventsDefinitions } from "../graphEvents";
+import { TGraphConstants, TResolvedGraphColors } from "../graphConfig";
+import { GraphEventListener, GraphEventsDefinitions } from "../graphEvents";
import { CoreComponent } from "../lib";
import { Component, TComponentState } from "../lib/Component";
import { ESchedulerPriority } from "../lib/Scheduler";
@@ -49,7 +49,7 @@ export type LayerContext = {
graph: Graph;
camera: ICamera;
constants: TGraphConstants;
- colors: TGraphColors;
+ colors: TResolvedGraphColors;
graphCanvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
layer: Layer;
@@ -82,9 +82,9 @@ export class Layer<
> extends Component {
public static id?: string;
- protected canvas: HTMLCanvasElement;
+ protected canvas?: HTMLCanvasElement;
- protected html: HTMLElement;
+ protected html?: HTMLElement;
protected root?: HTMLDivElement;
@@ -119,11 +119,11 @@ export class Layer<
* @param options - Additional options (optional)
* @returns The result of graph.on call (an unsubscribe function)
*/
- protected onGraphEvent(
+ protected onGraphEvent(
eventName: EventName,
- handler: Cb,
+ handler: GraphEventListener,
options?: Omit
- ) {
+ ): () => void {
return this.props.graph.on(eventName, handler, {
...options,
signal: this.eventAbortController.signal,
@@ -168,7 +168,7 @@ export class Layer<
throw new Error("Attempt to add event listener to non-existent HTML element");
}
- this.html.addEventListener(eventName, handler, {
+ this.html.addEventListener(eventName, handler as EventListener, {
...options,
signal: this.eventAbortController.signal,
});
@@ -198,7 +198,7 @@ export class Layer<
throw new Error("Attempt to add event listener to non-existent canvas element");
}
- this.canvas.addEventListener(eventName, handler, {
+ this.canvas.addEventListener(eventName, handler as EventListener, {
...options,
signal: this.eventAbortController.signal,
});
@@ -228,7 +228,7 @@ export class Layer<
throw new Error("Attempt to add event listener to non-existent root element");
}
- this.root.addEventListener(eventName, handler, {
+ this.root.addEventListener(eventName, handler as EventListener, {
...options,
signal: this.eventAbortController.signal,
});
@@ -376,10 +376,14 @@ export class Layer<
* @param active - Whether the HTML layer is now active
*/
protected onHtmlActiveChange(active: boolean) {
+ const html = this.html;
+ if (!html) {
+ return;
+ }
if (active) {
- this.html.classList.remove("layer-hidden");
+ html.classList.remove("layer-hidden");
} else {
- this.html.classList.add("layer-hidden");
+ html.classList.add("layer-hidden");
}
}
@@ -411,12 +415,14 @@ export class Layer<
protected unmountLayer() {
if (this.canvas) {
const cameraState = this.context.camera.getCameraState();
- const context = this.canvas.getContext("2d");
- context.setTransform(1, 0, 0, 1, 0, 0);
+ const ctx = this.canvas.getContext("2d");
+ if (ctx) {
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
- context.clearRect(0, 0, cameraState.width, cameraState.height);
+ ctx.clearRect(0, 0, cameraState.width, cameraState.height);
- context.setTransform(cameraState.scale, 0, 0, cameraState.scale, cameraState.x, cameraState.y);
+ ctx.setTransform(cameraState.scale, 0, 0, cameraState.scale, cameraState.x, cameraState.y);
+ }
}
this.canvas?.parentNode?.removeChild(this.canvas);
this.html?.parentNode?.removeChild(this.html);
@@ -435,11 +441,11 @@ export class Layer<
super.unmount();
}
- public getCanvas() {
+ public getCanvas(): HTMLCanvasElement | undefined {
return this.canvas;
}
- public getHTML() {
+ public getHTML(): HTMLElement | undefined {
return this.html;
}
@@ -466,23 +472,27 @@ export class Layer<
this.root = undefined;
}
- protected createCanvas(params: LayerProps["canvas"]) {
+ protected createCanvas(params: NonNullable) {
const canvas = document.createElement("canvas");
canvas.classList.add("layer", "layer-canvas");
if (Array.isArray(params.classNames)) canvas.classList.add(...params.classNames);
canvas.style.zIndex = `${Number(params.zIndex)}`;
+ const ctx = canvas.getContext("2d", {
+ desynchronized: params.desynchronized ?? false,
+ willReadFrequently: params.willReadFrequently ?? false,
+ alpha: params.alpha ?? true,
+ });
+ if (!ctx) {
+ throw new Error("Layer: failed to acquire Canvas 2D context");
+ }
this.setContext({
graphCanvas: canvas,
- ctx: canvas.getContext("2d", {
- desynchronized: params.desynchronized ?? false,
- willReadFrequently: params.willReadFrequently ?? false,
- alpha: params.alpha ?? true,
- }),
+ ctx,
});
return canvas;
}
- protected createHTML(params: LayerProps["html"]) {
+ protected createHTML(params: NonNullable) {
const div = document.createElement("div");
div.classList.add("layer", "layer-html");
if (Array.isArray(params.classNames)) div.classList.add(...params.classNames);
@@ -502,7 +512,7 @@ export class Layer<
x: number,
y: number,
scale: number,
- respectPixelRatio: boolean = this.props.canvas?.respectPixelRatio
+ respectPixelRatio: boolean = this.props.canvas?.respectPixelRatio ?? true
) {
const ctx = this.context.ctx;
const dpr = respectPixelRatio ? this.getDRP() : 1;
@@ -510,12 +520,18 @@ export class Layer<
}
protected updateCanvasSize() {
+ if (!this.canvas) {
+ return;
+ }
const { width, height, dpr } = this.context.graph.layers.getRootSize();
this.canvas.width = width * dpr;
this.canvas.height = height * dpr;
}
public resetTransform() {
+ if (!this.canvas) {
+ return;
+ }
if (this.sizeTouched) {
this.sizeTouched = false;
this.updateCanvasSize();
diff --git a/src/services/LayersService.ts b/src/services/LayersService.ts
index 66cb09e1..a08544f9 100644
--- a/src/services/LayersService.ts
+++ b/src/services/LayersService.ts
@@ -7,7 +7,17 @@ import { observeDPR, throttle } from "../utils/functions";
import { Layer } from "./Layer";
-export class Layers extends Emitter {
+export type TLayersRootSize = {
+ width: number;
+ height: number;
+ dpr: number;
+};
+
+type TLayersEmitterEvents = {
+ "update-size": (...args: unknown[]) => void;
+};
+
+export class Layers extends Emitter {
private attached = false;
public readonly rootSize = signal({ width: 0, height: 0, dpr: globalThis.devicePixelRatio || 1 });
@@ -34,6 +44,9 @@ export class Layers extends Emitter {
}) as InstanceType;
this.layers.add(layer);
if (this.attached) {
+ if (!this.$root) {
+ throw new Error("Layers root is not set");
+ }
layer.attachLayer(this.$root);
}
return layer;
@@ -52,14 +65,18 @@ export class Layers extends Emitter {
return Array.from(this.layers);
}
- public attach(root: HTMLDivElement = this.$root) {
- this.$root = root;
+ public attach(root?: HTMLDivElement): void {
+ const nextRoot = root ?? this.$root;
+ if (!nextRoot) {
+ throw new Error("Root required to attach layers");
+ }
+ this.$root = nextRoot;
this.layers.forEach((layer) => {
- layer.attachLayer(this.$root);
+ layer.attachLayer(nextRoot);
});
}
- public start(root: HTMLDivElement = this.$root) {
+ public start(root?: HTMLDivElement): void {
if (this.attached) {
return;
}
diff --git a/src/services/camera/Camera.ts b/src/services/camera/Camera.ts
index 69718b07..dc6d5b63 100644
--- a/src/services/camera/Camera.ts
+++ b/src/services/camera/Camera.ts
@@ -35,8 +35,8 @@ export class Camera extends EventedComponent void);
+ this.addEventListener("mousedown", this.handleMouseDownEvent as (e: Event) => void);
// Subscribe to auto-panning state changes
this.context.graph.on("camera-change", this.handleCameraStateChange);
@@ -53,7 +53,7 @@ export class Camera extends EventedComponent {
+ protected handleClick = (_event: Event): void => {
this.context.graph.api.unsetSelection();
};
@@ -65,12 +65,12 @@ export class Camera extends EventedComponent void);
this.context.graph.off("camera-change", this.handleCameraStateChange);
}
@@ -172,16 +172,22 @@ export class Camera extends EventedComponent {
+ private handleMouseDownEvent = (event: Event): void => {
if (!this.context.graph.rootStore.settings.getConfigFlag("canDragCamera") || !(event instanceof MouseEvent)) {
return;
}
if (!isMetaKeyEvent(event)) {
// Camera drag doesn't need graph sync since it IS the camera
dragListener(this.ownerDocument, { graph: this.context.graph, autopanning: false, dragCursor: "grabbing" })
- .on(EVENTS.DRAG_START, (event: MouseEvent) => this.onDragStart(event))
- .on(EVENTS.DRAG_UPDATE, (event: MouseEvent) => this.onDragUpdate(event))
- .on(EVENTS.DRAG_END, () => this.onDragEnd());
+ .on(EVENTS.DRAG_START, (...args: unknown[]) => {
+ this.onDragStart(args[0] as MouseEvent);
+ })
+ .on(EVENTS.DRAG_UPDATE, (...args: unknown[]) => {
+ this.onDragUpdate(args[0] as MouseEvent);
+ })
+ .on(EVENTS.DRAG_END, () => {
+ this.onDragEnd();
+ });
}
};
@@ -241,7 +247,10 @@ export class Camera extends EventedComponent {
+ private handleWheelEvent = (event: Event): void => {
+ if (!(event instanceof WheelEvent)) {
+ return;
+ }
if (!this.context.graph.rootStore.settings.getConfigFlag("canZoomCamera")) {
return;
}
diff --git a/src/services/camera/CameraService.ts b/src/services/camera/CameraService.ts
index 26310369..d322d1c0 100644
--- a/src/services/camera/CameraService.ts
+++ b/src/services/camera/CameraService.ts
@@ -82,8 +82,10 @@ export class CameraService extends Emitter {
}
public resize(newState: Partial) {
- const diffX = newState.width - this.state.width;
- const diffY = newState.height - this.state.height;
+ const nextWidth = newState.width ?? this.state.width;
+ const nextHeight = newState.height ?? this.state.height;
+ const diffX = nextWidth - this.state.width;
+ const diffY = nextHeight - this.state.height;
this.set(newState);
this.move(diffX, diffY);
}
diff --git a/src/services/camera/defaultGetCameraBlockScaleLevel.test.ts b/src/services/camera/defaultGetCameraBlockScaleLevel.test.ts
index 3d7d05bb..82336488 100644
--- a/src/services/camera/defaultGetCameraBlockScaleLevel.test.ts
+++ b/src/services/camera/defaultGetCameraBlockScaleLevel.test.ts
@@ -9,7 +9,7 @@ function createGraphWithBlockScales(scales: [number, number, number]): Graph {
graphConstants: {
block: { SCALES: scales },
},
- } as unknown as Graph;
+ } as Graph;
}
describe("defaultGetCameraBlockScaleLevel", () => {
diff --git a/src/services/drag/DragService.ts b/src/services/drag/DragService.ts
index 2f7993c4..a5c264eb 100644
--- a/src/services/drag/DragService.ts
+++ b/src/services/drag/DragService.ts
@@ -139,9 +139,15 @@ export class DragService {
dragCursor: "grabbing",
autopanning: true,
})
- .on(EVENTS.DRAG_START, this.handleDragStart)
- .on(EVENTS.DRAG_UPDATE, this.handleDragUpdate)
- .on(EVENTS.DRAG_END, this.handleDragEnd);
+ .on(EVENTS.DRAG_START, (...args: unknown[]) => {
+ this.handleDragStart(args[0] as MouseEvent);
+ })
+ .on(EVENTS.DRAG_UPDATE, (...args: unknown[]) => {
+ this.handleDragUpdate(args[0] as MouseEvent);
+ })
+ .on(EVENTS.DRAG_END, (...args: unknown[]) => {
+ this.handleDragEnd(args[0] as MouseEvent);
+ });
};
/**
@@ -328,14 +334,17 @@ export class DragService {
stopOnMouseLeave,
threshold,
})
- .on(EVENTS.DRAG_START, (event: MouseEvent) => {
+ .on(EVENTS.DRAG_START, (...args: unknown[]) => {
+ const event = args[0] as MouseEvent;
onStart?.(event, initialEvent ? this.getWorldCoords(initialEvent) : this.getWorldCoords(event));
})
- .on(EVENTS.DRAG_UPDATE, (event: MouseEvent) => {
+ .on(EVENTS.DRAG_UPDATE, (...args: unknown[]) => {
+ const event = args[0] as MouseEvent;
const coords = this.getWorldCoords(event);
onUpdate?.(event, coords);
})
- .on(EVENTS.DRAG_END, (event: MouseEvent) => {
+ .on(EVENTS.DRAG_END, (...args: unknown[]) => {
+ const event = args[0] as MouseEvent;
const coords = this.getWorldCoords(event);
onEnd?.(event, coords);
});
diff --git a/src/services/optimizations/fpsManager.ts b/src/services/optimizations/fpsManager.ts
index 78b589c2..a8690f7e 100644
--- a/src/services/optimizations/fpsManager.ts
+++ b/src/services/optimizations/fpsManager.ts
@@ -6,7 +6,7 @@ class FpsManager {
private history: number[] = [];
constructor() {
- const times = [];
+ const times: number[] = [];
const refreshLoop = () => {
const now = performance.now();
diff --git a/src/services/optimizations/frameDebouncer.ts b/src/services/optimizations/frameDebouncer.ts
index 8c2f9c30..a6863fe6 100644
--- a/src/services/optimizations/frameDebouncer.ts
+++ b/src/services/optimizations/frameDebouncer.ts
@@ -56,6 +56,9 @@ export class FrameDebouncer {
public delete(fn: any) {
const bindedFn = this.mapOriginalToBindedFn.get(fn);
+ if (bindedFn === undefined) {
+ return;
+ }
const i = this.nextFrameFns.indexOf(bindedFn);
if (i !== -1) {
@@ -75,15 +78,17 @@ export class FrameDebouncer {
private createBindedFunction(fn: any, options: Options): any {
const bindedFn: any = (frameTime: number) => {
- const hardFrame = options.lightFrame ? frameTime > 16 : frameTime > options.frameTime;
- const skip = options.leading ? bindedFn.delay < options.delay : bindedFn.delay > 0;
+ const delayOpt = options.delay;
+ const frameTimeOpt = options.frameTime;
+ const hardFrame = options.lightFrame ? frameTime > 16 : frameTimeOpt !== undefined && frameTime > frameTimeOpt;
+ const skip = options.leading ? delayOpt !== undefined && bindedFn.delay < delayOpt : bindedFn.delay > 0;
if (hardFrame || skip) {
// skip original function
if (options.leading) {
bindedFn.delay += 1;
- if (hardFrame || bindedFn.delay < options.delay) {
+ if (hardFrame || (delayOpt !== undefined && bindedFn.delay < delayOpt)) {
this.tmpFns.push(bindedFn);
} else {
bindedFn.inOrder = false;
@@ -96,14 +101,14 @@ export class FrameDebouncer {
return;
}
- const run = options.leading ? bindedFn.delay >= options.delay : bindedFn.delay < 1;
+ const run = options.leading ? delayOpt !== undefined && bindedFn.delay >= delayOpt : bindedFn.delay < 1;
if (run) {
// perform original function
fn(...bindedFn.args);
if (options.throttle) {
- bindedFn.delay = options.delay;
+ bindedFn.delay = delayOpt;
}
if (options.leading) {
diff --git a/src/services/selection/BaseSelectionBucket.ts b/src/services/selection/BaseSelectionBucket.ts
index 25239785..9663d263 100644
--- a/src/services/selection/BaseSelectionBucket.ts
+++ b/src/services/selection/BaseSelectionBucket.ts
@@ -7,11 +7,11 @@ import type { SelectionService } from "./SelectionService";
import {
ESelectionStrategy,
IEntityWithComponent,
- ISelectionBucket,
TSelectionDiff,
TSelectionEntity,
TSelectionEntityId,
} from "./types";
+import type { ISelectionBucket } from "./types";
/**
* @abstract
@@ -85,18 +85,18 @@ export abstract class BaseSelectionBucket<
.map((entity) => {
// Check if entity is already a GraphComponent
if (this.isGraphComponent(entity)) {
- return entity as unknown as GraphComponent;
+ return entity as GraphComponent;
}
// Check if entity has getViewComponent method
if (this.hasViewComponent(entity)) {
- return (entity as unknown as IEntityWithComponent).getViewComponent();
+ return entity.getViewComponent();
}
return undefined;
})
.filter((component): component is GraphComponent => component !== undefined);
});
- protected manager: SelectionService;
+ protected manager?: SelectionService;
/**
* Check if an entity is a GraphComponent
@@ -113,12 +113,12 @@ export abstract class BaseSelectionBucket<
/**
* Check if an entity has getViewComponent method
*/
- private hasViewComponent(entity: TEntity): boolean {
+ private hasViewComponent(entity: TEntity): entity is TEntity & IEntityWithComponent {
return (
typeof entity === "object" &&
entity !== null &&
"getViewComponent" in entity &&
- typeof (entity as { getViewComponent?: unknown }).getViewComponent === "function"
+ typeof (entity as IEntityWithComponent).getViewComponent === "function"
);
}
diff --git a/src/services/selection/Resolver.integration.test.ts b/src/services/selection/Resolver.integration.test.ts
index 00e52a90..45a7e8d2 100644
--- a/src/services/selection/Resolver.integration.test.ts
+++ b/src/services/selection/Resolver.integration.test.ts
@@ -1,3 +1,5 @@
+import type { GraphComponent } from "../../components/canvas/GraphComponent";
+
import { MultipleSelectionBucket } from "./MultipleSelectionBucket";
import { SingleSelectionBucket } from "./SingleSelectionBucket";
import { ESelectionStrategy } from "./types";
@@ -211,8 +213,8 @@ describe("Selection Resolver Integration", () => {
const components = bucket.$selectedComponents.value;
expect(components).toHaveLength(2);
- expect((components[0] as unknown as MockComponent).name).toBe("Component 1");
- expect((components[1] as unknown as MockComponent).name).toBe("Component 2");
+ expect(components[0]).toEqual(expect.objectContaining({ name: "Component 1" }));
+ expect(components[1]).toEqual(expect.objectContaining({ name: "Component 2" }));
});
it("should resolve entities that are already components", () => {
@@ -232,7 +234,7 @@ describe("Selection Resolver Integration", () => {
const resolvedComponents = bucket.$selectedComponents.value;
expect(resolvedComponents).toHaveLength(1);
- expect((resolvedComponents[0] as unknown as MockComponent).name).toBe("Component 1");
+ expect(resolvedComponents[0]).toEqual(expect.objectContaining({ name: "Component 1" }));
});
it("should return empty array for entities without components", () => {
@@ -268,10 +270,10 @@ describe("Selection Resolver Integration", () => {
};
const bucket = new MultipleSelectionBucket("test", undefined, undefined, resolver);
- const updates: MockComponent[][] = [];
+ const updates: GraphComponent[][] = [];
const unsubscribe = bucket.$selectedComponents.subscribe((components) => {
- updates.push([...(components as unknown as MockComponent[])]);
+ updates.push([...components]);
});
bucket.select(["state1"], ESelectionStrategy.REPLACE);
@@ -283,7 +285,7 @@ describe("Selection Resolver Integration", () => {
expect(updates).toHaveLength(4); // Initial + 3 changes
expect(updates[0]).toHaveLength(0); // Initial empty
expect(updates[1]).toHaveLength(1);
- expect(updates[1][0].name).toBe("Component 1");
+ expect(updates[1][0]).toEqual(expect.objectContaining({ name: "Component 1" }));
expect(updates[2]).toHaveLength(2);
expect(updates[3]).toHaveLength(0); // Cleared
});
diff --git a/src/services/selection/SelectionService.ts b/src/services/selection/SelectionService.ts
index 9b0fe161..72a52f27 100644
--- a/src/services/selection/SelectionService.ts
+++ b/src/services/selection/SelectionService.ts
@@ -7,6 +7,7 @@ import {
IEntityWithComponent,
ISelectionBucket,
TMultiEntitySelection,
+ TSelectionEntity,
TSelectionEntityId,
} from "./types";
@@ -101,12 +102,15 @@ export class SelectionService {
* @param bucket The selection bucket to register
* @returns void
*/
- public registerBucket(bucket: ISelectionBucket): void {
+ public registerBucket<
+ IDType extends TSelectionEntityId = TSelectionEntityId,
+ TEntity extends TSelectionEntity = TSelectionEntity,
+ >(bucket: ISelectionBucket): void {
if (this.buckets.value.has(bucket.entityType)) {
throw new Error(`Selection bucket for entityType '${bucket.entityType}' is already registered`);
}
const newMap = new Map(this.buckets.value);
- newMap.set(bucket.entityType, bucket);
+ newMap.set(bucket.entityType, bucket as unknown as ISelectionBucket);
this.buckets.value = newMap;
}
@@ -116,7 +120,10 @@ export class SelectionService {
* @param bucket The selection bucket to unregister
* @returns void
*/
- public unregisterBucket(bucket: ISelectionBucket): void {
+ public unregisterBucket<
+ IDType extends TSelectionEntityId = TSelectionEntityId,
+ TEntity extends TSelectionEntity = TSelectionEntity,
+ >(bucket: ISelectionBucket): void {
const newMap = new Map(this.buckets.value);
newMap.delete(bucket.entityType);
this.buckets.value = newMap;
diff --git a/src/store/anchor/Anchor.ts b/src/store/anchor/Anchor.ts
index c41e87b6..fc778101 100644
--- a/src/store/anchor/Anchor.ts
+++ b/src/store/anchor/Anchor.ts
@@ -1,3 +1,4 @@
+import type { Signal } from "@preact/signals-core";
import { computed, signal } from "@preact/signals-core";
import { Anchor, TAnchor } from "../../components/canvas/anchors";
@@ -9,13 +10,13 @@ export enum EAnchorType {
}
export class AnchorState {
- protected $state = signal(undefined);
+ protected $state: Signal;
public $selected = computed(() => this.block.store.anchorSelectionBucket.isSelected(this.id));
public $viewComponentReady = signal(false);
- private anchorView: Anchor;
+ private anchorView?: Anchor;
public get id() {
return this.$state.value.id;
@@ -32,7 +33,7 @@ export class AnchorState {
public readonly block: BlockState,
anchor: TAnchor
) {
- this.$state.value = anchor;
+ this.$state = signal(anchor);
}
public update(anchor: TAnchor) {
@@ -53,7 +54,7 @@ export class AnchorState {
this.$viewComponentReady.value = false;
}
- public getViewComponent() {
+ public getViewComponent(): Anchor | undefined {
return this.anchorView;
}
diff --git a/src/store/block/Block.ts b/src/store/block/Block.ts
index eaf15ddb..0237fbf8 100644
--- a/src/store/block/Block.ts
+++ b/src/store/block/Block.ts
@@ -18,7 +18,7 @@ export class BlockState {
return new BlockState(store, block, store.blockSelectionBucket);
}
- protected $rawState = signal(undefined);
+ protected $rawState: Signal;
/**
* Block state signal
@@ -103,12 +103,12 @@ export class BlockState {
* @returns {ReadonlySignal