UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

508 lines (507 loc) 20.9 kB
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); } }