@gravity-ui/graph
Version:
Modern graph editor component
508 lines (507 loc) • 20.9 kB
JavaScript
import { batch } from "@preact/signals-core";
import { EAnchorType } from "../../../store/anchor/Anchor";
import { Group } from "./Group";
const DEFAULT_COLLAPSED_WIDTH = 200;
const DEFAULT_COLLAPSED_HEIGHT = 48;
const DIRECTION_FACTOR = { start: 0, center: 0.5, end: 1 };
/**
* Default collapse rect computation. Produces a rect of the given size
* pinned at the position determined by `direction`.
*
* Exported so users can call it from their custom `getCollapseRect` and
* extend or modify the default behavior.
*
* @param expandedRect - The full group rect before collapsing.
* @param direction - Where the header is pinned (defaults to top-left).
* @param collapsedWidth - Header width (defaults to 200).
* @param collapsedHeight - Header height (defaults to 48).
*/
export function computeDefaultCollapseRect(expandedRect, direction, collapsedWidth, collapsedHeight) {
const w = collapsedWidth ?? DEFAULT_COLLAPSED_WIDTH;
const h = collapsedHeight ?? DEFAULT_COLLAPSED_HEIGHT;
const ax = DIRECTION_FACTOR[direction?.x ?? "start"];
const ay = DIRECTION_FACTOR[direction?.y ?? "start"];
return {
x: expandedRect.x + ax * (expandedRect.width - w),
y: expandedRect.y + ay * (expandedRect.height - h),
width: w,
height: h,
};
}
/**
* A Group component that supports collapsing and expanding.
*
* When collapsed:
* - All blocks in the group are hidden (not deleted from the store)
* - Connection ports of hidden blocks are redirected to the group edges
* - The group renders as a compact header using `collapsedRect`
* - The real `rect` (block bounding box) is NOT locked — `withBlockGrouping`
* continues to track block positions normally
*
* When expanded, all of the above is reversed.
*
* Collapse/expand is triggered programmatically via {@link collapse} and
* {@link expand}. There is no built-in UI trigger — subscribe to graph
* events (e.g. `dblclick`) and call these methods from your handler.
*
* A cancelable `group-collapse-change` event is emitted before each
* transition. Call `event.preventDefault()` to cancel the operation.
*
* ### Collapse rect
*
* Provide `getCollapseRect` on the TCollapsibleGroup data to control
* where the group collapses to. If not provided, the default
* implementation uses `collapseDirection` to pin a 200×48 header.
*
* ### Usage
* ```typescript
* const group: TCollapsibleGroup = {
* id: "my-group",
* rect: { x: 0, y: 0, width: 0, height: 0 },
* component: CollapsibleGroup,
* collapsed: false,
* collapseDirection: { x: "start", y: "start" },
* };
* ```
*
* Blocks must carry `group: "my-group"` in their TBlock data.
*/
/** Port ID suffix for the group's left-edge delegation target. */
const GROUP_PORT_LEFT = "_left";
/** Port ID suffix for the group's right-edge delegation target. */
const GROUP_PORT_RIGHT = "_right";
const defaultStyle = {
background: "rgba(100, 100, 100, 0.1)",
border: "rgba(100, 100, 100, 0.3)",
borderWidth: 2,
selectedBackground: "rgba(100, 100, 100, 1)",
selectedBorder: "rgba(100, 100, 100, 1)",
highlightedBackground: "rgba(100, 200, 100, 0.3)",
highlightedBorder: "rgba(100, 200, 100, 0.8)",
};
const defaultGeometry = {
padding: [20, 20, 20, 20],
};
export class CollapsibleGroup extends Group {
constructor() {
super(...arguments);
/** Snapshot of `collapsedRect` at drag start; see {@link handleDrag}. */
this.dragStartCollapsedRect = null;
}
static define(config) {
return class SpecificGroup extends this {
constructor(props, parent) {
super({
...props,
style: {
...defaultStyle,
...config.style,
...props.style,
},
geometry: {
...defaultGeometry,
...config.geometry,
...props.geometry,
},
}, parent);
}
};
}
/**
* Extend base subscription to also react to collapsed state on init.
* subscribeSignal fires immediately with the current value, so a group
* that starts with collapsed: true will hide its blocks on mount.
*
* Also handles external collapse state changes: if setGroups() is called
* with collapsed: false while the group is currently collapsed, it expands.
*/
subscribeToGroup() {
const unsub = super.subscribeToGroup();
this.subscribeSignal(this.groupState.$state, (group) => {
if (group.collapsed) {
this.applyBlockVisibility(true);
const rect = group.collapsedRect ?? this.computeCollapsedRect(group.rect);
if (!group.collapsedRect) {
this.groupState.updateGroup({
collapsedRect: rect,
});
}
this.delegatePorts(rect);
// Correct the hitbox immediately. super.subscribeToGroup already ran
// updateHitBox(group.rect) but at that point state.collapsedRect may
// not yet be populated, so getRect() used the expanded rect.
// Passing the collapsed rect here forces the correct visual rect regardless
// of whether state.collapsedRect is set yet (getRect falls through to
// super.getRect(rect) when state.collapsedRect is undefined).
this.updateHitBox(rect);
}
else if (this.state.collapsed) {
// Transition: collapsed → expanded triggered externally (e.g. via setGroups).
this.undelegatePorts();
this.applyBlockVisibility(false);
this.updateHitBox(group.rect);
}
});
return unsub;
}
// ---------------------------------------------------------------------------
// Overrides — use collapsedRect for rendering and hit-testing when collapsed
// ---------------------------------------------------------------------------
/**
* Returns the visual rect. When collapsed, returns `collapsedRect`
* (with padding) so the group renders as a compact header.
*/
getRect(rect) {
const state = this.getState();
if (state.collapsed && state.collapsedRect) {
return super.getRect(state.collapsedRect);
}
return super.getRect(rect);
}
/**
* Sets the hitbox to the collapsed rect when collapsed, or the expanded
* rect otherwise. Passes the raw inner rect to super so that base Group's
* updateHitBox can apply padding exactly once.
*/
updateHitBox(rect) {
const state = this.getState();
super.updateHitBox(state.collapsed && state.collapsedRect ? state.collapsedRect : rect);
}
/**
* Remember inner rect and collapsed rect so {@link handleDrag} can translate
* `collapsedRect` by the same snapped delta as `rect` (grid snapping in Group).
*/
handleDragStart(context) {
super.handleDragStart(context);
// Use store snapshot — component `this.state` can lag behind after collapse/expand.
const group = this.groupState.$state.value;
if (group.collapsed && group.collapsedRect) {
this.dragStartCollapsedRect = { ...group.collapsedRect };
}
else {
this.dragStartCollapsedRect = null;
}
}
handleDragEnd(context) {
this.dragStartCollapsedRect = null;
super.handleDragEnd(context);
}
/**
* When dragging a collapsed group, move `collapsedRect` in lockstep with the snapped
* `rect` movement computed by {@link Group.handleDrag} (not per-frame mouse deltas).
*/
handleDrag(diff, context) {
if (!this.dragStartRect || !this.lastSnappedPos) {
return;
}
super.handleDrag(diff, context);
const group = this.groupState.$state.value;
if (!group.collapsed || !group.collapsedRect || !this.dragStartCollapsedRect || !this.dragStartRect) {
return;
}
// Same snapped inner position Group uses (see Group.handleDrag) — avoids relying on
// store vs component state timing with withBlockGrouping during drag.
const { x: newInnerX, y: newInnerY } = this.snapPosition(this.dragStartRect.x + diff.diffX, this.dragStartRect.y + diff.diffY);
const dx = newInnerX - this.dragStartRect.x;
const dy = newInnerY - this.dragStartRect.y;
if (dx === 0 && dy === 0) {
return;
}
const newCollapsedRect = {
x: this.dragStartCollapsedRect.x + dx,
y: this.dragStartCollapsedRect.y + dy,
width: this.dragStartCollapsedRect.width,
height: this.dragStartCollapsedRect.height,
};
this.groupState.updateGroup({
collapsedRect: newCollapsedRect,
});
this.updateGroupPortPositions(this.getRect(newCollapsedRect));
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Whether this group is currently in the collapsed state. */
isCollapsed() {
return this.groupState.$state.value.collapsed ?? false;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
getGroupBlocks() {
return this.context.graph.rootStore.groupsList.$blockGroups.value[this.props.id] ?? [];
}
/**
* Returns the number of ports currently delegated to the left and right
* group edge ports. Only meaningful when the group is collapsed.
*
* Cost is O(blocks × anchors) per call when the cache is cold — typical group
* sizes keep this cheap. The result is cached until delegation changes
* ({@link delegatePorts} / {@link undelegatePorts}); override {@link renderCollapsedView}
* and call a custom counter if you need different invalidation rules.
*/
getPortDelegationCounts() {
if (this.portDelegationCountsCache !== undefined) {
return this.portDelegationCountsCache;
}
let left = 0;
let right = 0;
this.getGroupBlocks().forEach((blockState) => {
const canvasBlock = blockState.getViewComponent();
if (!canvasBlock)
return;
const inputPort = canvasBlock.getInputPort();
if (inputPort?.isDelegated)
left++;
const outputPort = canvasBlock.getOutputPort();
if (outputPort?.isDelegated)
right++;
blockState.$anchors.value.forEach((anchor) => {
const port = canvasBlock.getAnchorPort(anchor.id);
if (port?.isDelegated) {
if (anchor.type === EAnchorType.OUT)
right++;
else
left++;
}
});
});
this.portDelegationCountsCache = { left, right };
return this.portDelegationCountsCache;
}
invalidatePortDelegationCountsCache() {
this.portDelegationCountsCache = undefined;
}
/**
* Compute the collapsed rect for a given full rect.
*
* Uses the user-provided `getCollapseRect` if available, otherwise falls
* back to the direction-based default.
*/
computeCollapsedRect(fullRect) {
const state = this.groupState.$state.value;
if (state.getCollapseRect) {
return state.getCollapseRect(state, fullRect);
}
return computeDefaultCollapseRect(fullRect, state.collapseDirection);
}
// ---------------------------------------------------------------------------
// Collapse
// ---------------------------------------------------------------------------
/**
* Collapse the group: set collapsedRect, hide member blocks,
* and redirect their ports to the group edges.
*
* Emits a cancelable `group-collapse-change` event before applying changes.
* If a listener calls `event.preventDefault()`, the collapse is cancelled.
*/
collapse() {
const currentRect = this.groupState.$state.value.rect;
const nextRect = this.computeCollapsedRect(currentRect);
this.context.graph.executеDefaultEventAction("group-collapse-change", {
groupId: this.props.id,
collapsed: true,
currentRect,
nextRect,
}, () => {
batch(() => {
this.applyBlockVisibility(true);
this.delegatePorts(nextRect);
this.groupState.updateGroup({
collapsed: true,
collapsedRect: nextRect,
});
});
// Explicitly update hitbox to the collapsed rect.
this.updateHitBox(nextRect);
});
}
// ---------------------------------------------------------------------------
// Expand
// ---------------------------------------------------------------------------
/**
* Expand the group: remove collapsedRect, show member blocks, and let
* them resume managing their own ports.
*
* Emits a cancelable `group-collapse-change` event before applying changes.
* If a listener calls `event.preventDefault()`, the expand is cancelled.
*/
expand() {
const state = this.groupState.$state.value;
const currentRect = state.collapsedRect ?? state.rect;
const nextRect = state.rect;
this.context.graph.executеDefaultEventAction("group-collapse-change", {
groupId: this.props.id,
collapsed: false,
currentRect,
nextRect,
}, () => {
batch(() => {
this.undelegatePorts();
this.applyBlockVisibility(false);
this.groupState.updateGroup({
collapsed: false,
collapsedRect: undefined,
});
});
// Explicitly update hitbox to the full rect. The signal
// subscription also calls updateHitBox, but getRect() may see stale
// `this.state.collapsed` during the batch.
this.updateHitBox(this.groupState.$state.value.rect);
});
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
unmount() {
// When a collapsed group is removed, restore block visibility and undelegate
// ports before releasing them so blocks are shown and ports have no stale
// $delegate references pointing to the now-destroyed edge ports.
if (this.state.collapsed) {
this.applyBlockVisibility(false);
this.undelegatePorts();
}
this.invalidatePortDelegationCountsCache();
super.unmount();
}
// ---------------------------------------------------------------------------
// Block visibility
// ---------------------------------------------------------------------------
applyBlockVisibility(hidden) {
this.getGroupBlocks().forEach((blockState) => {
blockState.requestHidden(hidden);
});
}
// ---------------------------------------------------------------------------
// Port delegation
// ---------------------------------------------------------------------------
/**
* Get (or create) the group's left-edge port used as a delegation target.
* Input ports and IN anchors delegate to this port when collapsed.
*/
getLeftEdgePort() {
return this.getPort(`${String(this.props.id)}${GROUP_PORT_LEFT}`);
}
/**
* Get (or create) the group's right-edge port used as a delegation target.
* Output ports and OUT anchors delegate to this port when collapsed.
*/
getRightEdgePort() {
return this.getPort(`${String(this.props.id)}${GROUP_PORT_RIGHT}`);
}
/**
* Update the group's edge port positions to match the given rect.
*/
updateGroupPortPositions(rect) {
const midY = rect.y + rect.height / 2;
this.getLeftEdgePort().setPoint(rect.x, midY);
this.getRightEdgePort().setPoint(rect.x + rect.width, midY);
}
/**
* Delegate all ports of group blocks to the group's edge ports.
*
* - Input port → left-edge port
* - Output port → right-edge port
* - IN anchors → left-edge port
* - OUT anchors → right-edge port
*
* While delegated, block ports mirror the group edge positions.
* When the group is dragged, only the group edge ports need to be
* updated — all delegated ports follow automatically.
*/
delegatePorts(targetRect) {
this.invalidatePortDelegationCountsCache();
const rect = this.getRect(targetRect);
this.updateGroupPortPositions(rect);
const leftPort = this.getLeftEdgePort();
const rightPort = this.getRightEdgePort();
this.getGroupBlocks().forEach((blockState) => {
const canvasBlock = blockState.getViewComponent();
if (!canvasBlock)
return;
const inputPort = canvasBlock.getInputPort();
if (inputPort && !inputPort.isDelegated) {
inputPort.delegate(leftPort);
}
const outputPort = canvasBlock.getOutputPort();
if (outputPort && !outputPort.isDelegated) {
outputPort.delegate(rightPort);
}
blockState.$anchors.value.forEach((anchor) => {
const port = canvasBlock.getAnchorPort(anchor.id);
if (port && !port.isDelegated) {
port.delegate(anchor.type === EAnchorType.OUT ? rightPort : leftPort);
}
});
});
}
/**
* Remove delegation from all ports of group blocks, restoring their
* original positions (saved automatically by the delegation mechanism).
*/
undelegatePorts() {
this.invalidatePortDelegationCountsCache();
this.getGroupBlocks().forEach((blockState) => {
const canvasBlock = blockState.getViewComponent();
if (!canvasBlock)
return;
const inputPort = canvasBlock.getInputPort();
if (inputPort?.isDelegated) {
inputPort.undelegate();
}
const outputPort = canvasBlock.getOutputPort();
if (outputPort?.isDelegated) {
outputPort.undelegate();
}
blockState.$anchors.value.forEach((anchor) => {
const port = canvasBlock.getAnchorPort(anchor.id);
if (port?.isDelegated) {
port.undelegate();
}
});
});
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
render() {
const collapsed = this.state.collapsed ?? false;
if (collapsed) {
this.renderCollapsedView(this.context.ctx);
}
else {
super.render();
}
}
/**
* Render the compact header shown when the group is collapsed.
* Override this method to customise the collapsed appearance.
*/
renderCollapsedView(ctx) {
const rect = this.getRect();
if (this.isHighlighted()) {
ctx.strokeStyle = this.style.highlightedBorder;
ctx.fillStyle = this.style.highlightedBackground;
}
else if (this.state.selected) {
ctx.strokeStyle = this.style.selectedBorder;
ctx.fillStyle = this.style.selectedBackground;
}
else {
ctx.strokeStyle = this.style.border;
ctx.fillStyle = this.style.background;
}
ctx.lineWidth = this.style.borderWidth;
ctx.beginPath();
ctx.roundRect(rect.x, rect.y, rect.width, rect.height, 8);
ctx.fill();
ctx.stroke();
// Label with collapse indicator
const label = `[−] ${String(this.props.id)}`;
ctx.fillStyle = "#ffffff";
ctx.font = "bold 12px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(label, rect.x + rect.width / 2, rect.y + rect.height / 2, rect.width - 16);
}
}