@gravity-ui/graph
Version:
Modern graph editor component
264 lines (263 loc) • 11.9 kB
JavaScript
import { extractNativeGraphMouseEvent } from "../../../../graphEvents";
import { Layer } from "../../../../services/Layer";
import { ESelectionStrategy } from "../../../../services/selection/types";
import { getXY, isAltKeyEvent, isBlock } from "../../../../utils/functions";
import { render } from "../../../../utils/renderers/render";
export class NewBlockLayer extends Layer {
constructor(props) {
super({
canvas: {
zIndex: 4,
classNames: ["no-pointer-events"],
...props.canvas,
},
...props,
});
this.copyBlocks = [];
this.blockStates = [];
this.enabled = true;
this.handleMouseDown = (nativeEvent) => {
const event = extractNativeGraphMouseEvent(nativeEvent);
const target = nativeEvent.detail.target;
if (event && isAltKeyEvent(event) && isBlock(target) && this.enabled) {
// Check if duplication is allowed for this block
if (this.props.isDuplicateAllowed && !this.props.isDuplicateAllowed(target)) {
return; // Exit if duplication is not allowed
}
if (!this.root?.ownerDocument) {
return;
}
nativeEvent.preventDefault();
nativeEvent.stopPropagation();
// Capture target in closure for onStart callback
const blockTarget = target;
this.context.graph.dragService.startDrag({
onStart: (event) => this.onStartNewBlock(event, blockTarget),
onUpdate: (event) => this.onMoveNewBlock(event),
onEnd: (event, coords) => this.onEndNewBlock(event, { x: coords[0], y: coords[1] }),
}, { cursor: "copy" });
}
};
this.setContext({
canvas: this.getCanvas(),
graphCanvas: props.graph.getGraphCanvas(),
ctx: this.getCanvas().getContext("2d"),
camera: props.camera,
constants: this.props.graph.graphConstants,
colors: this.props.graph.graphColors,
graph: this.props.graph,
});
}
/**
* Called after initialization and when the layer is reattached.
* This is where we set up event subscriptions to ensure they work properly
* after the layer is unmounted and reattached.
*/
afterInit() {
super.afterInit();
// Register event listeners with the graphOn wrapper method for automatic cleanup when unmounted
this.onGraphEvent("mousedown", this.handleMouseDown, {
capture: true,
});
}
render() {
this.resetTransform();
if (!this.blockStates.length) {
return;
}
render(this.context.ctx, (ctx) => {
ctx.beginPath();
ctx.fillStyle = this.props.ghostBackground || this.context.colors.block.border;
// Draw each block ghost
for (const blockState of this.blockStates) {
ctx.fillRect(blockState.x, blockState.y, blockState.width, blockState.height);
}
ctx.closePath();
});
}
onStartNewBlock(event, block) {
// Check if the clicked block is selected
const isBlockSelected = block.connectedState.selected;
// Get the blocks to duplicate
let blockStates;
if (isBlockSelected) {
// If the clicked block is selected, get all selected blocks
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()));
// If no blocks can be duplicated, exit
if (blockStates.length === 0)
return;
}
else {
blockStates = selectedBlockStates;
}
}
else {
// If the clicked block is not selected, use only the clicked block
blockStates = [block.connectedState];
}
// Map BlockState to Block for the event
const blocks = isBlockSelected ? blockStates.map((blockState) => blockState.getViewComponent()) : [block];
// Use the already filtered blockStates
this.copyBlocks = blockStates;
// Store the initial point for calculating the offset later
this.initialPoint = this.context.graph.getPointInCameraSpace(event);
this.context.graph.executеDefaultEventAction("block-add-start-from-shadow", { blocks }, () => {
const scale = this.context.camera.getCameraScale();
const xy = getXY(this.context.graphCanvas, event);
const mouseX = xy[0];
const mouseY = xy[1];
// Calculate the screen position of the clicked block
const cameraRect = this.context.camera.getCameraRect();
const clickedBlockX = block.connectedState.x * scale + cameraRect.x;
const clickedBlockY = block.connectedState.y * scale + cameraRect.y;
// Calculate the click position relative to the block's top-left corner
const clickOffsetX = mouseX - clickedBlockX;
const clickOffsetY = mouseY - clickedBlockY;
// Create ghost blocks for each block being duplicated
this.blockStates = this.copyBlocks.map((blockState) => {
// Calculate screen position for each block
const blockScreenX = blockState.x * scale + cameraRect.x;
const blockScreenY = blockState.y * scale + cameraRect.y;
// Use block's own width and height values
const blockWidth = blockState.width * scale;
const blockHeight = blockState.height * scale;
return {
width: blockWidth,
height: blockHeight,
// Position the ghost block so that the click offset is maintained
x: mouseX - clickOffsetX + (blockScreenX - clickedBlockX),
y: mouseY - clickOffsetY + (blockScreenY - clickedBlockY),
};
});
this.performRender();
});
}
onMoveNewBlock(event) {
if (!this.copyBlocks.length) {
return;
}
const xy = getXY(this.context.graphCanvas, event);
const mouseX = xy[0];
const mouseY = xy[1];
// If this is the first move event, initialize the last mouse position
if (this.lastMouseX === undefined) {
this.lastMouseX = mouseX;
this.lastMouseY = mouseY;
return;
}
// Calculate the movement delta
const deltaX = mouseX - this.lastMouseX;
const deltaY = mouseY - this.lastMouseY;
// Update positions of all ghost blocks by applying the delta
this.blockStates = this.blockStates.map((blockState) => {
return {
...blockState,
x: blockState.x + deltaX,
y: blockState.y + deltaY,
};
});
// Update the last mouse position
this.lastMouseX = mouseX;
this.lastMouseY = mouseY;
this.performRender();
}
onEndNewBlock(event, point) {
if (!this.copyBlocks.length) {
return;
}
// Clear the block states and reset mouse tracking
this.blockStates = [];
this.lastMouseX = undefined;
this.lastMouseY = undefined;
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;
// 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,
};
});
// Calculate the delta between start and end positions
const delta = {
x: offsetX,
y: offsetY,
};
// Emit a single event with all items and the position delta
this.context.graph.executеDefaultEventAction("block-added-from-shadow", {
items,
delta,
}, () => {
// Clear selection of old blocks before adding new ones
this.context.graph.api.unsetSelection();
// Map to store original block ID to new block ID mapping
const blockIdMap = new Map();
// First pass: create all blocks and build the ID mapping
items.forEach((item) => {
const block = item.block.connectedState.asTBlock();
const blockPoint = item.coord;
const newBlockId = `${block.id.toString()}-added-from-shadow-${Date.now()}`;
// Store the mapping between original and new block IDs
blockIdMap.set(block.id.toString(), newBlockId);
this.context.graph.api.addBlock({
...block,
id: newBlockId,
is: "block",
x: blockPoint.x,
y: blockPoint.y,
}, {
selected: true,
strategy: ESelectionStrategy.APPEND, // Use APPEND strategy to keep all blocks selected
});
});
// Second pass: duplicate connections between duplicated blocks
if (blockIdMap.size > 1) {
// Only process connections if we have at least 2 blocks
// Get all connections from the graph
const connections = this.context.graph.rootStore.connectionsList.$connections.value;
// Check each connection to see if it connects blocks that were duplicated
connections.forEach((connection) => {
const sourceId = connection.sourceBlockId;
const targetId = connection.targetBlockId;
// 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,
});
}
});
}
});
this.copyBlocks = [];
this.initialPoint = null;
}
enable() {
this.enabled = true;
}
disable() {
this.enabled = false;
}
isEnabled() {
return this.enabled;
}
}