blob: fa027632e50af1cfb804b7d31323f9363c905209 [file]
// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type {QueryNode} from './query_node';
import {getAllNodes, addConnection} from './query_builder/graph_utils';
// Clipboard entry stores a cloned node with its relative position for paste.
export interface ClipboardEntry {
node: QueryNode;
relativeX: number; // Position relative to the first node (only used if not docked)
relativeY: number;
isDocked: boolean; // True if node was docked (no explicit layout position)
}
// Clipboard connection stores connections between clipboard nodes (by index).
export interface ClipboardConnection {
fromIndex: number;
toIndex: number;
portIndex?: number;
}
export interface ClipboardResult {
clipboardNodes: ClipboardEntry[];
clipboardConnections: ClipboardConnection[];
}
// The subset of state needed for copy operations.
interface CopyableState {
readonly rootNodes: QueryNode[];
readonly selectedNodes: ReadonlySet<string>;
readonly nodeLayouts: Map<string, {x: number; y: number}>;
}
// The subset of state needed for paste operations.
interface PastableState {
readonly rootNodes: QueryNode[];
readonly nodeLayouts: Map<string, {x: number; y: number}>;
}
// Copies the currently selected nodes and their internal connections to a
// clipboard result. Returns undefined if no nodes are selected.
export function copySelectedNodes(
state: CopyableState,
): ClipboardResult | undefined {
const selectedNodeIds = state.selectedNodes;
if (selectedNodeIds.size === 0) {
return undefined;
}
const allNodes = getAllNodes(state.rootNodes);
const selectedNodes = allNodes.filter((n) => selectedNodeIds.has(n.nodeId));
if (selectedNodes.length === 0) {
return undefined;
}
// Get positions for relative layout calculation
const positions = selectedNodes.map((node) => {
const layout = state.nodeLayouts.get(node.nodeId);
return {
node,
x: layout?.x ?? 0,
y: layout?.y ?? 0,
};
});
// Find the top-left corner as reference point
const minX = Math.min(...positions.map((p) => p.x));
const minY = Math.min(...positions.map((p) => p.y));
// Create clipboard entries with cloned nodes and relative positions
// Track whether each node is docked (no explicit layout) or undocked
const nodeIdToIndex = new Map<string, number>();
const clipboardNodes: ClipboardEntry[] = positions.map((p, index) => {
nodeIdToIndex.set(p.node.nodeId, index);
const hasLayout = state.nodeLayouts.has(p.node.nodeId);
return {
node: p.node.clone(),
relativeX: p.x - minX,
relativeY: p.y - minY,
isDocked: !hasLayout,
};
});
// Capture connections between selected nodes
const clipboardConnections: ClipboardConnection[] = [];
for (const node of selectedNodes) {
const toIndex = nodeIdToIndex.get(node.nodeId);
if (toIndex === undefined) continue;
// Check primaryInput
if (node.primaryInput && selectedNodeIds.has(node.primaryInput.nodeId)) {
const fromIndex = nodeIdToIndex.get(node.primaryInput.nodeId);
if (fromIndex !== undefined) {
clipboardConnections.push({fromIndex, toIndex});
}
}
// Check secondaryInputs
if (node.secondaryInputs) {
for (const [portIndex, inputNode] of node.secondaryInputs.connections) {
if (selectedNodeIds.has(inputNode.nodeId)) {
const fromIndex = nodeIdToIndex.get(inputNode.nodeId);
if (fromIndex !== undefined) {
clipboardConnections.push({fromIndex, toIndex, portIndex});
}
}
}
}
}
return {clipboardNodes, clipboardConnections};
}
// Pastes clipboard nodes into the state. Returns the updated state fields
// with new nodes added, or undefined if clipboard is empty.
export function pasteClipboardNodes(
state: PastableState,
clipboard: ClipboardResult | undefined,
):
| {
rootNodes: QueryNode[];
selectedNodes: Set<string>;
nodeLayouts: Map<string, {x: number; y: number}>;
}
| undefined {
if (clipboard === undefined || clipboard.clipboardNodes.length === 0) {
return undefined;
}
// Clone nodes again for this paste operation (allows multiple pastes)
const newNodes = clipboard.clipboardNodes.map((entry) => entry.node.clone());
// Calculate paste offset (place slightly offset from original)
const pasteOffsetX = 50;
const pasteOffsetY = 50;
// Update layouts for new nodes - only add layouts for undocked nodes
// Docked nodes will remain docked (attached to their parent)
const updatedLayouts = new Map(state.nodeLayouts);
clipboard.clipboardNodes.forEach((entry, index) => {
if (!entry.isDocked) {
updatedLayouts.set(newNodes[index].nodeId, {
x: entry.relativeX + pasteOffsetX,
y: entry.relativeY + pasteOffsetY,
});
}
});
// Restore connections between pasted nodes
for (const conn of clipboard.clipboardConnections) {
const fromNode = newNodes[conn.fromIndex] as QueryNode | undefined;
const toNode = newNodes[conn.toIndex] as QueryNode | undefined;
if (fromNode !== undefined && toNode !== undefined) {
addConnection(fromNode, toNode, conn.portIndex);
}
}
return {
rootNodes: [...state.rootNodes, ...newNodes],
selectedNodes: new Set(newNodes.map((n) => n.nodeId)),
nodeLayouts: updatedLayouts,
};
}