Skip to content

Commit

Permalink
Copy & Paste all items (#302)
Browse files Browse the repository at this point in the history
* 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

* Fix name collision

* Fix cannot copy specified nodes to clipboard

* Allow mapping of original IDs to pasted clones
  • Loading branch information
webfiltered authored Nov 13, 2024
1 parent 4c0c05e commit cc08481
Show file tree
Hide file tree
Showing 3 changed files with 176 additions and 94 deletions.
2 changes: 2 additions & 0 deletions src/LGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1265,7 +1265,9 @@ export class LGraph implements LinkNetwork, Serialisable<SerialisableGraph> {
* @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)
Expand Down
260 changes: 166 additions & 94 deletions src/LGraphCanvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<NodeId, LGraphNode>
/** Map: original link IDs to new link IDs */
links: Map<LinkId, LLink>
/** Map: original reroute IDs to newly created reroutes */
reroutes: Map<RerouteId, Reroute>
}

/**
* 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
Expand Down Expand Up @@ -2961,55 +2976,51 @@ export class LGraphCanvas {
return false
}
}
copyToClipboard(nodes?: Dictionary<LGraphNode>): void {
const clipboard_info: IClipboardContents = {

/**
* 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<Positionable>): void {
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 items ?? 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)
)
}

Expand Down Expand Up @@ -3037,76 +3048,137 @@ 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): 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 && 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]
}
}
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;
const results: ClipboardPasteResult = {
created: [],
nodes: new Map<NodeId, LGraphNode>(),
links: new Map<LinkId, LLink>(),
reroutes: new Map<RerouteId, Reroute>(),
}
const { created, nodes, links, reroutes } = results

// const failedNodes: ISerialisedNode[] = []

this.graph.add(node, true)
// Groups
for (const info of parsed.groups) {
info.id = undefined

nodes.push(node)
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)
}

//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)
}
// Reroutes
for (const info of parsed.reroutes) {
const { id } = info
info.id = undefined

const reroute = graph.setReroute(info)
created.push(reroute)
reroutes.set(id, reroute)
}

// Remap reroute parentIds for pasted reroutes
for (const reroute of reroutes.values()) {
const mapped = reroutes.get(reroute.parentId)
if (mapped) reroute.parentId = mapped.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) links.set(info.id, link)
}
}

this.selectNodes(nodes)
// Remap linkIds
for (const reroute of reroutes.values()) {
const ids = [...reroute.linkIds].map(x => links.get(x)?.id ?? x)
reroute.update(reroute.parentId, undefined, ids)

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()

return results
}

pasteFromClipboard(isConnectUnselected = false): void {
Expand Down
8 changes: 8 additions & 0 deletions src/types/serialisation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down

0 comments on commit cc08481

Please sign in to comment.