From e930a4738a7cd220f24695bd25e50ca08bbb6759 Mon Sep 17 00:00:00 2001 From: draedful Date: Fri, 26 Jun 2026 16:04:43 +0300 Subject: [PATCH 1/2] feat(camera): add committed $camera signal and Layer.onCameraChange hook Separate proposed camera state (camera-change event) from committed state (graph.$camera signal). Layers and React hooks react to applied changes; camera-change remains for interception via preventDefault(). Co-authored-by: Cursor --- .cursor/rules/event-model-rules.mdc | 24 ++- .cursor/rules/layer-rules.mdc | 3 +- docs/react/hooks.md | 82 +++++---- docs/react/usage.md | 1 + docs/rendering/layers.md | 30 ++-- docs/system/camera.md | 49 +++++- docs/system/events.md | 41 +++-- e2e/README.md | 4 + e2e/page-objects/GraphPageObject.ts | 34 ++++ e2e/tests/camera/camera-change-signal.spec.ts | 165 ++++++++++++++++++ .../canvas/layers/graphLayer/GraphLayer.ts | 13 +- .../PortConnectionLayer.ts | 10 +- src/graph.ts | 9 +- src/plugins/devtools/DevToolsLayer.ts | 5 +- src/plugins/minimap/layer.ts | 6 +- src/react-components/hooks/useSceneChange.ts | 30 ++-- src/react-components/hooks/useSignal.test.ts | 25 ++- src/react-components/hooks/useSignal.ts | 13 +- src/services/Layer.ts | 24 ++- src/services/camera/Camera.ts | 10 +- src/services/camera/CameraService.ts | 11 +- src/stories/Playground/Toolbox.tsx | 10 +- src/utils/functions/dragListener.ts | 13 +- 23 files changed, 477 insertions(+), 135 deletions(-) create mode 100644 e2e/tests/camera/camera-change-signal.spec.ts diff --git a/.cursor/rules/event-model-rules.mdc b/.cursor/rules/event-model-rules.mdc index 42e777c4..e9c4c97c 100644 --- a/.cursor/rules/event-model-rules.mdc +++ b/.cursor/rules/event-model-rules.mdc @@ -124,24 +124,30 @@ When implementing event subscriptions in this project: } protected afterInit(): void { - // Register graph events with the onGraphEvent wrapper method - this.onGraphEvent("camera-change", this.handleCameraChange); - - // Register DOM events with the appropriate wrapper methods + // Optional: cancel updates before commit + this.onGraphEvent("camera-change", (event) => { + if (shouldReject(event.detail)) { + event.preventDefault(); + } + }); + if (this.canvas) { this.onCanvasEvent("mousedown", this.handleMouseDown); } - + if (this.html) { this.onHtmlEvent("click", this.handleHtmlClick); } - + if (this.root) { this.onRootEvent("keydown", this.handleRootKeyDown); } - - // Always call super.afterInit() at the end - super.afterInit(); + + super.afterInit(); // subscribes to $camera and calls onCameraChange() + } + + protected onCameraChange(camera: TCameraState): void { + this.handleCameraChange(camera); } } ``` diff --git a/.cursor/rules/layer-rules.mdc b/.cursor/rules/layer-rules.mdc index 14a2999a..a2009483 100644 --- a/.cursor/rules/layer-rules.mdc +++ b/.cursor/rules/layer-rules.mdc @@ -144,7 +144,8 @@ this.html.style.transform = `matrix(${camera.scale}, 0, 0, ${camera.scale}, ${ca - Attach event listeners (e.g., `mousemove`, `mouseleave`) to the layer's root element (`this.root`) or specific layer elements (`this.getCanvas()`, `this.getHTML()`) within `afterInit`. - Always clean up listeners and subscriptions in the `unmount` method. - **Camera Interaction & Coordinates:** - - Subscribe to graph's `'camera-change'` event (`this.props.graph.on(...)`) to get updates. The event detail (`event.detail`) provides the `TCameraState` (containing `width`, `height`, `scale`, `x`, `y`). + - Override **`onCameraChange(camera)`** in derived layers to react to committed camera state (pan, zoom, resize). The base `Layer` subscribes to `graph.$camera` in `afterInit()` and calls this hook after built-in HTML/canvas transforms. + - Use **`camera-change` event** via `onGraphEvent` only when you need to intercept or cancel an update before commit (`event.preventDefault()`). - `cameraState.x` and `cameraState.y` represent the *screen coordinates* of the world origin (0,0). Use these (`worldOriginScreenX`, `worldOriginScreenY`) for coordinate calculations. - To convert **screen coordinates to world coordinates** (e.g., mouse position), use `this.context.camera.applyToPoint(screenX, screenY)`. - To convert **world coordinates to screen coordinates** (e.g., placing ticks), use the formula: `screenX = worldX * scale + worldOriginScreenX` and `screenY = worldY * scale + worldOriginScreenY`. diff --git a/docs/react/hooks.md b/docs/react/hooks.md index 29e635cd..9476ae2f 100644 --- a/docs/react/hooks.md +++ b/docs/react/hooks.md @@ -21,6 +21,7 @@ This document describes all available React hooks for working with @gravity-ui/g - [useSignal](#usesignal) - Subscribes to signal values for reactive updates - [useComputedSignal](#usecomputedsignal) - Creates computed signals from other signals - [useSignalEffect](#usesignaleffect) - Runs side effects when signal values change +- [useSignalLayoutEffect](#usesignallayouteffect) - Runs layout effects when signal values change ### Scheduler Hooks - [useSchedulerDebounce](#useschedulerdebounce) - Creates frame-synchronized debounced function @@ -199,18 +200,15 @@ function MyComponent({ graph }: Props): JSX.Element | null { } ); - // With debounce options + // Intercept proposed camera state before commit useGraphEvent( - graph, - "camera-change", - (detail: UnwrapGraphEventsDetail<"camera-change">) => { - console.log("Camera:", detail.camera); + graph, + "camera-change", + (detail: UnwrapGraphEventsDetail<"camera-change">, event: UnwrapGraphEvents<"camera-change">) => { + if (detail.scale < 0.1) { + event.preventDefault(); + } }, - { - priority: ESchedulerPriority.MEDIUM, - frameInterval: 2, // Wait 2 frames - frameTimeout: 100, // Wait at least 100ms - } ); // Prevent default behavior @@ -528,6 +526,23 @@ function BlockLogger({ blockState }: Props): null { | `effectFn` | `() => void` | Effect function that reads signals | | `deps` | `DependencyList` | Dependencies array (like useEffect) | +### useSignalLayoutEffect + +Like `useSignalEffect`, but uses `useLayoutEffect` internally. Use when the side effect must run synchronously after DOM updates and before the browser paints — for example, layout-dependent overlays tied to the camera. + +```typescript +import { useSignalLayoutEffect } from "@gravity-ui/graph/react"; + +useSignalLayoutEffect(() => { + const camera = graph.$camera.value; + updateOverlay(camera); +}, [graph]); +``` + +#### Parameters + +Same as `useSignalEffect`. + ## Scheduler Hooks These hooks integrate with the graph's internal scheduler for frame-based timing control. They are designed primarily for **synchronizing UI updates with graph component changes** on a per-frame basis. @@ -668,7 +683,7 @@ Hook to create a throttled function that limits execution frequency. Unlike debounce, throttle executes immediately on the first call and then enforces the delay for subsequent calls. ```typescript -import { useSchedulerThrottle, useGraphEvent } from "@gravity-ui/graph/react"; +import { useSchedulerThrottle, useSignalLayoutEffect } from "@gravity-ui/graph/react"; import type { ESchedulerPriority, Graph, TCameraState } from "@gravity-ui/graph"; interface ThrottledFn void> { @@ -680,23 +695,20 @@ interface ThrottledFn void> { function MinimapOverlay({ graph }: { graph: Graph }): JSX.Element { const [viewport, setViewport] = useState(null); - // Throttle camera updates - executes immediately on first call, - // then waits for frame interval before next execution const throttledCameraUpdate: ThrottledFn<(camera: TCameraState) => void> = useSchedulerThrottle( (camera: TCameraState): void => { setViewport(camera); }, { priority: ESchedulerPriority.LOW, - frameInterval: 2, // Update at most every 2 frames - frameTimeout: 32, // At most once per ~32ms + frameInterval: 2, + frameTimeout: 32, } ); - // Camera events fire very frequently during pan/zoom - useGraphEvent(graph, "camera-change", ({ camera }) => { - throttledCameraUpdate(camera); - }); + useSignalLayoutEffect(() => { + throttledCameraUpdate(graph.$camera.value); + }, [graph, throttledCameraUpdate]); return (
@@ -910,9 +922,10 @@ This means: The hook subscribes to: -1. **`camera-change` event** - Fires when camera position, scale, or rotation changes -2. **HitTest `update` event** - Fires when blocks enter or leave the viewport -3. **Initial mount** - Calls the function immediately on component mount +1. **`graph.$camera` signal** (via `useSignalLayoutEffect`) — committed camera state after a non-prevented change +2. **HitTest `update` event** — fires when blocks enter or leave the viewport + +Both paths call the debounced callback on mount and on subsequent updates. #### Use Cases @@ -978,32 +991,15 @@ function MyGraph(): JSX.Element { ### Performance Optimization with Debounced Events ```typescript -import { useGraphEvent } from "@gravity-ui/graph/react"; -import { ESchedulerPriority } from "@gravity-ui/graph"; -import type { Graph, TCameraState } from "@gravity-ui/graph"; +import { useSignal } from "@gravity-ui/graph/react"; +import type { Graph } from "@gravity-ui/graph"; interface Props { graph: Graph; } -function CameraInfo({ graph }: Props): JSX.Element | null { - const [cameraState, setCameraState] = useState(null); - - // Debounce camera updates for performance - useGraphEvent( - graph, - "camera-change", - ({ camera }: { camera: TCameraState }): void => { - setCameraState(camera); - }, - { - priority: ESchedulerPriority.LOW, - frameInterval: 2, - frameTimeout: 50, - } - ); - - if (!cameraState) return null; +function CameraInfo({ graph }: Props): JSX.Element { + const cameraState = useSignal(graph.$camera); return (
diff --git a/docs/react/usage.md b/docs/react/usage.md index 141c6f05..ca22012e 100644 --- a/docs/react/usage.md +++ b/docs/react/usage.md @@ -159,6 +159,7 @@ The library provides a comprehensive set of React hooks for working with the gra | `useSignal` | Subscribe to signal values | | `useComputedSignal` | Create computed signals | | `useSignalEffect` | Run effects on signal changes | +| `useSignalLayoutEffect` | Run layout effects on signal changes (before paint) | | `useSchedulerDebounce` | Create debounced function with frame timing | | `useSchedulerThrottle` | Create throttled function with frame timing | | `useScheduledTask` | Schedule task for frame-based execution | diff --git a/docs/rendering/layers.md b/docs/rendering/layers.md index bf516bdf..0410ec82 100644 --- a/docs/rendering/layers.md +++ b/docs/rendering/layers.md @@ -204,12 +204,14 @@ export class MyLayer extends Layer { // `afterInit` is called after the layer's elements (canvas/html) are created and attached. protected afterInit() { - // Subscribe to events here - this.onGraphEvent("camera-change", this.performRender); - // Add other event listeners (e.g., to this.getHTML() or this.getCanvas()) + // Base Layer applies transforms and calls onCameraChange() on $camera updates super.afterInit(); } + protected onCameraChange(_camera: TCameraState): void { + this.performRender(); + } + // --- Rendering --- // This method is called automatically when performRender is invoked (e.g., on camera change). protected render() { @@ -337,10 +339,12 @@ The Layer class provides convenient wrapper methods for subscribing to events us **Example:** ```typescript protected afterInit() { - this.onGraphEvent("camera-change", this.handleCameraChange); - this.onCanvasEvent("mousedown", this.handleMouseDown); super.afterInit(); } + +protected onCameraChange(camera: TCameraState): void { + this.updateOverlay(camera.scale); +} ``` When the layer is unmounted, all handlers are automatically removed. @@ -365,10 +369,9 @@ export class MyCustomLayer extends Layer { signal: this.eventAbortController.signal }); - // Option 2: Manual subscription to graph events with AbortController - this.props.graph.on("camera-change", this.performRender, { - signal: this.eventAbortController.signal - }); + // Option 2: Direct $camera subscription — only when onCameraChange() is not enough + // (e.g. multiple handlers with different priorities before super.afterInit()) + this.onSignal(this.props.graph.$camera, this.performRender); // Option 3: Manual subscription without AbortController // In this case, you must handle unsubscription manually @@ -440,12 +443,13 @@ export class GridLayer extends Layer { * after the layer is unmounted and reattached. */ protected afterInit(): void { - // Use the onGraphEvent wrapper method for automatic cleanup - this.onGraphEvent("camera-change", this.performRender); - - // Call parent afterInit to ensure proper initialization + // Committed camera state — override onCameraChange(), base Layer handles subscription super.afterInit(); } + + protected onCameraChange(_camera: TCameraState): void { + this.performRender(); + } protected render() { const { ctx, camera } = this.context; diff --git a/docs/system/camera.md b/docs/system/camera.md index 327373a1..56430fae 100644 --- a/docs/system/camera.md +++ b/docs/system/camera.md @@ -39,7 +39,7 @@ export type TCameraState = { ## Camera control -- `set(newState)` – update camera state (emits `camera-change`). +- `set(newState)` – update camera state (emits `camera-change`, then commits to `$camera` if not prevented). - `resize({ width, height })` – update canvas size preserving center. - `move(dx, dy)` – pan. - `zoom(x, y, scale)` – zoom anchored to a screen-space point `(x, y)`. @@ -198,9 +198,52 @@ graph.cameraService.disableAutoPanning(); - Canvas rendering and hit-testing use the full camera-space viewport (`relative*`) for correctness and performance. - HTML/React view switches at defined zoom tiers (`getCameraBlockScaleLevel`), which depend on `scale` independent of insets. -## Events +## Events and reactive state -- `camera-change` – emitted on any camera state update (`set`, `move`, `resize`, `zoom`, etc.). +Camera updates use the same **preventable default action** pattern as `block-change`: + +1. **`camera-change` event** — emitted synchronously **before** commit. `event.detail` is the **proposed** next `TCameraState`. Listeners may call `event.preventDefault()` to cancel the update. +2. **`graph.$camera` signal** — holds the **committed** camera state. Updated only when the default action was not prevented. + +### Choosing an API + +| API | State | Use for | +|-----|-------|---------| +| `camera-change` event | **Proposed** (`event.detail`) | Intercept, validate, `preventDefault()`, log intent before commit | +| `graph.$camera` / `graph.cameraState` | **Committed** | Reactive subscriptions, React hooks, rendering, derived UI | +| `graph.cameraService` | **Committed** (via `getCameraState()`) | Imperative control: `move`, `zoom`, `set`, `resize`, `setViewportInsets` | + +Outside event handlers, `$camera.value` and `cameraService.getCameraState()` refer to the same committed state. During a `camera-change` handler, `event.detail` is proposed; `$camera` and `cameraService` still hold the previous committed state until the default action runs. + +### Why both event and signal? + +Event listeners run in registration order **before** commit. If an early handler calls `preventDefault()`, later handlers still see the proposed state in `event.detail` and may incorrectly update their own state unless they re-read committed values. + +**Prefer `$camera` for rendering and side effects** (layers, React hooks, auto-panning sync). Subscribe via `Layer.onSignal(graph.$camera, …)` or `graph.$camera.subscribe()`. + +**Keep `camera-change` for interception** — validation, logging, or cancelling a change before it is applied (same as `block-change`). + +### Layer example + +```typescript +protected onCameraChange(camera: TCameraState): void { + // Runs after built-in HTML/canvas transforms on each committed camera update + this.updateRuler(camera); +} +``` + +### React example + +```typescript +import { useSignalLayoutEffect } from "@gravity-ui/graph/react"; + +useSignalLayoutEffect(() => { + const camera = graph.$camera.value; + // react to committed camera state +}, [graph]); +``` + +For imperative/event-based API (e.g. logging proposed state before commit), use `useGraphEvent(graph, "camera-change", …)`. ## Viewport insets (optional focus) diff --git a/docs/system/events.md b/docs/system/events.md index 5d64bb55..1fc47576 100644 --- a/docs/system/events.md +++ b/docs/system/events.md @@ -97,9 +97,28 @@ interface GraphMouseEvent = CustomEvent<{ ### Camera Events -| Event | Description | -|-------|-------------| -| `camera-change` | Fires on camera state changes | +| Source | When it fires | Payload | Use for | +|--------|---------------|---------|---------| +| `camera-change` event | Before commit | Proposed `TCameraState` in `event.detail` | Interception, `preventDefault()`, logging intent | +| `graph.$camera` signal | After successful commit | Committed `TCameraState` | Rendering, transforms, derived state, React subscriptions | + +```typescript +// Intercept before commit (same pattern as block-change) +graph.on("camera-change", (event) => { + if (shouldCancel(event.detail)) { + event.preventDefault(); + } +}); + +// React to applied state — safe even if another listener cancelled an earlier change +graph.$camera.subscribe((camera) => { + applyCameraTransform(camera); +}); +``` + +In `Layer` subclasses, override `onCameraChange()` for committed camera updates. The base `Layer` subscribes to `$camera` in `afterInit()`. Use `onGraphEvent("camera-change", …)` only to intercept or cancel a change before commit. + +See [Camera Service — Choosing an API](./camera.md#choosing-an-api) for when to use `cameraService`, `$camera`, or `camera-change`. ## Event Cleanup with AbortController @@ -147,19 +166,19 @@ export class MyLayer extends Layer { * This is the proper place to set up event subscriptions using onGraphEvent(). */ protected afterInit(): void { - // Use the onGraphEvent wrapper method that automatically includes the AbortController signal - this.onGraphEvent("camera-change", this.handleCameraChange); this.onGraphEvent("blocks-selection-change", this.handleSelectionChange); this.onGraphEvent("mousedown", this.handleMouseDown); - - // DOM event listeners can also use the AbortController signal - this.getCanvas()?.addEventListener("mousedown", this.handleMouseDown, { - signal: this.eventAbortController.signal + + this.getCanvas()?.addEventListener("mousedown", this.handleMouseDown, { + signal: this.eventAbortController.signal, }); - - // Always call super.afterInit() at the end of your implementation + super.afterInit(); } + + protected onCameraChange(_camera: TCameraState): void { + this.handleCameraChange(); + } // No need to manually remove event listeners in unmount // They are automatically removed by the AbortController diff --git a/e2e/README.md b/e2e/README.md index c95b6204..7f6faf4b 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -23,6 +23,10 @@ e2e/ │ │ ├── block-click.spec.ts # Block click and selection tests │ │ └── block-hover.spec.ts # Block cursor hover tests │ ├── camera-control.spec.ts # Camera zoom and pan tests +│ ├── camera/ +│ │ ├── camera-change-signal.spec.ts # camera-change vs $camera signal +│ │ ├── camera-block-scale-level.spec.ts +│ │ └── camera-mouse-emulation.spec.ts │ ├── drag-and-drop.spec.ts # Drag and drop tests │ └── selection-test.spec.ts # Selection tests ├── pages/ # HTML pages for tests diff --git a/e2e/page-objects/GraphPageObject.ts b/e2e/page-objects/GraphPageObject.ts index 3e009f56..624e61f4 100644 --- a/e2e/page-objects/GraphPageObject.ts +++ b/e2e/page-objects/GraphPageObject.ts @@ -482,6 +482,40 @@ export class GraphPageObject { }); } + /** + * Returns a snapshot of the committed camera state from `graph.$camera`. + */ + async getCameraSignalSnapshot(): Promise<{ x: number; y: number; scale: number }> { + return this.page.evaluate(() => { + const { x, y, scale } = window.graph.$camera.value; + return { x, y, scale }; + }); + } + + /** + * Subscribes to `graph.$camera` and collects committed state snapshots. + * Returns a function that reads all updates collected since subscription. + */ + async collectCameraSignalUpdates(): Promise< + () => Promise> + > { + const key = `__cameraSignal_${listenerIdCounter++}`; + + await this.page.evaluate((storageKey) => { + (window as any)[storageKey] = []; + (window as any)[`${storageKey}_unsub`] = window.graph.$camera.subscribe((state) => { + (window as any)[storageKey].push({ x: state.x, y: state.y, scale: state.scale }); + }); + }, key); + + return async () => { + const json = await this.page.evaluate((storageKey) => { + return JSON.stringify((window as any)[storageKey] ?? []); + }, key); + return JSON.parse(json) as Array<{ x: number; y: number; scale: number }>; + }; + } + /** * Starts collecting graph events of the given name in the browser context. * Returns a {@link GraphEventListener} whose `analyze()` method lets you diff --git a/e2e/tests/camera/camera-change-signal.spec.ts b/e2e/tests/camera/camera-change-signal.spec.ts new file mode 100644 index 00000000..6dcb1455 --- /dev/null +++ b/e2e/tests/camera/camera-change-signal.spec.ts @@ -0,0 +1,165 @@ +import { test, expect } from "@playwright/test"; + +import { GraphPageObject } from "../../page-objects/GraphPageObject"; + +test.describe("camera-change event and $camera signal", () => { + let graphPO: GraphPageObject; + + test.beforeEach(async ({ page }) => { + graphPO = new GraphPageObject(page); + await graphPO.initialize({ + blocks: [ + { + id: "block-1", + is: "Block", + x: 100, + y: 100, + width: 200, + height: 100, + name: "Block 1", + anchors: [], + selected: false, + }, + ], + connections: [], + settings: { + canDragCamera: true, + canZoomCamera: true, + }, + }); + }); + + test("preventDefault on camera-change skips committed state and $camera update", async ({ page }) => { + await page.evaluate(() => { + window.graph.on("camera-change", (event: Event) => { + event.preventDefault(); + }); + }); + + const camera = graphPO.getCamera(); + const initial = await camera.getState(); + const initialSignal = await graphPO.getCameraSignalSnapshot(); + + await camera.pan(120, 80); + + const final = await camera.getState(); + const finalSignal = await graphPO.getCameraSignalSnapshot(); + + expect(final.x).toBe(initial.x); + expect(final.y).toBe(initial.y); + expect(final.scale).toBe(initial.scale); + expect(finalSignal.x).toBe(initialSignal.x); + expect(finalSignal.y).toBe(initialSignal.y); + expect(finalSignal.scale).toBe(initialSignal.scale); + }); + + test("successful camera move updates $camera after commit", async ({ page }) => { + const readSignalUpdates = await graphPO.collectCameraSignalUpdates(); + + const camera = graphPO.getCamera(); + const before = await camera.getState(); + const n0 = (await readSignalUpdates()).length; + + await camera.pan(100, 50); + await graphPO.waitForFrames(2); + + const after = await camera.getState(); + const updates = (await readSignalUpdates()).slice(n0); + + expect(after.x).not.toBe(before.x); + expect(after.y).not.toBe(before.y); + expect(updates.length).toBeGreaterThan(0); + + const last = updates[updates.length - 1]; + expect(last.x).toBe(after.x); + expect(last.y).toBe(after.y); + expect(last.scale).toBe(after.scale); + }); + + test("during camera-change event, $camera still holds previous committed state", async ({ page }) => { + await page.evaluate(() => { + const log: Array<{ + phase: "event" | "signal"; + detailX?: number; + signalX: number; + serviceX: number; + }> = []; + (window as unknown as { __cameraCommitLog: typeof log }).__cameraCommitLog = log; + + window.graph.on("camera-change", (event: CustomEvent<{ x: number }>) => { + log.push({ + phase: "event", + detailX: event.detail.x, + signalX: window.graph.$camera.value.x, + serviceX: window.graph.cameraService.getCameraState().x, + }); + }); + + window.graph.$camera.subscribe((state) => { + log.push({ + phase: "signal", + signalX: state.x, + serviceX: window.graph.cameraService.getCameraState().x, + }); + }); + }); + + const camera = graphPO.getCamera(); + const before = await camera.getState(); + + await camera.pan(100, 0); + await graphPO.waitForFrames(2); + + const after = await camera.getState(); + const log = await page.evaluate( + () => (window as unknown as { __cameraCommitLog: Array<{ phase: string; detailX?: number; signalX: number; serviceX: number }> }).__cameraCommitLog + ); + + expect(after.x).not.toBe(before.x); + + const eventEntries = log.filter((e) => e.phase === "event"); + const signalEntries = log.filter((e) => e.phase === "signal"); + + expect(eventEntries.length).toBeGreaterThan(0); + expect(signalEntries.length).toBeGreaterThan(0); + + const lastEvent = eventEntries[eventEntries.length - 1]; + const lastSignal = signalEntries[signalEntries.length - 1]; + + // In the event handler, proposed x is in detail but committed stores are unchanged + expect(lastEvent.detailX).not.toBe(before.x); + expect(lastEvent.signalX).toBe(before.x); + expect(lastEvent.serviceX).toBe(before.x); + + // Signal subscriber runs after commit with the new x + expect(lastSignal.signalX).toBe(after.x); + expect(lastSignal.serviceX).toBe(after.x); + }); + + test("collectGraphEventDetails receives proposed state even when change is prevented", async ({ page }) => { + await page.evaluate(() => { + window.graph.on("camera-change", (event: Event) => { + event.preventDefault(); + }); + }); + + const readEvents = await graphPO.collectGraphEventDetails<{ scale: number; x: number; y: number }>("camera-change"); + const n0 = (await readEvents()).length; + + const camera = graphPO.getCamera(); + const before = await camera.getState(); + + await camera.pan(80, 40); + + const events = (await readEvents()).slice(n0); + expect(events.length).toBeGreaterThan(0); + + const last = events[events.length - 1]; + expect(last.x).not.toBe(before.x); + expect(last.y).not.toBe(before.y); + + const committed = await camera.getState(); + expect(committed.x).toBe(before.x); + expect(committed.y).toBe(before.y); + }); +}); diff --git a/src/components/canvas/layers/graphLayer/GraphLayer.ts b/src/components/canvas/layers/graphLayer/GraphLayer.ts index a0865a6c..249aae9d 100644 --- a/src/components/canvas/layers/graphLayer/GraphLayer.ts +++ b/src/components/canvas/layers/graphLayer/GraphLayer.ts @@ -106,13 +106,6 @@ export class GraphLayer extends Layer { }); this.attachListeners(); - // Subscribe to graph events here instead of in the constructor - this.onGraphEvent("camera-change", this.performRender); - this.onGraphEvent("camera-change", (event) => { - if (this.context.graph.rootStore.settings.getConfigFlag("emulateMouseEventsOnCameraChange")) { - this.onCameraChangeEmulateMouseEvents(event.detail); - } - }); this.context.graph.rootStore.blocksList.$blocks.subscribe(() => { this.performRender(); }); @@ -122,6 +115,12 @@ export class GraphLayer extends Layer { super.afterInit(); } + protected onCameraChange(camera: TCameraState): void { + if (this.context.graph.rootStore.settings.getConfigFlag("emulateMouseEventsOnCameraChange")) { + this.onCameraChangeEmulateMouseEvents(camera); + } + } + /** * Attaches DOM event listeners to the root element. * All event listeners are registered with the rootOn wrapper method to ensure they are properly cleaned up diff --git a/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts b/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts index 03dd2cb3..e8ab4323 100644 --- a/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts +++ b/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts @@ -2,6 +2,7 @@ import RBush from "rbush"; import { GraphMouseEvent, extractNativeGraphMouseEvent, isGraphEvent } from "../../../../graphEvents"; import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; +import { TCameraState } from "../../../../services/camera/CameraService"; import { ESelectionStrategy } from "../../../../services/selection"; import { EAnchorType } from "../../../../store/anchor/Anchor"; import { TBlockId } from "../../../../store/block/Block"; @@ -230,11 +231,6 @@ export class PortConnectionLayer extends Layer< this.portsUnsubscribe = this.onSignal(this.context.graph.rootStore.connectionsList.ports.$ports, checkPortsChanged); - // Subscribe to camera changes to invalidate tree when viewport changes - this.onGraphEvent("camera-change", () => { - this.isSnappingTreeOutdated = true; - }); - this.context.graph.keyboardService.onPress( "Escape", () => { @@ -250,6 +246,10 @@ export class PortConnectionLayer extends Layer< super.afterInit(); } + protected onCameraChange(_camera: TCameraState): void { + this.isSnappingTreeOutdated = true; + } + public enable = (): void => { this.enabled = true; }; diff --git a/src/graph.ts b/src/graph.ts index 26024b18..469bed89 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -15,7 +15,8 @@ import { HitTest } from "./services/HitTest"; import { KeyboardService } from "./services/KeyboardService"; import { Layer, LayerPublicProps } from "./services/Layer"; import { Layers } from "./services/LayersService"; -import { CameraService } from "./services/camera/CameraService"; +import { CameraService, getInitCameraState } from "./services/camera/CameraService"; +import type { TCameraState } from "./services/camera/CameraService"; import { DragService } from "./services/drag"; import { RootStore } from "./store"; import { TBlockId } from "./store/block/Block"; @@ -102,6 +103,12 @@ export class Graph { public $graphConstants = signal(initGraphConstants); + /** + * Committed camera state. Updated only after a non-prevented `camera-change` event. + * Subscribe via `signal.subscribe()` or Layer's `onSignal()` to react to applied changes. + */ + public $camera = signal(getInitCameraState()); + public state: GraphState = GraphState.INIT; protected config: TGraphConfig; diff --git a/src/plugins/devtools/DevToolsLayer.ts b/src/plugins/devtools/DevToolsLayer.ts index 70da5c62..066088f4 100644 --- a/src/plugins/devtools/DevToolsLayer.ts +++ b/src/plugins/devtools/DevToolsLayer.ts @@ -73,7 +73,6 @@ export class DevToolsLayer extends Layer this.performRender()); this.onRootEvent( "mousemove", (event: MouseEvent): void => { @@ -127,6 +126,10 @@ export class DevToolsLayer extends Layer { this.performRender(); }); - this.onGraphEvent("camera-change", () => this.performRender()); this.onGraphEvent("colors-changed", () => this.performRender()); // block-change / batched geometry during drag — recalculate coords when blocks move @@ -83,6 +83,10 @@ export class MiniMapLayer extends Layer { super.afterInit(); } + protected onCameraChange(_camera: TCameraState): void { + this.performRender(); + } + protected updateCanvasSize(): void { const dpr = this.getDRP(); this.canvas.width = this.minimapWidth * dpr; diff --git a/src/react-components/hooks/useSceneChange.ts b/src/react-components/hooks/useSceneChange.ts index ef0bfcdf..2c3f92ae 100644 --- a/src/react-components/hooks/useSceneChange.ts +++ b/src/react-components/hooks/useSceneChange.ts @@ -4,7 +4,6 @@ import { Graph } from "../../graph"; import { ESchedulerPriority } from "../../lib"; import { useSchedulerDebounce } from "./schedulerHooks"; -import { useGraphEvent } from "./useGraphEvents"; /** * Hook to handle scene updates. @@ -15,29 +14,34 @@ import { useGraphEvent } from "./useGraphEvents"; * @param fn - Function to handle scene updates */ export function useSceneChange(graph: Graph, fn: () => void) { - const handleCameraChange = useSchedulerDebounce(fn, { + const handleSceneChange = useSchedulerDebounce(fn, { priority: ESchedulerPriority.HIGHEST, frameInterval: 1, }); - /* Subscribe to camera changes */ - useGraphEvent(graph, "camera-change", handleCameraChange); + useLayoutEffect(() => { + handleSceneChange(); + + const unsubscribe = graph.$camera.subscribe(() => { + handleSceneChange(); + }); + + return () => { + unsubscribe(); + }; + }, [graph, handleSceneChange]); - // Subscribe to hitTest updates to catch when blocks become available in viewport useEffect(() => { - graph.hitTest.on("update", handleCameraChange); + graph.hitTest.on("update", handleSceneChange); return () => { - graph.hitTest.off("update", handleCameraChange); + graph.hitTest.off("update", handleSceneChange); }; - }, [graph, handleCameraChange]); + }, [graph, handleSceneChange]); - // Check initial camera scale on mount to handle cases where zoomTo() is called - // during initialization before the camera-change event subscription is active useLayoutEffect(() => { - handleCameraChange(); return () => { - handleCameraChange.cancel(); + handleSceneChange.cancel(); }; - }, [graph, handleCameraChange]); + }, [handleSceneChange]); } diff --git a/src/react-components/hooks/useSignal.test.ts b/src/react-components/hooks/useSignal.test.ts index 8639bc86..7abb4e36 100644 --- a/src/react-components/hooks/useSignal.test.ts +++ b/src/react-components/hooks/useSignal.test.ts @@ -2,7 +2,7 @@ import { signal } from "@preact/signals-core"; import type { Signal } from "@preact/signals-core"; import { act, renderHook } from "@testing-library/react"; -import { useComputedSignal, useSignal, useSignalEffect } from "./useSignal"; +import { useComputedSignal, useSignal, useSignalEffect, useSignalLayoutEffect } from "./useSignal"; describe("useSignal hook", () => { describe("Getting signal value", () => { @@ -602,3 +602,26 @@ describe("useSignalEffect hook", () => { }); }); }); + +describe("useSignalLayoutEffect hook", () => { + it("should execute effect on mount and react to signal changes", () => { + const baseSignal: Signal = signal("initial"); + const effectFn = jest.fn(); + + renderHook(() => + useSignalLayoutEffect(() => { + effectFn(baseSignal.value); + }, [baseSignal]) + ); + + expect(effectFn).toHaveBeenCalledWith("initial"); + expect(effectFn).toHaveBeenCalledTimes(1); + + act(() => { + baseSignal.value = "updated"; + }); + + expect(effectFn).toHaveBeenCalledWith("updated"); + expect(effectFn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/react-components/hooks/useSignal.ts b/src/react-components/hooks/useSignal.ts index 6cb21c2f..d5d375bc 100644 --- a/src/react-components/hooks/useSignal.ts +++ b/src/react-components/hooks/useSignal.ts @@ -1,4 +1,4 @@ -import { DependencyList, useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; +import { DependencyList, useCallback, useEffect, useLayoutEffect, useMemo, useSyncExternalStore } from "react"; import { computed, effect } from "@preact/signals-core"; import type { Signal } from "@preact/signals-core"; @@ -65,3 +65,14 @@ export function useSignalEffect(effectFn: () => void, deps: DependencyList) { return effect(() => handle()); }, deps); } + +/** + * Like {@link useSignalEffect}, but runs the subscription setup in `useLayoutEffect` + * so the effect fires synchronously after DOM updates and before paint. + */ +export function useSignalLayoutEffect(effectFn: () => void, deps: DependencyList) { + const handle = useFn(effectFn); + useLayoutEffect(() => { + return effect(() => handle()); + }, deps); +} diff --git a/src/services/Layer.ts b/src/services/Layer.ts index 2daffca1..19025c77 100644 --- a/src/services/Layer.ts +++ b/src/services/Layer.ts @@ -311,7 +311,7 @@ export class Layer< constants: event.detail.constants, }); }); - this.onGraphEvent("camera-change", (event) => this.onCameraChange(event.detail)); + this.onSignal(this.props.graph.$camera, (camera) => this.handleCommittedCameraChange(camera)); this.onSignal(this.props.graph.layers.rootSize, this.updateSize); this.shouldRenderChildren = true; @@ -326,7 +326,7 @@ export class Layer< } } - this.onCameraChange(this.context.camera.getCameraState()); + this.handleCommittedCameraChange(this.context.camera.getCameraState()); this.updateSize(); } @@ -351,7 +351,12 @@ export class Layer< } } - protected onCameraChange(camera: TCameraState) { + private handleCommittedCameraChange(camera: TCameraState): void { + this.applyCameraTransform(camera); + this.onCameraChange(camera); + } + + private applyCameraTransform(camera: TCameraState): void { // Check if HTML layer should be active based on activationScale if (this.html && this.props.html?.activationScale !== undefined) { const shouldBeActive = camera.scale >= this.props.html.activationScale; @@ -369,6 +374,19 @@ export class Layer< } } + /** + * Called when committed camera state changes (`graph.$camera` signal). + * Override in derived layers to react to pan, zoom, and resize. + * + * Built-in HTML/canvas transforms (`transformByCameraPosition`, `activationScale`) + * are applied before this hook via `applyCameraTransform`. + * + * @param camera - Committed camera state (same as `graph.$camera.value`) + */ + protected onCameraChange(_camera: TCameraState): void { + // Override in subclasses + } + /** * Called when the HTML layer's active state changes based on camera scale. * Override this method to implement custom behavior when the layer activates/deactivates. diff --git a/src/services/camera/Camera.ts b/src/services/camera/Camera.ts index 5f820665..3c006b90 100644 --- a/src/services/camera/Camera.ts +++ b/src/services/camera/Camera.ts @@ -29,6 +29,8 @@ export class Camera extends EventedComponent void; + constructor(props: TCameraProps, parent: Component) { super(props, parent); @@ -39,12 +41,10 @@ export class Camera extends EventedComponent) => { - const state = event.detail; + private handleCameraStateChange = (state: TCameraState): void => { const isAutoPanningEnabled = state.autoPanningEnabled; if (isAutoPanningEnabled) { @@ -83,7 +83,7 @@ export class Camera extends EventedComponent) { - this.graph.executеDefaultEventAction("camera-change", Object.assign({}, this.state, newState), () => { + const nextState = Object.assign({}, this.state, newState); + this.graph.executеDefaultEventAction("camera-change", nextState, () => { this.state = Object.assign(this.state, newState); this.updateRelative(); + this.syncCameraSignal(); }); } + private syncCameraSignal(): void { + this.graph.$camera.value = { + ...this.state, + viewportInsets: { ...this.state.viewportInsets }, + }; + } + private updateRelative() { // Relative coordinates are based on full canvas viewport (ignore insets) this.state.relativeX = this.getRelative(this.state.x) | 0; diff --git a/src/stories/Playground/Toolbox.tsx b/src/stories/Playground/Toolbox.tsx index 5c4dedb9..51f709d9 100644 --- a/src/stories/Playground/Toolbox.tsx +++ b/src/stories/Playground/Toolbox.tsx @@ -1,17 +1,13 @@ -import React, { useState } from "react"; +import React from "react"; import { MagnifierMinus, MagnifierPlus, SquareDashed } from "@gravity-ui/icons"; import { Button, Flex, Icon, Tooltip } from "@gravity-ui/uikit"; import { Graph } from "../../graph"; -import { useGraphEvent } from "../../react-components"; +import { useSignal } from "../../react-components"; export function Toolbox({ className, graph }: { className: string; graph: Graph }) { - const [scale, setScale] = useState(1); - - useGraphEvent(graph, "camera-change", ({ scale }) => { - setScale(scale); - }); + const scale = useSignal(graph.$camera).scale; return ( diff --git a/src/utils/functions/dragListener.ts b/src/utils/functions/dragListener.ts index c631889a..38c7e693 100644 --- a/src/utils/functions/dragListener.ts +++ b/src/utils/functions/dragListener.ts @@ -88,17 +88,14 @@ export function dragListener(document: Document | HTMLDivElement | HTMLCanvasEle }; const mouseupBinded = mouseup.bind(null, emitter); - // Handle camera-change for auto-panning synchronization - const handleCameraChange = () => { + // Re-emit drag update when committed camera state changes during auto-panning + const handleCameraChange = (): void => { if (started && !finished && lastMouseEvent) { - // Re-emit drag update with last known mouse position emitter.emit(EVENTS.DRAG_UPDATE, lastMouseEvent); } }; - if (graph && autopanning) { - graph.on("camera-change", handleCameraChange); - } + const unsubscribeCamera = graph && autopanning ? graph.$camera.subscribe(handleCameraChange) : undefined; /** * Check if the mouse has moved beyond the threshold distance @@ -166,9 +163,7 @@ export function dragListener(document: Document | HTMLDivElement | HTMLCanvasEle const cleanup = (): void => { cleanupTextSelection(); - if (graph && autopanning) { - graph.off("camera-change", handleCameraChange); - } + unsubscribeCamera?.(); document.removeEventListener("mousemove", mousemoveBinded); // Also remove threshold listener if it was added if (threshold > 0) { From 367b44f485b3d045962844b0b23dcf0b107c2a35 Mon Sep 17 00:00:00 2001 From: draedful Date: Mon, 29 Jun 2026 02:44:48 +0300 Subject: [PATCH 2/2] docs(story): align viewportInsets example with committed $camera API Remove nonexistent graph.cameraState from camera docs and sync the overlay via useSceneChange instead of camera-change events. Co-authored-by: Cursor --- docs/system/camera.md | 2 +- .../viewportInsets/viewportInsets.stories.tsx | 30 +++++++++++-------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/docs/system/camera.md b/docs/system/camera.md index 56430fae..8f98ea25 100644 --- a/docs/system/camera.md +++ b/docs/system/camera.md @@ -210,7 +210,7 @@ Camera updates use the same **preventable default action** pattern as `block-cha | API | State | Use for | |-----|-------|---------| | `camera-change` event | **Proposed** (`event.detail`) | Intercept, validate, `preventDefault()`, log intent before commit | -| `graph.$camera` / `graph.cameraState` | **Committed** | Reactive subscriptions, React hooks, rendering, derived UI | +| `graph.$camera` | **Committed** | Reactive subscriptions, React hooks, rendering, derived UI | | `graph.cameraService` | **Committed** (via `getCameraState()`) | Imperative control: `move`, `zoom`, `set`, `resize`, `setViewportInsets` | Outside event handlers, `$camera.value` and `cameraService.getCameraState()` refer to the same committed state. During a `camera-change` handler, `event.detail` is proposed; `$camera` and `cameraService` still hold the previous committed state until the default action runs. diff --git a/src/stories/examples/viewportInsets/viewportInsets.stories.tsx b/src/stories/examples/viewportInsets/viewportInsets.stories.tsx index 04c15810..eb8592cc 100644 --- a/src/stories/examples/viewportInsets/viewportInsets.stories.tsx +++ b/src/stories/examples/viewportInsets/viewportInsets.stories.tsx @@ -5,12 +5,24 @@ import type { Meta, StoryObj } from "@storybook/react-webpack5"; import { TBlock } from "../../../components/canvas/blocks/Block"; import { Graph, GraphState } from "../../../graph"; -import { GraphCanvas, useGraph, useGraphEvent } from "../../../react-components"; +import { GraphCanvas, useGraph, useGraphEvent, useSceneChange } from "../../../react-components"; import { generatePrettyBlocks } from "../../configurations/generatePretty"; import { BlockStory } from "../../main/Block"; type TRect = { x: number; y: number; width: number; height: number }; +function computeOverlayRect(graph: Graph): TRect { + const state = graph.$camera.value; + const insets = graph.cameraService.getViewportInsets(); + + return { + x: insets.left, + y: insets.top, + width: Math.max(0, state.width - insets.left - insets.right), + height: Math.max(0, state.height - insets.top - insets.bottom), + }; +} + const storyConfig = generatePrettyBlocks({ layersCount: 6, connectionsPerLayer: 80 }); const Toolbar = ({ @@ -107,20 +119,14 @@ const InsetsOverlay = ({ graph, maintain }: { graph: Graph; maintain: "center" | rectRef.current = rect; }, [rect]); - useGraphEvent(graph, "camera-change", () => { - // Recompute from static viewport insets and current canvas size only - const state = graph.cameraService.getCameraState(); - const insets = graph.cameraService.getViewportInsets(); - const next = { - x: insets.left, - y: insets.top, - width: Math.max(0, state.width - insets.left - insets.right), - height: Math.max(0, state.height - insets.top - insets.bottom), - }; + const syncRectFromCamera = useCallback(() => { + const next = computeOverlayRect(graph); setRect((prev) => prev.x !== next.x || prev.y !== next.y || prev.width !== next.width || prev.height !== next.height ? next : prev ); - }); + }, [graph]); + + useSceneChange(graph, syncRectFromCamera); // Initialize centered 300x300 viewport after graph is attached useGraphEvent(graph, "state-change", ({ state }) => {