Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Copy & Paste all items #302

Merged
merged 4 commits into from
Nov 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading