From c84a7ce334ca53815d3b4752399d45e867a58deb Mon Sep 17 00:00:00 2001 From: filtered <176114999+webfiltered@users.noreply.github.com> Date: Mon, 11 Nov 2024 21:58:11 +1100 Subject: [PATCH 1/4] Add copy & paste of groups & reroutes Complete rewrite of copy & paste Fixes a bug where failure to clone a node would corrupt all subsequent nodes No longer mutates nodes when copying --- src/LGraph.ts | 2 + src/LGraphCanvas.ts | 238 ++++++++++++++++++++++--------------- src/types/serialisation.ts | 8 ++ 3 files changed, 154 insertions(+), 94 deletions(-) diff --git a/src/LGraph.ts b/src/LGraph.ts index ea96b606..d74bf2e0 100644 --- a/src/LGraph.ts +++ b/src/LGraph.ts @@ -1265,7 +1265,9 @@ export class LGraph implements LinkNetwork, Serialisable { * @param linkIds IDs of links that pass through this reroute */ setReroute({ id, parentId, pos, linkIds }: SerialisableReroute): Reroute { + id ??= ++this.state.lastRerouteId if (id > this.state.lastRerouteId) this.state.lastRerouteId = id + const reroute = this.reroutes.get(id) ?? new Reroute(id, this) reroute.update(parentId, pos, linkIds) this.reroutes.set(id, reroute) diff --git a/src/LGraphCanvas.ts b/src/LGraphCanvas.ts index ad3b1c95..c4888eec 100644 --- a/src/LGraphCanvas.ts +++ b/src/LGraphCanvas.ts @@ -2,8 +2,8 @@ import type { CanvasColour, Dictionary, Direction, IBoundaryNodes, IContextMenuO import type { IWidget, TWidgetValue } from "./types/widgets" import { LGraphNode, type NodeId } from "./LGraphNode" import type { CanvasDragEvent, CanvasMouseEvent, CanvasWheelEvent, CanvasEventDetail, CanvasPointerEvent } from "./types/events" -import type { IClipboardContents } from "./types/serialisation" -import { LLink } from "./LLink" +import type { ClipboardItems } from "./types/serialisation" +import { LLink, type LinkId } from "./LLink" import type { LGraph } from "./LGraph" import type { ContextMenu } from "./ContextMenu" import { EaseFunction, LGraphEventMode, LinkDirection, LinkMarkerShape, LinkRenderType, RenderShape, TitleMode } from "./types/globalEnums" @@ -2961,55 +2961,46 @@ export class LGraphCanvas { return false } } + copyToClipboard(nodes?: Dictionary): void { - const clipboard_info: IClipboardContents = { + const serialisable: ClipboardItems = { nodes: [], + groups: [], + reroutes: [], links: [] } - let index = 0 - const selected_nodes_array: LGraphNode[] = [] - if (!nodes) nodes = this.selected_nodes - for (const i in nodes) { - const node = nodes[i] - if (node.clonable === false) continue - node._relative_id = index - selected_nodes_array.push(node) - index += 1 - } + // Create serialisable objects + for (const item of this.selectedItems) { + if (item instanceof LGraphNode) { + // Nodes + if (item.clonable === false) continue - for (let i = 0; i < selected_nodes_array.length; ++i) { - const node = selected_nodes_array[i] - const cloned = node.clone() - if (!cloned) { - console.warn("node type not found: " + node.type) - continue - } - clipboard_info.nodes.push(cloned.serialize()) - if (node.inputs?.length) { - for (let j = 0; j < node.inputs.length; ++j) { - const input = node.inputs[j] - if (!input || input.link == null) continue - - const link_info = this.graph._links.get(input.link) - if (!link_info) continue - - const target_node = this.graph.getNodeById(link_info.origin_id) - if (!target_node) continue - - clipboard_info.links.push([ - target_node._relative_id, - link_info.origin_slot, //j, - node._relative_id, - link_info.target_slot, - target_node.id - ]) - } + const cloned = item.clone()?.serialize() + if (!cloned) continue + + cloned.id = item.id + serialisable.nodes.push(cloned) + + // Links + const links = item.inputs + ?.map(input => this.graph._links.get(input?.link)?.asSerialisable()) + .filter(x => !!x) + + if (!links) continue + serialisable.links.push(...links) + } else if (item instanceof LGraphGroup) { + // Groups + serialisable.groups.push(item.serialize()) + } else if (this.reroutesEnabled && item instanceof Reroute) { + // Reroutes + serialisable.reroutes.push(item.asSerialisable()) } } + localStorage.setItem( "litegrapheditor_clipboard", - JSON.stringify(clipboard_info) + JSON.stringify(serialisable) ) } @@ -3037,76 +3028,135 @@ export class LGraphCanvas { }) } - _pasteFromClipboard(isConnectUnselected = false): void { + /** + * Pastes the items from the canvas "clipbaord" - a local storage variable. + * @param connectInputs If `true`, always attempt to connect inputs of pasted nodes - including to nodes that were not pasted. + */ + _pasteFromClipboard(connectInputs = false): void { // if ctrl + shift + v is off, return when isConnectUnselected is true (shift is pressed) to maintain old behavior - if (!LiteGraph.ctrl_shift_v_paste_connect_unselected_outputs && isConnectUnselected) return + if (!LiteGraph.ctrl_shift_v_paste_connect_unselected_outputs && connectInputs) return + const data = localStorage.getItem("litegrapheditor_clipboard") if (!data) return - this.graph.beforeChange() + const { graph } = this + graph.beforeChange() - //create nodes - const clipboard_info: IClipboardContents = JSON.parse(data) - // calculate top-left node, could work without this processing but using diff with last node pos :: clipboard_info.nodes[clipboard_info.nodes.length-1].pos - let posMin: false | [number, number] = false - let posMinIndexes: false | [number, number] = false - for (let i = 0; i < clipboard_info.nodes.length; ++i) { - if (posMin) { - if (posMin[0] > clipboard_info.nodes[i].pos[0]) { - posMin[0] = clipboard_info.nodes[i].pos[0] - posMinIndexes[0] = i - } - if (posMin[1] > clipboard_info.nodes[i].pos[1]) { - posMin[1] = clipboard_info.nodes[i].pos[1] - posMinIndexes[1] = i - } - } - else { - posMin = [clipboard_info.nodes[i].pos[0], clipboard_info.nodes[i].pos[1]] - posMinIndexes = [i, i] + // Parse & initialise + const parsed: ClipboardItems = JSON.parse(data) + parsed.nodes ??= [] + parsed.groups ??= [] + parsed.reroutes ??= [] + parsed.links ??= [] + + // Find top-left-most boundary + let offsetX = Infinity + let offsetY = Infinity + for (const item of [...parsed.nodes, ...parsed.reroutes]) { + if (item.pos[0] < offsetX) offsetX = item.pos[0] + if (item.pos[1] < offsetY) offsetY = item.pos[1] + } + + // TODO: Remove when implementing `asSerialisable` + if (parsed.groups) { + for (const group of parsed.groups) { + if (group.bounding[0] < offsetX) offsetX = group.bounding[0] + if (group.bounding[1] < offsetY) offsetY = group.bounding[1] + } + } + + /** All successfully created items */ + const created: Positionable[] = [] + /** Map: original node IDs to newly created nodes */ + const nodes = new Map() + /** Map: original link IDs to new link IDs */ + const linkIds = new Map() + /** Map: original reroute IDs to newly created reroutes */ + const reroutes = new Map() + // const failedNodes: ISerialisedNode[] = [] + + // Groups + for (const info of parsed.groups) { + info.id = undefined + + const group = new LGraphGroup() + group.configure(info) + graph.add(group) + created.push(group) + } + + // Nodes + for (const info of parsed.nodes) { + const node = LiteGraph.createNode(info.type) + if (!node) { + // failedNodes.push(info) + continue } + + nodes.set(info.id, node) + info.id = undefined + + node.configure(info) + graph.add(node) + + created.push(node) } - const nodes: LGraphNode[] = [] - for (let i = 0; i < clipboard_info.nodes.length; ++i) { - const node_data = clipboard_info.nodes[i] - const node = LiteGraph.createNode(node_data.type) - if (node) { - node.configure(node_data) - //paste in last known mouse position - node.pos[0] += this.graph_mouse[0] - posMin[0] //+= 5; - node.pos[1] += this.graph_mouse[1] - posMin[1] //+= 5; + // Reroutes + for (const info of parsed.reroutes) { + const { id } = info + info.id = undefined - this.graph.add(node, true) + const reroute = graph.setReroute(info) + created.push(reroute) + reroutes.set(id, reroute) + } - nodes.push(node) - } + // Remap reroute parentIds for pasted reroutes + for (const reroute of reroutes.values()) { + const mapped = reroutes.get(reroute.parentId) + if (mapped) reroute.parentId = mapped.id } - //create links - for (let i = 0; i < clipboard_info.links.length; ++i) { - const link_info = clipboard_info.links[i] - let origin_node: LGraphNode = undefined - const origin_node_relative_id = link_info[0] - if (origin_node_relative_id != null) { - origin_node = nodes[origin_node_relative_id] - } else if (LiteGraph.ctrl_shift_v_paste_connect_unselected_outputs && isConnectUnselected) { - const origin_node_id = link_info[4] - if (origin_node_id) { - origin_node = this.graph.getNodeById(origin_node_id) - } + // Links + for (const info of parsed.links) { + // Find the copied node / reroute ID + let outNode = nodes.get(info.origin_id) + let afterRerouteId = reroutes.get(info.parentId)?.id + + // If it wasn't copied, use the original graph value + if (connectInputs && LiteGraph.ctrl_shift_v_paste_connect_unselected_outputs) { + outNode ??= graph.getNodeById(info.origin_id) + afterRerouteId ??= info.parentId } - const target_node = nodes[link_info[2]] - if (origin_node && target_node) - origin_node.connect(link_info[1], target_node, link_info[3]) - else - console.warn("Warning, nodes missing on pasting") + const inNode = nodes.get(info.target_id) + if (inNode) { + const link = outNode?.connect(info.origin_slot, inNode, info.target_slot, afterRerouteId) + if (link) linkIds.set(info.id, link.id) + } } - this.selectNodes(nodes) + // Remap linkIds + for (const reroute of reroutes.values()) { + const linkIds = [...reroute.linkIds].map(x => linkIds.get(x) ?? x) + reroute.update(reroute.parentId, undefined, linkIds) - this.graph.afterChange() + // Remove any invalid items + if (!reroute.validateLinks(graph.links)) graph.removeReroute(reroute.id) + } + + // Adjust positions + for (const item of created) { + item.pos[0] += this.graph_mouse[0] - offsetX + item.pos[1] += this.graph_mouse[1] - offsetY + } + + // TODO: Report failures, i.e. `failedNodes` + + this.selectItems(created) + + graph.afterChange() } pasteFromClipboard(isConnectUnselected = false): void { diff --git a/src/types/serialisation.ts b/src/types/serialisation.ts index 071cd2a4..3ae64f08 100644 --- a/src/types/serialisation.ts +++ b/src/types/serialisation.ts @@ -84,6 +84,14 @@ export interface ISerialisedGroup { export type TClipboardLink = [targetRelativeIndex: number, originSlot: number, nodeRelativeIndex: number, targetSlot: number, targetNodeId: NodeId] +/** Items copied from the canvas */ +export interface ClipboardItems { + nodes?: ISerialisedNode[] + groups?: ISerialisedGroup[] + reroutes?: SerialisableReroute[] + links?: SerialisableLLink[] +} + /** */ export interface IClipboardContents { nodes?: ISerialisedNode[] From 83baa0e9945164b15387424099dbb1a4cf53051f Mon Sep 17 00:00:00 2001 From: filtered <176114999+webfiltered@users.noreply.github.com> Date: Tue, 12 Nov 2024 09:43:12 +1100 Subject: [PATCH 2/4] Fix name collision --- src/LGraphCanvas.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/LGraphCanvas.ts b/src/LGraphCanvas.ts index c4888eec..ede2e6a4 100644 --- a/src/LGraphCanvas.ts +++ b/src/LGraphCanvas.ts @@ -3139,8 +3139,8 @@ export class LGraphCanvas { // Remap linkIds for (const reroute of reroutes.values()) { - const linkIds = [...reroute.linkIds].map(x => linkIds.get(x) ?? x) - reroute.update(reroute.parentId, undefined, linkIds) + const ids = [...reroute.linkIds].map(x => linkIds.get(x) ?? x) + reroute.update(reroute.parentId, undefined, ids) // Remove any invalid items if (!reroute.validateLinks(graph.links)) graph.removeReroute(reroute.id) From 549a0fbe03dd33300d3dc8e4d459135d2eadb89c Mon Sep 17 00:00:00 2001 From: filtered <176114999+webfiltered@users.noreply.github.com> Date: Wed, 13 Nov 2024 08:10:22 +1100 Subject: [PATCH 3/4] Fix cannot copy specified nodes to clipboard --- src/LGraphCanvas.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/LGraphCanvas.ts b/src/LGraphCanvas.ts index ede2e6a4..4afca4e6 100644 --- a/src/LGraphCanvas.ts +++ b/src/LGraphCanvas.ts @@ -2962,7 +2962,12 @@ export class LGraphCanvas { } } - copyToClipboard(nodes?: Dictionary): void { + /** + * Copies canvas items to an internal, app-specific clipboard backed by local storage. + * When called without parameters, it copies {@link selectedItems}. + * @param items The items to copy. If nullish, all selected items are copied. + */ + copyToClipboard(items?: Iterable): void { const serialisable: ClipboardItems = { nodes: [], groups: [], @@ -2971,7 +2976,7 @@ export class LGraphCanvas { } // Create serialisable objects - for (const item of this.selectedItems) { + for (const item of items ?? this.selectedItems) { if (item instanceof LGraphNode) { // Nodes if (item.clonable === false) continue From 0625080f463b7206d1c3179083fd02712eaaf903 Mon Sep 17 00:00:00 2001 From: filtered <176114999+webfiltered@users.noreply.github.com> Date: Wed, 13 Nov 2024 22:15:21 +1100 Subject: [PATCH 4/4] Allow mapping of original IDs to pasted clones --- src/LGraphCanvas.ts | 39 ++++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/src/LGraphCanvas.ts b/src/LGraphCanvas.ts index 4afca4e6..52711265 100644 --- a/src/LGraphCanvas.ts +++ b/src/LGraphCanvas.ts @@ -100,6 +100,21 @@ export interface LGraphCanvasState { readOnly: boolean } +/** + * The items created by a clipboard paste operation. + * Includes maps of original copied IDs to newly created items. + */ +interface ClipboardPasteResult { + /** All successfully created items */ + created: Positionable[] + /** Map: original node IDs to newly created nodes */ + nodes: Map + /** Map: original link IDs to new link IDs */ + links: Map + /** Map: original reroute IDs to newly created reroutes */ + reroutes: Map +} + /** * This class is in charge of rendering one graph inside a canvas. And provides all the interaction required. * Valid callbacks are: onNodeSelected, onNodeDeselected, onShowNodePanel, onNodeDblClicked @@ -3037,7 +3052,7 @@ export class LGraphCanvas { * Pastes the items from the canvas "clipbaord" - a local storage variable. * @param connectInputs If `true`, always attempt to connect inputs of pasted nodes - including to nodes that were not pasted. */ - _pasteFromClipboard(connectInputs = false): void { + _pasteFromClipboard(connectInputs = false): ClipboardPasteResult { // if ctrl + shift + v is off, return when isConnectUnselected is true (shift is pressed) to maintain old behavior if (!LiteGraph.ctrl_shift_v_paste_connect_unselected_outputs && connectInputs) return @@ -3070,14 +3085,14 @@ export class LGraphCanvas { } } - /** All successfully created items */ - const created: Positionable[] = [] - /** Map: original node IDs to newly created nodes */ - const nodes = new Map() - /** Map: original link IDs to new link IDs */ - const linkIds = new Map() - /** Map: original reroute IDs to newly created reroutes */ - const reroutes = new Map() + const results: ClipboardPasteResult = { + created: [], + nodes: new Map(), + links: new Map(), + reroutes: new Map(), + } + const { created, nodes, links, reroutes } = results + // const failedNodes: ISerialisedNode[] = [] // Groups @@ -3138,13 +3153,13 @@ export class LGraphCanvas { const inNode = nodes.get(info.target_id) if (inNode) { const link = outNode?.connect(info.origin_slot, inNode, info.target_slot, afterRerouteId) - if (link) linkIds.set(info.id, link.id) + if (link) links.set(info.id, link) } } // Remap linkIds for (const reroute of reroutes.values()) { - const ids = [...reroute.linkIds].map(x => linkIds.get(x) ?? x) + const ids = [...reroute.linkIds].map(x => links.get(x)?.id ?? x) reroute.update(reroute.parentId, undefined, ids) // Remove any invalid items @@ -3162,6 +3177,8 @@ export class LGraphCanvas { this.selectItems(created) graph.afterChange() + + return results } pasteFromClipboard(isConnectUnselected = false): void {