Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,23 @@ BlockState {
}
```

**Port State** (`src/store/connection/port/Port.ts`):
```typescript
PortState {
$state: Signal<TPort> // Raw port data (id, x, y, component, lookup)
$point: computed(() => ...) // Effective position (respects delegation)
delegate(target): void // Mirror another port's position
undelegate(): void // Restore own position
isDelegated: boolean // Whether currently delegated
}
```

**Key Patterns**:
- Components subscribe to signals via `onSignal()` in `afterInit()`
- Use `batch(() => { ... })` to wrap multiple signal updates
- React integration via `useSignal()` hook
- Automatic cleanup via AbortController
- Port delegation: `port.delegate(targetPort)` makes port mirror target's `$point`; `port.undelegate()` restores saved position

### Camera System

Expand Down
141 changes: 141 additions & 0 deletions docs/components/canvas-graph-component.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ classDiagram
+context: TComponentContext
+addEventListener()
+removeEventListener()
#eventedArea()
}

class GraphComponent {
Expand Down Expand Up @@ -296,6 +297,146 @@ graph.dragService.$state.subscribe((state) => {

For more details on the drag system, see [Drag System](../system/drag-system.md).

### 5. Evented Areas

Evented areas let you define **interactive sub-regions** inside a component — each with its own set of event handlers. Instead of creating separate child components for small interactive zones (buttons, icons, resize handles drawn on canvas), you declare them inline during `render()`.

#### API

```typescript
protected eventedArea(fn: (state: TEventedAreaState) => TRect, params: TEventedAreaParams): TRect
```

| Parameter | Description |
|-----------|-------------|
| `fn` | A callback that receives the area's local state (`TEventedAreaState`), draws the area on the canvas, and returns its bounding `TRect` (`{ x, y, width, height }`) in world coordinates. |
| `params` | An object with a required `key`, event handlers, and an optional `onHitBox` callback. |

**`TEventedAreaState`:**

```typescript
type TEventedAreaState = {
hovered: boolean;
};
```

The framework automatically tracks hover state per area key. When cursor enters/leaves an area, `hovered` changes and the component re-renders, so `fn` receives the updated state. This eliminates the need to manually subscribe to `mouseenter`/`mouseleave` for visual feedback.

**`TEventedAreaParams`:**

```typescript
type TEventedAreaParams = {
key: string;
onHitBox?: (data: HitBoxData) => boolean;
[eventName: string]: ((event: Event) => void) | ((data: HitBoxData) => boolean) | string | undefined;
};
```

- **`key`** (required) — unique identifier for the area within the component. If an area with the same key already exists, it is replaced instead of duplicated. Also used to persist the area's local state across render cycles.
- **Event handlers** (`click`, `mouseenter`, etc.) — called when the event fires and the hit test passes.
- **`onHitBox`** (optional) — fine-grained hit test. Receives the `HitBoxData` with world-space coordinates. Return `true` if the area should respond. When omitted, a default AABB intersection check against the area rect is used.

The method returns the `TRect` produced by `fn`, so you can use it for further layout calculations.

#### How It Works

1. **Registration** — `eventedArea()` is called during `render()`. It executes `fn(state)` (which draws on the canvas and returns the rect), stores the area with its handlers.
2. **Local state** — the framework maintains `hovered` state per area key. When the hover state changes, the component automatically re-renders so `fn` receives the updated `{ hovered }`.
3. **Cleanup** — all areas are cleared in `willRender()` before each render cycle, so they always match the current visual state. The hover key persists across renders.
4. **Event dispatch** — when `_fireEvent` is invoked on the component, it checks each registered area: if the area has a handler for the event type and `onHitBox` (or the default AABB check) confirms a hit, the handler fires.
5. **Listener detection** — `_hasListener` accounts for evented areas, so events bubble correctly to components that only use evented areas without conventional `addEventListener` calls.

#### Usage Examples

**Basic: two clickable zones on a block**

```typescript
class MyBlock extends CanvasBlock {
protected render() {
super.render();

// Top-left 50×50 "edit" button — uses hovered state for visual feedback
this.eventedArea(
({ hovered }) => {
const ctx = this.context.ctx;
ctx.fillStyle = hovered ? "rgba(0, 120, 255, 0.4)" : "rgba(0, 120, 255, 0.2)";
ctx.fillRect(this.state.x, this.state.y, 50, 50);
return { x: this.state.x, y: this.state.y, width: 50, height: 50 };
},
{
key: "edit-btn",
click: () => this.onEditClick(),
}
);

// Top-right 50×50 "delete" button
this.eventedArea(
({ hovered }) => {
const ctx = this.context.ctx;
ctx.fillStyle = hovered ? "rgba(255, 0, 0, 0.4)" : "rgba(255, 0, 0, 0.2)";
const x = this.state.x + this.state.width - 50;
ctx.fillRect(x, this.state.y, 50, 50);
return { x, y: this.state.y, width: 50, height: 50 };
},
{
key: "delete-btn",
click: () => this.onDeleteClick(),
}
);
}
}
```

**Custom `onHitBox` for circular areas**

```typescript
this.eventedArea(
() => {
const cx = this.state.x + 25;
const cy = this.state.y + 25;
const r = 25;
const ctx = this.context.ctx;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
return { x: cx - r, y: cy - r, width: r * 2, height: r * 2 };
},
{
key: "circle-btn",
onHitBox: (data) => {
const cx = this.state.x + 25;
const cy = this.state.y + 25;
const dx = ((data.minX + data.maxX) / 2) - cx;
const dy = ((data.minY + data.maxY) / 2) - cy;
return dx * dx + dy * dy <= 25 * 25;
},
click: (e) => {
e.stopPropagation();
this.onCircleClick();
},
}
);
```

**Conditional areas**

Areas are rebuilt each render, so you can conditionally include them:

```typescript
protected render() {
super.render();

if (this.state.showControls) {
this.eventedArea(
() => { /* draw control, return rect */ },
{ key: "control", click: () => this.handleControlClick() }
);
}
}
```

When `showControls` becomes `false` and the component re-renders, the area disappears and no longer responds to events — no manual cleanup required.

### 4. Reactive Data with Signal Subscriptions

GraphComponent enables reactive programming with a simple subscription system:
Expand Down
60 changes: 60 additions & 0 deletions docs/connections/canvas-connection-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,66 @@ if (graph.rootStore.connectionsList.hasPort("block-1_output")) {

**Ownership Model**: Components that own ports (blocks, anchors) are responsible for updating port coordinates. Components that observe ports (connections) read coordinates for rendering but don't control them. Ports are automatically cleaned up when they have no owner and no observers.

### Port Delegation

Port delegation allows one component to temporarily take control of another component's port position. While delegated, the port mirrors the position of its delegate target. The original owner continues to call `setPoint()` as usual, but those values are silently saved — the effective position comes from the delegate. When delegation ends, the port restores its last saved position as if nothing happened.

**Why this is useful:** Sometimes a component needs to intercept port positions of other components without those components knowing about it. For example, `CollapsibleGroup` collapses a group of blocks into a compact header. When collapsed, the group hides all its blocks but their external connections must remain visible — now originating from the group's edges instead of the hidden blocks. The group creates its own edge ports and delegates all block ports to them:

```
Before collapse: After collapse:
┌─────────────┐
[Block-1] ──conn──→ [Outer] │ [−] Group ├──conn──→ [Outer]
[Block-2] └─────────────┘
```

The block ports don't know they're delegated — they keep receiving `setPoint()` calls from their owners as blocks move inside the group. When the group expands, delegation is removed and connections snap back to their original block positions.

#### API

```typescript
const blockPort = graph.rootStore.connectionsList.ports.getPort("block-1_output");
const groupEdgePort = graph.rootStore.connectionsList.ports.getPort("group-1_right");

// Delegate: blockPort now mirrors groupEdgePort's position
blockPort.delegate(groupEdgePort);

blockPort.getPoint(); // returns groupEdgePort's position
blockPort.isDelegated; // true

// Owner keeps calling setPoint — values are saved, not applied
blockPort.setPoint(100, 200);
blockPort.getPoint(); // still returns groupEdgePort's position

// Moving the delegate target automatically updates all delegated ports
groupEdgePort.setPoint(300, 400);
blockPort.getPoint(); // { x: 300, y: 400 }

// Remove delegation — restores last saved position
blockPort.undelegate();
blockPort.getPoint(); // { x: 100, y: 200 }
blockPort.isDelegated; // false
```

#### How CollapsibleGroup uses delegation

When a group collapses:
1. The group creates two edge ports (left and right) positioned at the collapsed rect edges
2. All block ports inside the group are delegated to the appropriate edge port (input → left, output → right)
3. External connections now visually originate from the group's edges
4. Internal connections (both endpoints in the group) are hidden via `$hidden` signal
5. When the group is dragged, only the edge ports need to move — all delegated ports follow automatically

When the group expands:
1. All block ports are undelegated — they restore their original positions
2. Connections snap back to their block positions

#### Key behaviors

- `$point` signal is reactive — subscribers (connections) automatically update when the delegate moves
- If `setPoint()` is never called during delegation, `undelegate()` restores the position from before `delegate()` was called
- If `setPoint()` is called multiple times during delegation, the last value wins on `undelegate()`

## Styling and Visual Customization

Global settings control the visual appearance the default [BlockConnection](../../src/components/canvas/connections/BlockConnection.md) and behavior of all connections in your graph:
Expand Down
6 changes: 6 additions & 0 deletions docs/system/event-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ Key points from `GraphLayer`:

Bottom line: local handlers do not consume events automatically; call `stopPropagation` explicitly to stop further propagation.

#### Evented Areas

`EventedComponent` supports **evented areas** — sub-regions within a component that respond to events independently. During `render()`, a component calls `eventedArea(fn, params)` to register a rectangular area with its own event handlers and optional hit-test logic. When `_fireEvent` dispatches an event, it also checks all registered evented areas on the target component: if the last known hit-test coordinates fall within an area (via its `onHitBox` callback or a default AABB check), the area's matching handler is invoked.

Areas are cleared and rebuilt on every render cycle (`willRender`), so they always reflect the current visual state. See [Canvas GraphComponent — Evented Areas](../components/canvas-graph-component.md#5-evented-areas) for API details and examples.

### How a click on an element works (step by step)

Scenario for a typical left‑click where press and release occur nearly at the same spot:
Expand Down
Loading
Loading