diff --git a/package-lock.json b/package-lock.json index 0c603438..f1ef8219 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "@types/react-dom": "^18.2.6", "@typescript-eslint/eslint-plugin": "5.39.0", "@typescript-eslint/parser": "5.39.0", + "concurrently": "^9.2.1", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", "elkjs": "^0.9.3", @@ -75,6 +76,10 @@ "engines": { "pnpm": "Please use npm instead of pnpm to install dependencies", "yarn": "Please use npm instead of yarn to install dependencies" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" } }, "node_modules/@adobe/css-tools": { @@ -7676,6 +7681,160 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/concurrently/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/concurrently/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/concurrently/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/concurrently/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/conventional-changelog-conventionalcommits": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz", @@ -15455,6 +15614,16 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-array-concat": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", @@ -15654,6 +15823,19 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/side-channel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", @@ -16556,6 +16738,16 @@ "node": ">=18" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", diff --git a/package.json b/package.json index 86b309e1..b8bf80c3 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,7 @@ "storybook": "^9.1.2", "ts-node": "^10.9.2", "typescript": "^5.9.2", - "web-worker": "^1.3.0" + "web-worker": "^1.3.0", + "concurrently": "^9.2.1" } } diff --git a/src/components/canvas/groups/BlockGroups.ts b/src/components/canvas/groups/BlockGroups.ts index a2020abf..358f69d7 100644 --- a/src/components/canvas/groups/BlockGroups.ts +++ b/src/components/canvas/groups/BlockGroups.ts @@ -1,5 +1,6 @@ -import { Signal, computed } from "@preact/signals-core"; +import { ReadonlySignal, Signal, computed } from "@preact/signals-core"; +import { TBlock } from "../../.."; import { Graph } from "../../../graph"; import { CoreComponent } from "../../../lib"; import { TComponentState } from "../../../lib/Component"; @@ -11,11 +12,14 @@ import { TRect } from "../../../utils/types/shapes"; import { Group } from "./Group"; +export type TDefinitionGroup = Omit & { blocksIds: BlockState["id"][] }; + export type BlockGroupsProps = LayerProps & { // Some specific props mapBlockGroups?: (blocks: BlockState[]) => GroupState[]; groupComponent?: typeof Group; draggable?: boolean; + updateBlocksOnDrag?: boolean; }; export type BlockGroupsContext = LayerContext & { @@ -35,14 +39,23 @@ export class BlockGroups

extends BlockGroupsContext, BlockGroupsState > { - public static withBlockGrouping({ - groupingFn, - mapToGroups, - }: { - groupingFn: (blocks: BlockState[]) => Record; - mapToGroups: (key: string, params: { blocks: BlockState[]; rect: TRect }) => TGroup; - }): typeof BlockGroups { - return class BlockGroupWithGrouping extends BlockGroups { + public static withBlockGrouping

>( + this: new (props: P) => Instance, + { + groupingFn, + mapToGroups, + }: { + groupingFn: (blocks: BlockState[]) => Record; + mapToGroups: (key: string, params: { blocks: BlockState[]; rect: TRect }) => TGroup; + } + ): new (props: P) => Instance & { $groupsBlocksMap: ReadonlySignal> } { + const Base = this as new (props: P) => BlockGroups

; + /** + * We use `as any` here because TypeScript has trouble inferring the correct type + * for an anonymous class extending a generic base with protected members. + * The public method signature ensures strict type safety for consumers. + */ + return class BlockGroupWithGrouping extends Base { public $groupsBlocksMap = computed(() => { const blocks = this.props.graph.rootStore.blocksList.$blocks.value; return groupingFn(blocks); @@ -53,7 +66,10 @@ export class BlockGroups

extends computed(() => { const groupedBlocks = this.$groupsBlocksMap.value; return Object.entries(groupedBlocks).map(([key, blocks]) => - mapToGroups(key, { blocks, rect: getBlocksRect(blocks.map((block) => block.asTBlock())) }) + mapToGroups(key, { + blocks, + rect: getBlocksRect(blocks.map((block) => block.asTBlock())), + }) ); }), (groups: TGroup[]) => { @@ -62,13 +78,95 @@ export class BlockGroups

extends ); super.afterInit(); } - }; + } as any; } + public static withPredefinedGroups< + T extends TGroup, + P extends TDefinitionGroup = TDefinitionGroup, + Props extends BlockGroupsProps = BlockGroupsProps, + Instance extends BlockGroups = BlockGroups, + >( + this: new (props: Props) => Instance + ): new (props: Props) => Instance & { + $groupsBlocksMap: ReadonlySignal>; + defineGroups(groups: P[]): void; + } { + const Base = this as new (props: Props) => BlockGroups; + /** + * We use `as any` here because TypeScript has trouble inferring the correct type + * for an anonymous class extending a generic base with protected members. + * The public method signature ensures strict type safety for consumers. + */ + return class BlockGroupWithPredefinedGroups extends Base { + private $predefinedGroups = new Signal([]); + + public $groupsBlocksMap = computed(() => { + const groups = this.$predefinedGroups.value; + const blocksMap: Record = {}; + const blocksListStore = this.props.graph.rootStore.blocksList; + + groups.forEach((group) => { + const blocks = blocksListStore.getBlockStates(group.blocksIds); + blocksMap[group.id] = blocks; + }); + + return blocksMap; + }); + + public defineGroups(groups: P[]): void { + this.$predefinedGroups.value = groups; + } + + protected afterInit(): void { + this.onSignal( + computed(() => { + const groups = this.$predefinedGroups.value; + const groupsBlocksMap = this.$groupsBlocksMap.value; + + return groups.map((group) => { + const blocks = groupsBlocksMap[group.id] || []; + const rect = getBlocksRect(blocks.map((block) => block.asTBlock())); + + return { + ...group, + rect, + } as TGroup; + }); + }), + (groups: TGroup[]) => { + this.setGroups(groups); + } + ); + super.afterInit(); + } + } as any; + } + + /** + * Map of groups to blocks + * Used to quickly find the blocks of a group + */ protected $groupsBlocksMap = new Signal>({}); + /** + * Source of groups + */ protected $groupsSource = this.props.graph.rootStore.groupsList.$groups; + /** + * Map of blocks to groups + * Used to quickly find the group of a block + */ + protected $blockGroupsMap = computed(() => { + return Object.entries(this.$groupsBlocksMap.value).reduce((acc, [key, blocks]) => { + blocks.forEach((block) => { + acc.set(block.id, key); + }); + return acc; + }, new Map()); + }); + constructor(props: P) { super({ canvas: { @@ -112,7 +210,7 @@ export class BlockGroups

extends } public updateBlocks = (groupId: TGroupId, { deltaX, deltaY }: { deltaX: number; deltaY: number }) => { - if ((this.props as BlockGroupsProps & { updateBlocksOnDrag?: boolean }).updateBlocksOnDrag) { + if (this.props.updateBlocksOnDrag) { const blocks = this.$groupsBlocksMap.value[groupId]; if (blocks) { blocks.forEach((block) => { @@ -123,7 +221,15 @@ export class BlockGroups

extends }; public setGroups(groups: T[]) { - this.props.graph.rootStore.groupsList.setGroups(groups); + const groupsToUpdate = groups.map((group) => { + const existingGroupState = this.props.graph.rootStore.groupsList.getGroupState(group.id); + if (existingGroupState?.isSizeLocked()) { + // Keep the existing rect when size is locked + return { ...group, rect: existingGroupState.$state.value.rect }; + } + return group; + }); + this.props.graph.rootStore.groupsList.setGroups(groupsToUpdate); } public updateGroups(groups: T[]) { @@ -147,8 +253,15 @@ export class BlockGroups

extends onDragUpdate: this.updateBlocks, draggable: this.props.draggable || false, }, - { key: group.id } + { key: group.id, ref: group.id } ); }); } + + /** + * Find a Group component by its ID + */ + public getGroupById(groupId: string): Group | null { + return this.$?.[groupId]; + } } diff --git a/src/components/canvas/groups/BlockGroupsTransferLayer.ts b/src/components/canvas/groups/BlockGroupsTransferLayer.ts new file mode 100644 index 00000000..93b2e98b --- /dev/null +++ b/src/components/canvas/groups/BlockGroupsTransferLayer.ts @@ -0,0 +1,475 @@ +import { effect } from "@preact/signals-core"; + +import type { DragState } from "../../../services/drag/types"; +import { Point } from "../../../utils/types/shapes"; +import { Block, TBlock } from "../blocks/Block"; + +import { BlockGroups, BlockGroupsProps } from "./BlockGroups"; +import { Group } from "./Group"; + +/** + * Callback called when block transfer starts (Shift pressed during drag) + * @param blockIds - IDs of blocks being transferred + * @param sourceGroupIds - Set of source group IDs (groups blocks came from) + */ +export type OnTransferStart = (blockIds: TBlock["id"][], sourceGroupIds: Set) => void; + +/** + * Callback called when block transfer ends (mouse released or Shift released) + * @param blockIds - IDs of blocks that were transferred + * @param targetGroupId - Target group ID (null if removed from group) + */ +export type OnTransferEnd = (blockIds: TBlock["id"][], targetGroupId: string | null) => void; + +/** + * Object representing a change in block's group membership + */ +export type TBlockGroupsTransferGroupChange = { + blockId: TBlock["id"]; + sourceGroup?: string | null; + targetGroup?: string | null; +}; + +/** + * Callback called when blocks' groups change + * @param changes - Array of changes to apply + */ +export type OnBlockGroupChange = (changes: TBlockGroupsTransferGroupChange[]) => void; + +export type BlockGroupsTransferLayerProps = BlockGroupsProps & { + /** + * Enable/disable block transfer between groups with Shift+drag. + * Default: true + */ + transferEnabled?: boolean; + + /** + * Called when block transfer starts (Shift pressed during drag) + */ + onTransferStart?: OnTransferStart; + + /** + * Called when block transfer ends (mouse released or Shift released) + */ + onTransferEnd?: OnTransferEnd; + + /** + * Called when a block's group changes + */ + onBlockGroupChange?: OnBlockGroupChange; + + /** + * If true, blocks will move when the group is dragged + */ + updateBlocksOnDrag?: boolean; +}; + +type TransferState = { + isTransferring: boolean; + /** All blocks being transferred */ + blocks: TBlock[]; + /** Source group IDs for each block (null if block was not in a group) */ + sourceGroupIds: Set; + /** Current target group ID */ + targetGroupId: string | null; + /** Currently highlighted group ID */ + highlightedGroupId: string | null; +}; + +/** + * BlockGroups layer with block-to-group transfer functionality. + * + * ## Features + * - Hold Shift during drag to activate transfer mode + * - Release Shift to deactivate transfer mode and return to normal drag + * - Groups highlight when blocks are dragged over them + * - Multi-block transfer: all selected blocks are transferred together + * - Source groups lock their size during transfer + * - Callbacks for state synchronization with external stores (Redux, MobX, etc.) + * + * ## Basic Usage + * ```typescript + * const layer = graph.addLayer(BlockGroupsTransferLayer, { + * transferEnabled: true, + * draggable: true, + * }); + * ``` + * + * ## With Automatic Grouping + * ```typescript + * const GroupsLayer = BlockGroupsTransferLayer.withBlockGrouping({ + * groupingFn: (blocks) => groupBy(blocks, (b) => b.$state.value.group), + * mapToGroups: (groupId, { rect }) => ({ id: groupId, rect }), + * }); + * + * graph.addLayer(GroupsLayer, { + * transferEnabled: true, + * updateBlocksOnDrag: true, // Blocks move with the group + * }); + * ``` + * + * ## With Redux Integration + * ```typescript + * graph.addLayer(GroupsLayer, { + * onTransferStart: (blockIds, sourceGroupIds) => { + * console.log('Transfer started:', blockIds); + * }, + * onBlockGroupChange: (changes) => { + * // Sync with Redux + * changes.forEach(({ blockId, targetGroup }) => { + * store.dispatch(updateBlockGroup({ blockId, groupId: targetGroup })); + * }); + * }, + * onTransferEnd: (blockIds, targetGroupId) => { + * console.log('Transfer completed:', blockIds, 'to group:', targetGroupId); + * }, + * }); + * ``` + * + * Uses DragService.$state.currentEvent.shiftKey to track Shift state in real-time. + */ +export class BlockGroupsTransferLayer< + P extends BlockGroupsTransferLayerProps = BlockGroupsTransferLayerProps, +> extends BlockGroups

{ + /** Current transfer state */ + protected transferState: TransferState = this.createIdleState(); + + /** Cleanup function for the drag state subscription */ + protected disposeSubscription: (() => void) | null = null; + + protected get isTransferEnabled(): boolean { + return this.props.transferEnabled !== false; + } + + protected afterInit(): void { + super.afterInit(); + + if (this.isTransferEnabled) { + this.subscribeToDragState(); + } + } + + /** + * Subscribe to DragService state changes + */ + protected subscribeToDragState(): void { + const dragService = this.props.graph.dragService; + if (!dragService) return; + + this.disposeSubscription = effect(() => { + const isShiftPressed = this.props.graph.keyboardService.isShiftPressed(); + this.handleDragStateChange(dragService.$state.value, isShiftPressed ?? false); + }); + } + + /** + * Handle drag state changes - react to Shift key in real-time + */ + protected handleDragStateChange(dragState: DragState, isShiftPressed: boolean): void { + // Drag ended + if (!dragState.isDragging) { + if (this.transferState.isTransferring) { + this.endTransfer(); + } + return; + } + + // During drag: check if Shift state changed + if (isShiftPressed && !this.transferState.isTransferring) { + // Shift pressed - activate transfer + this.activateTransfer(dragState); + } else if (!isShiftPressed && this.transferState.isTransferring) { + // Shift released - deactivate transfer + this.deactivateTransfer(); + } + + // Update highlighting if in transfer mode + if (this.transferState.isTransferring && dragState.currentCoords) { + this.updateHighlight(dragState.currentCoords); + } + } + + /** + * Activate transfer mode for currently dragged blocks + */ + protected activateTransfer(dragState: DragState): void { + // Find all blocks among the dragged components + const blocks = dragState.components + .filter((c): c is Block => c instanceof Block) + .map((block) => block.state as TBlock); + + if (blocks.length === 0) return; + + // Collect unique source group IDs (for tracking which groups blocks came from) + const sourceGroupIds = new Set(); + for (const block of blocks) { + const groupId = this.$blockGroupsMap.value.get(block.id) ?? null; + if (groupId) { + sourceGroupIds.add(groupId); + } + } + + this.transferState = { + isTransferring: true, + blocks, + sourceGroupIds, + targetGroupId: null, + highlightedGroupId: null, + }; + + // Lock ALL groups' sizes during transfer mode + this.lockAllGroups(); + + // Call onTransferStart callback + this.props.onTransferStart?.( + blocks.map((b) => b.id.toString()), + sourceGroupIds + ); + + // Update highlight immediately if we have coordinates + if (dragState.currentCoords) { + this.updateHighlight(dragState.currentCoords); + } + } + + /** + * Deactivate transfer mode - apply transfer and unlock groups + * Called when Shift is released during drag + */ + protected deactivateTransfer(): void { + const { highlightedGroupId, targetGroupId, blocks } = this.transferState; + + // Unhighlight current group + if (highlightedGroupId) { + this.setGroupHighlight(highlightedGroupId, false); + } + + // Apply the group change for all blocks (transfer happens on Shift release) + const changes: TBlockGroupsTransferGroupChange[] = []; + for (const block of blocks) { + const oldGroupId = this.$blockGroupsMap.value.get(block.id) ?? null; + + // Only change if target is different from current + if (oldGroupId !== targetGroupId) { + changes.push({ + blockId: block.id, + sourceGroup: oldGroupId, + targetGroup: targetGroupId, + }); + } + } + + this.applyGroupChange(changes); + + // Unlock all groups + this.unlockAllGroups(); + + // Call onTransferEnd callback + this.props.onTransferEnd?.( + blocks.map((b) => b.id.toString()), + targetGroupId + ); + + this.transferState = this.createIdleState(); + } + + /** + * Lock all groups' sizes + */ + protected lockAllGroups(): void { + const groups = this.props.graph.rootStore.groupsList.$groups.value; + for (const groupState of groups) { + groupState.lockSize(); + } + } + + /** + * Unlock all groups' sizes + */ + protected unlockAllGroups(): void { + const groups = this.props.graph.rootStore.groupsList.$groups.value; + for (const groupState of groups) { + groupState.unlockSize(); + } + } + + protected createIdleState(): TransferState { + return { + isTransferring: false, + blocks: [], + sourceGroupIds: new Set(), + targetGroupId: null, + highlightedGroupId: null, + }; + } + + /** + * Update highlighting based on cursor position + */ + protected updateHighlight(point: [number, number]): void { + if (!this.transferState.isTransferring) return; + + const targetGroup = this.findGroupAtPoint(point); + const targetGroupId = targetGroup?.getEntityId() ?? null; + + // Update highlight visual if changed + if (this.transferState.highlightedGroupId !== targetGroupId) { + // Unhighlight previous group + if (this.transferState.highlightedGroupId) { + this.setGroupHighlight(this.transferState.highlightedGroupId, false); + } + + // Highlight new group + if (targetGroupId) { + this.setGroupHighlight(targetGroupId, true); + } + } + + // Always store the actual target group (even if it's a source group) + // The decision about what to do is made at drop time + this.transferState = { + ...this.transferState, + targetGroupId, // Real target group under cursor + highlightedGroupId: targetGroupId, + }; + } + + /** + * End transfer on drag end (mouseup) - apply transfer if in transfer mode + */ + protected endTransfer(): void { + const { highlightedGroupId, targetGroupId, blocks } = this.transferState; + + // Unhighlight the group + if (highlightedGroupId) { + this.setGroupHighlight(highlightedGroupId, false); + } + + // Apply the group change for all blocks + const changes: TBlockGroupsTransferGroupChange[] = []; + for (const block of blocks) { + const currentGroupId = this.$blockGroupsMap.value.get(block.id) ?? null; + + // Only change if target is different from current + if (currentGroupId !== targetGroupId) { + changes.push({ + blockId: block.id, + sourceGroup: currentGroupId, + targetGroup: targetGroupId, + }); + } + } + + this.applyGroupChange(changes); + + // Unlock all groups + this.unlockAllGroups(); + + // Call onTransferEnd callback + this.props.onTransferEnd?.( + blocks.map((b) => b.id.toString()), + targetGroupId + ); + + this.transferState = this.createIdleState(); + } + + /** + * Cancel the transfer operation without applying changes. + * + * This method can be called to abort an ongoing transfer without moving blocks to a new group. + * It will unhighlight groups, unlock sizes, and reset the transfer state. + * + * @example + * ```typescript + * // Cancel transfer on Escape key + * document.addEventListener('keydown', (e) => { + * if (e.key === 'Escape' && layer.isTransferring()) { + * layer.cancelTransfer(); + * } + * }); + * ``` + */ + public cancelTransfer(): void { + const { highlightedGroupId } = this.transferState; + + if (highlightedGroupId) { + this.setGroupHighlight(highlightedGroupId, false); + } + + this.unlockAllGroups(); + this.transferState = this.createIdleState(); + } + + /** + * Find a group at the given point + */ + protected findGroupAtPoint(point: [number, number]): Group | null { + const [x, y] = point; + return this.props.graph.getElementOverPoint(new Point(x, y), [Group]) ?? null; + } + + /** + * Set highlight state for a group directly on the component + */ + protected setGroupHighlight(groupId: string, highlighted: boolean): void { + const groupComponent = this.getGroupById(groupId); + groupComponent?.setHighlighted(highlighted); + } + + /** + * Apply the group change to the block + */ + protected applyGroupChange(changes: TBlockGroupsTransferGroupChange[]): void { + if (changes.length > 0) { + this.props.onBlockGroupChange?.(changes); + } + } + + /** + * Check if a block transfer is currently in progress. + * + * @returns `true` if transfer mode is active (Shift is pressed during drag), `false` otherwise + * + * @example + * ```typescript + * if (layer.isTransferring()) { + * console.log('Transferring', layer.getTransferringBlocksCount(), 'blocks'); + * } + * ``` + */ + public isTransferring(): boolean { + return this.transferState.isTransferring; + } + + /** + * Get the number of blocks being transferred in the current operation. + * + * @returns Number of blocks currently being transferred, or 0 if no transfer is in progress + * + * @example + * ```typescript + * const count = layer.getTransferringBlocksCount(); + * console.log(`Transferring ${count} block${count !== 1 ? 's' : ''}`); + * ``` + */ + public getTransferringBlocksCount(): number { + return this.transferState.blocks.length; + } + + protected unmountLayer(): void { + // Cleanup drag subscription + if (this.disposeSubscription) { + this.disposeSubscription(); + this.disposeSubscription = null; + } + + // Cleanup transfer state + if (this.transferState.highlightedGroupId) { + this.setGroupHighlight(this.transferState.highlightedGroupId, false); + } + this.transferState = this.createIdleState(); + + super.unmountLayer(); + } +} diff --git a/src/components/canvas/groups/Group.ts b/src/components/canvas/groups/Group.ts index 92f7ae05..2a28c19d 100644 --- a/src/components/canvas/groups/Group.ts +++ b/src/components/canvas/groups/Group.ts @@ -18,6 +18,10 @@ export type TGroupStyle = { borderWidth: number; selectedBackground: string; selectedBorder: string; + /** Background color when group is highlighted (block is being dragged over it) */ + highlightedBackground: string; + /** Border color when group is highlighted */ + highlightedBorder: string; }; export type TGroupGeometry = { @@ -43,6 +47,8 @@ const defaultStyle: TGroupStyle = { 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: TGroupGeometry = { @@ -85,6 +91,9 @@ export class Group extends GraphComponent extends GraphComponent extends GraphComponent selected > default + if (this.highlighted) { + 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.highlighted ? this.style.borderWidth + 1 : this.style.borderWidth; - // Рисуем прямоугольник группы + // Draw group rectangle ctx.beginPath(); ctx.roundRect(rect.x, rect.y, rect.width, rect.height, 8); ctx.fill(); diff --git a/src/components/canvas/groups/index.ts b/src/components/canvas/groups/index.ts index 6cf9e2ef..586ccbce 100644 --- a/src/components/canvas/groups/index.ts +++ b/src/components/canvas/groups/index.ts @@ -1,4 +1,40 @@ +import { BlockState } from "../../../store/block/Block"; +import { TGroup } from "../../../store/group/Group"; +import { TRect } from "../../../utils/types/shapes"; + import { BlockGroups } from "./BlockGroups"; +import { BlockGroupsTransferLayer } from "./BlockGroupsTransferLayer"; import { Group } from "./Group"; -export { BlockGroups, Group }; +// Export components +export { BlockGroups, Group, BlockGroupsTransferLayer }; + +// Export types from BlockGroups +export type { BlockGroupsProps, BlockGroupsContext, BlockGroupsState } from "./BlockGroups"; + +// Export types from Group +export type { TGroupProps, TGroupStyle, TGroupGeometry } from "./Group"; + +// Export types from TransferableBlockGroups +export type { + BlockGroupsTransferLayerProps, + OnTransferStart, + OnTransferEnd, + OnBlockGroupChange, + TBlockGroupsTransferGroupChange, +} from "./BlockGroupsTransferLayer"; + +// Export utility types for withBlockGrouping +export type { BlockState } from "../../../store/block/Block"; +export type { TGroup, TGroupId } from "../../../store/group/Group"; +export type { TRect } from "../../../utils/types/shapes"; + +/** + * Function type for grouping blocks in withBlockGrouping + */ +export type GroupingFn = (blocks: BlockState[]) => Record; + +/** + * Function type for mapping grouped blocks to TGroup objects in withBlockGrouping + */ +export type MapToGroupsFn = (key: string, params: { blocks: BlockState[]; rect: TRect }) => TGroup; diff --git a/src/graph.ts b/src/graph.ts index 6f4f5547..3d9e65df 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -12,6 +12,7 @@ import { TGraphColors, TGraphConstants, initGraphColors, initGraphConstants } fr import { GraphEvent, GraphEventParams, GraphEventsDefinitions, isGraphEvent } from "./graphEvents"; import { scheduler } from "./lib/Scheduler"; 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"; @@ -57,15 +58,15 @@ export enum GraphState { export class Graph { private scheduler = scheduler; - public cameraService: CameraService = new CameraService(this); + public readonly cameraService: CameraService = new CameraService(this); - public layers: Layers = new Layers(); + public readonly layers: Layers = new Layers(); - public api = new PublicGraphApi(this); + public readonly api = new PublicGraphApi(this); - public eventEmitter = new EventTarget(); + public readonly eventEmitter = new EventTarget(); - public rootStore: RootStore = new RootStore(this); + public readonly rootStore: RootStore = new RootStore(this); public hitTest = new HitTest(this); @@ -73,15 +74,17 @@ export class Graph { * Service that manages drag operations for all draggable GraphComponents. * Handles autopanning, cursor locking, and coordinates drag lifecycle across selected components. */ - public dragService: DragService; + public readonly dragService: DragService; - protected graphLayer: GraphLayer; + public readonly keyboardService: KeyboardService; - protected belowLayer: BelowLayer; + protected readonly graphLayer: GraphLayer; - protected selectionLayer: SelectionLayer; + protected readonly belowLayer: BelowLayer; - protected cursorLayer: CursorLayer; + protected readonly selectionLayer: SelectionLayer; + + protected readonly cursorLayer: CursorLayer; public getGraphCanvas() { return this.graphLayer.getCanvas(); @@ -130,6 +133,7 @@ export class Graph { // Initialize DragService for managing drag operations on GraphComponents this.dragService = new DragService(this); + this.keyboardService = new KeyboardService(this); this.selectionLayer.hide(); this.graphLayer.hide(); @@ -447,6 +451,7 @@ export class Graph { this.layers.on("update-size", this.onUpdateSize); this.layers.start(); this.scheduler.start(); + this.setGraphState(GraphState.READY); this.runAfterGraphReady(() => { this.selectionLayer.show(); diff --git a/src/services/KeyboardService/index.ts b/src/services/KeyboardService/index.ts new file mode 100644 index 00000000..0f5da768 --- /dev/null +++ b/src/services/KeyboardService/index.ts @@ -0,0 +1,120 @@ +import { signal } from "@preact/signals-core"; + +import { Graph, GraphState } from "../../graph"; + +/** + * KeyboardService + * This is an internal service that manages keyboard state for the graph. + * + * It is used to detect keyboard state changes and to provide a reactive signal with the current keyboard state. + * + * @example + * ```typescript + * const keyboardService = new KeyboardService(graph); + * keyboardService.$keyboardState.subscribe((state) => { + * console.log("is shift key pressed", state.shiftKey); + * console.log("is meta key pressed", state.metaKey); + * console.log("is ctrl key pressed", state.ctrlKey); + * console.log("is alt key pressed", state.altKey); + * }); + * ``` + * @internal + */ +export class KeyboardService { + protected $keyboardState = signal<{ + shiftKey: boolean; + metaKey: boolean; + ctrlKey: boolean; + altKey: boolean; + }>({ + shiftKey: false, + metaKey: false, + ctrlKey: false, + altKey: false, + }); + + protected lastEvent: KeyboardEvent | null = null; + + protected unsubscribes: (() => void)[] = []; + + constructor(private graph: Graph) { + this.graph.on("state-change", (event) => { + if (event.detail.state === GraphState.READY) { + this.startListening(); + } else if (event.detail.state === GraphState.INIT) { + this.stopListening(); + } + }); + } + + public isShiftPressed(): boolean { + return this.$keyboardState.value.shiftKey; + } + + public isMetaPressed(): boolean { + return this.$keyboardState.value.metaKey; + } + + public isCtrlPressed(): boolean { + return this.$keyboardState.value.ctrlKey; + } + + public isAltPressed(): boolean { + return this.$keyboardState.value.altKey; + } + + protected startListening(): void { + this.graph.layers.$root?.ownerDocument?.addEventListener("keydown", this.handleKeyDown, { + capture: true, + }); + this.graph.layers.$root?.ownerDocument?.addEventListener("keyup", this.handleKeyDown, { + capture: true, + }); + this.unsubscribes.push(() => { + this.graph.layers.$root?.ownerDocument?.removeEventListener("keydown", this.handleKeyDown, { + capture: true, + }); + this.graph.layers.$root?.ownerDocument?.removeEventListener("keyup", this.handleKeyDown, { + capture: true, + }); + }); + } + + protected stopListening(): void { + this.unsubscribes.forEach((unsubscribe) => unsubscribe()); + this.unsubscribes = []; + this.lastEvent = null; + this.$keyboardState.value = { + shiftKey: false, + metaKey: false, + ctrlKey: false, + altKey: false, + }; + } + + protected hasChanged(event: KeyboardEvent): boolean { + return ( + this.lastEvent?.key !== event.key || + this.lastEvent?.shiftKey !== event.shiftKey || + this.lastEvent?.metaKey !== event.metaKey || + this.lastEvent?.ctrlKey !== event.ctrlKey || + this.lastEvent?.altKey !== event.altKey + ); + } + + protected handleKeyDown = (event: KeyboardEvent): void => { + if (this.hasChanged(event)) { + this.lastEvent = event; + this.$keyboardState.value = { + shiftKey: event.shiftKey, + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + altKey: event.altKey, + }; + } + }; + + protected cleanup(): void { + this.stopListening(); + } +} diff --git a/src/services/drag/DragService.ts b/src/services/drag/DragService.ts index 3643035a..6b910065 100644 --- a/src/services/drag/DragService.ts +++ b/src/services/drag/DragService.ts @@ -63,24 +63,32 @@ export class DragService { private createIdleState(): DragState { return { isDragging: false, + initialEvent: null, + currentEvent: null, components: [], componentTypes: new Set(), isMultiple: false, isHomogeneous: true, + startCoords: null, + currentCoords: null, }; } /** * Create active drag state from components */ - private createDragState(components: GraphComponent[]): DragState { + private createDragState(event: MouseEvent, components: GraphComponent[], startCoords: [number, number]): DragState { const componentTypes = new Set(components.map((c) => c.constructor.name)); return { isDragging: true, + initialEvent: event, + currentEvent: event, components, componentTypes, isMultiple: components.length > 1, isHomogeneous: componentTypes.size <= 1, + startCoords, + currentCoords: startCoords, }; } @@ -89,10 +97,6 @@ export class DragService { */ public destroy(): void { this.cleanup(); - if (this.unsubscribeMouseDown) { - this.unsubscribeMouseDown(); - this.unsubscribeMouseDown = null; - } } /** @@ -178,14 +182,14 @@ export class DragService { * Handle drag start from dragListener */ private handleDragStart = (event: MouseEvent): void => { - // Update reactive state - this.$state.value = this.createDragState(this.dragComponents); - // Calculate starting coordinates in world space const coords = this.getWorldCoords(event); this.startCoords = coords; this.prevCoords = coords; + // Update reactive state with event and coordinates + this.$state.value = this.createDragState(event, this.dragComponents, coords); + // Create context for drag start const context: DragContext = { sourceEvent: event, @@ -211,6 +215,13 @@ export class DragService { const currentCoords = this.getWorldCoords(event); + // Update reactive state with current event and coordinates + this.$state.value = { + ...this.$state.value, + currentEvent: event, + currentCoords, + }; + const diff: DragDiff = { startCoords: this.startCoords, prevCoords: this.prevCoords, @@ -279,6 +290,10 @@ export class DragService { this.dragComponents = []; this.startCoords = null; this.prevCoords = null; + if (this.unsubscribeMouseDown) { + this.unsubscribeMouseDown(); + this.unsubscribeMouseDown = null; + } // Update reactive state this.$state.value = this.createIdleState(); diff --git a/src/services/drag/types.ts b/src/services/drag/types.ts index 96cd4f87..556073b9 100644 --- a/src/services/drag/types.ts +++ b/src/services/drag/types.ts @@ -39,6 +39,10 @@ export type DragOperationCallbacks = { export type DragState = { /** Whether a drag operation is currently in progress */ isDragging: boolean; + /** The initial mouse event that started the drag operation (for checking modifiers like shiftKey) */ + initialEvent: MouseEvent | null; + /** The current mouse event (updated on each drag move) */ + currentEvent: MouseEvent | null; /** Components participating in the current drag operation */ components: GraphComponent[]; /** Set of component type names (constructor names) participating in drag */ @@ -47,6 +51,10 @@ export type DragState = { isMultiple: boolean; /** Whether all dragged components are of the same type */ isHomogeneous: boolean; + /** Starting coordinates in world space when drag began */ + startCoords: [number, number] | null; + /** Current coordinates in world space */ + currentCoords: [number, number] | null; }; /** diff --git a/src/store/group/Group.ts b/src/store/group/Group.ts index 677ed868..3941992a 100644 --- a/src/store/group/Group.ts +++ b/src/store/group/Group.ts @@ -27,6 +27,13 @@ export class GroupState { component: Group, }); + /** + * When true, the group's rect should not be auto-updated based on contained blocks. + * Used during Shift+drag to keep the group visually stable. + * Note: This is NOT a signal to avoid cycle detection issues in computed signals. + */ + private sizeLocked = false; + constructor( protected store: GroupsListStore, state: TGroup, @@ -35,6 +42,27 @@ export class GroupState { this.$state.value = state; } + /** + * Check if the group's size is locked + */ + public isSizeLocked(): boolean { + return this.sizeLocked; + } + + /** + * Lock the group's size to prevent auto-resize during block transfer + */ + public lockSize(): void { + this.sizeLocked = true; + } + + /** + * Unlock the group's size to allow auto-resize + */ + public unlockSize(): void { + this.sizeLocked = false; + } + public get id() { return this.$state.value.id; } diff --git a/src/stories/canvas/groups/transfer.stories.tsx b/src/stories/canvas/groups/transfer.stories.tsx new file mode 100644 index 00000000..d4ba4426 --- /dev/null +++ b/src/stories/canvas/groups/transfer.stories.tsx @@ -0,0 +1,327 @@ +import React, { useEffect, useState } from "react"; + +import type { Meta, StoryFn } from "@storybook/react-webpack5"; + +import { TDefinitionGroup } from "../../../components/canvas/groups/BlockGroups"; +import { BlockGroupsTransferLayer } from "../../../components/canvas/groups/BlockGroupsTransferLayer"; +import { ECanDrag, Graph, GraphState, Group, TBlock } from "../../../index"; +import { GraphCanvas, useGraph, useGraphEvent, useLayer } from "../../../react-components"; +import { useFn } from "../../../react-components/utils/hooks/useFn"; +import { BlockStory } from "../../main/Block"; + +const createConfig = () => { + const blocks: TBlock[] = [ + // Group A blocks (blue group) + { + id: "block-a1", + is: "block", + name: "Block A1", + x: 50, + y: 50, + width: 150, + height: 80, + selected: false, + anchors: [], + }, + { + id: "block-a2", + is: "block", + name: "Block A2", + x: 50, + y: 150, + width: 150, + height: 80, + selected: false, + anchors: [], + }, + { + id: "block-a3", + is: "block", + name: "Block A3", + x: 50, + y: 250, + width: 150, + height: 80, + selected: false, + anchors: [], + }, + // Group B blocks (green group) + { + id: "block-b1", + is: "block", + name: "Block B1", + x: 350, + y: 50, + width: 150, + height: 80, + selected: false, + anchors: [], + }, + { + id: "block-b2", + is: "block", + name: "Block B2", + x: 350, + y: 150, + width: 150, + height: 80, + selected: false, + anchors: [], + }, + // Group C blocks (purple group) + { + id: "block-c1", + is: "block", + name: "Block C1", + x: 650, + y: 50, + width: 150, + height: 80, + selected: false, + anchors: [], + }, + // Blocks without group + { + id: "block-free1", + is: "block", + name: "Free Block 1", + x: 200, + y: 400, + width: 150, + height: 80, + selected: false, + anchors: [], + }, + { + id: "block-free2", + is: "block", + name: "Free Block 2", + x: 450, + y: 400, + width: 150, + height: 80, + selected: false, + anchors: [], + }, + ]; + + return { blocks }; +}; + +// Blue group style +const GroupA = Group.define({ + style: { + background: "rgba(59, 130, 246, 0.1)", + border: "rgba(59, 130, 246, 0.4)", + borderWidth: 2, + selectedBackground: "rgba(59, 130, 246, 0.2)", + selectedBorder: "rgba(59, 130, 246, 0.8)", + highlightedBackground: "rgba(59, 130, 246, 0.3)", + highlightedBorder: "rgba(59, 130, 246, 1)", + }, +}); + +// Green group style +const GroupB = Group.define({ + style: { + background: "rgba(34, 197, 94, 0.1)", + border: "rgba(34, 197, 94, 0.4)", + borderWidth: 2, + selectedBackground: "rgba(34, 197, 94, 0.2)", + selectedBorder: "rgba(34, 197, 94, 0.8)", + highlightedBackground: "rgba(34, 197, 94, 0.3)", + highlightedBorder: "rgba(34, 197, 94, 1)", + }, +}); + +// Purple group style +const GroupC = Group.define({ + style: { + background: "rgba(168, 85, 247, 0.1)", + border: "rgba(168, 85, 247, 0.4)", + borderWidth: 2, + selectedBackground: "rgba(168, 85, 247, 0.2)", + selectedBorder: "rgba(168, 85, 247, 0.8)", + highlightedBackground: "rgba(168, 85, 247, 0.3)", + highlightedBorder: "rgba(168, 85, 247, 1)", + }, +}); + +const groupsInfo: TDefinitionGroup[] = [ + { + id: "group-a", + blocksIds: ["block-a1", "block-a2", "block-a3"], + component: GroupA, + }, + { + id: "group-b", + blocksIds: ["block-b1", "block-b2"], + component: GroupB, + }, + { + id: "group-c", + blocksIds: ["block-c1"], + component: GroupC, + }, +]; + +const GroupsLayer = BlockGroupsTransferLayer.withPredefinedGroups(); + +const GroupTransferApp = () => { + const { graph, setEntities, start } = useGraph({ + settings: { + canDrag: ECanDrag.ALL, + }, + }); + + const [groups, setGroups] = useState(groupsInfo); + const config = createConfig(); + + const groupsLayer = useLayer(graph, GroupsLayer, { + draggable: true, + onBlockGroupChange: (changes) => { + setGroups((groups) => { + const newGroups = groups.map((g) => ({ ...g, blocksIds: [...g.blocksIds] })); + + changes.forEach(({ blockId, sourceGroup, targetGroup }) => { + // Remove from source + if (sourceGroup) { + const source = newGroups.find((g) => g.id === sourceGroup); + if (source) { + source.blocksIds = source.blocksIds.filter((id) => id !== blockId); + } + } + // Add to target + if (targetGroup) { + const target = newGroups.find((g) => g.id === targetGroup); + if (target) { + target.blocksIds.push(blockId); + } + } + }); + + return newGroups; + }); + }, + onTransferEnd: (blockIds, targetGroupId) => { + console.log("onTransferEnd", blockIds, targetGroupId); + }, + onTransferStart: (blockIds, sourceGroupIds) => { + console.log("onTransferStart", blockIds, sourceGroupIds); + }, + transferEnabled: true, + updateBlocksOnDrag: true, + }); + + useGraphEvent(graph, "state-change", ({ state }) => { + if (state === GraphState.ATTACHED) { + setEntities(config); + start(); + graph.zoomTo("center", { padding: 100 }); + } + }); + + useEffect(() => { + if (groupsLayer) { + groupsLayer.defineGroups(groups); + } + }, [groupsLayer, groups]); + + const renderBlockFn = useFn((graphObject: Graph, block: TBlock) => { + return ; + }); + + return ( +

+
+ +
+
+

Instructions

+
    +
  • + Start dragging any block, then hold Shift to enter transfer mode +
  • +
  • + Notice: ALL groups freeze their size when Shift is held +
  • +
  • Move cursor over a target group — it will highlight
  • +
  • + Release Shift to transfer the block to the highlighted group +
  • +
  • Release Shift outside any group to remove block from its group
  • +
  • + Multi-select blocks and drag with Shift to transfer all at once +
  • +
+ +

Groups

+
+ + Group A (Blue) + + + Group B (Green) + + + Group C (Purple) + +
+
+
+ ); +}; + +const meta: Meta = { + title: "Canvas/Groups", + component: GroupTransferApp, +}; + +export default meta; + +export const GroupTransfer: StoryFn = () => ; +GroupTransfer.parameters = { + docs: { + description: { + story: ` +## Group Transfer Demo + +This story demonstrates the block-to-group transfer feature using \`TransferableBlockGroups\`. + +### How to use: +1. Start dragging any block +2. **Hold Shift** to activate transfer mode (ALL groups freeze their size) +3. Drag over a target group - it will be highlighted +4. **Release Shift** to transfer the block to the highlighted group +5. If you release Shift outside any group, the block will be removed from its group +6. If you release mouse (without releasing Shift first), transfer is cancelled + +### Multi-block transfer: +- Select multiple blocks (Cmd/Ctrl + Click) +- Drag with Shift to transfer all selected blocks at once + +### Usage: +\`\`\`tsx +import { TransferableBlockGroups } from "@gravity-ui/graph"; + +const GroupsLayer = TransferableBlockGroups.withBlockGrouping({ + groupingFn: (blocks) => groupBy(blocks, (b) => b.group), + mapToGroups: (groupId, { rect }) => ({ id: groupId, rect }), +}); + +graph.addLayer(GroupsLayer, { transferEnabled: true }); +\`\`\` + `, + }, + }, +};