浏览代码

Merge pull request #216 from j-p-higgins/undo_redo

Undo redo improvements
Jonathan Higgins 2 月之前
父节点
当前提交
29f21bcd10

+ 2 - 1
project.godot

@@ -90,7 +90,8 @@ auto_link_nodes={
 }
 redo={
 "deadzone": 0.2,
-"events": []
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"command_or_control_autoremap":true,"alt_pressed":false,"shift_pressed":false,"pressed":false,"keycode":0,"physical_keycode":89,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+]
 }
 
 [rendering]

+ 13 - 0
scenes/Nodes/addremoveinlets.gd

@@ -2,6 +2,7 @@ extends Control
 
 signal add_inlet
 signal remove_inlet
+var undo_redo: UndoRedo
 var minimum_inlet_count
 var maximum_inlet_count
 var current_inlet_count
@@ -14,6 +15,12 @@ func _ready() -> void:
 
 
 func _on_add_inlet_button_button_down() -> void:
+	undo_redo.create_action("Add Inlet")
+	undo_redo.add_do_method(add_new)
+	undo_redo.add_undo_method(remove)
+	undo_redo.commit_action()
+	
+func add_new() -> void:
 	add_inlet.emit()
 	current_inlet_count += 1
 	set_meta("inlet_count", current_inlet_count)
@@ -21,6 +28,12 @@ func _on_add_inlet_button_button_down() -> void:
 
 
 func _on_remove_inlet_button_button_down() -> void:
+	undo_redo.create_action("Remove Inlet")
+	undo_redo.add_do_method(remove)
+	undo_redo.add_undo_method(add_new)
+	undo_redo.commit_action()
+	
+func remove() -> void:
 	remove_inlet.emit()
 	current_inlet_count -= 1
 	set_meta("inlet_count", current_inlet_count)

+ 36 - 2
scenes/Nodes/node_logic.gd

@@ -1,6 +1,8 @@
 extends GraphNode
 
 @export var min_gap: float = 0.5  # editable value in inspector for the minimum gap between min and max
+var undo_redo: UndoRedo
+var button_states = {}
 signal open_help
 signal inlet_removed
 signal node_moved
@@ -11,6 +13,8 @@ func _ready() -> void:
 	for slider in sliders:
 		slider.value_changed.connect(_on_slider_value_changed.bind(slider))
 		
+	#link all buttons for undo redo
+	get_all_buttons()
 	#add button to title bar
 	var titlebar = self.get_titlebar_hbox()
 	
@@ -49,6 +53,15 @@ func _get_all_hsliders(node: Node) -> Array:
 			result += _get_all_hsliders(child)
 	return result
 
+func get_all_buttons() -> void:
+	for child in get_children():
+		if child is OptionButton:
+			button_states[child] = child.selected
+			child.item_selected.connect(button_changed.bind(child))
+		elif child is CheckButton:
+			button_states[child] = child.button_pressed
+			child.toggled.connect(button_changed.bind(child))
+			
 func _on_slider_value_changed(value: float, changed_slider: HSlider) -> void:
 	#checks if the slider moved has min or max meta data
 	var is_min = changed_slider.get_meta("min")
@@ -128,6 +141,7 @@ func _on_position_offset_changed():
 	
 	
 func _randomise_sliders():
+	undo_redo.create_action("Randomise Sliders")
 	var sliders := _get_all_hsliders(self) #finds all sliders
 	#links sliders to this script
 	for slider in sliders:
@@ -142,5 +156,25 @@ func _randomise_sliders():
 		else:
 			rnd_value = (rnd * (maximum - minimum)) + minimum
 		
-		slider.value = rnd_value
-	
+		
+		undo_redo.add_do_method(set_slider_value.bind(slider, rnd_value))
+		undo_redo.add_undo_method(set_slider_value.bind(slider, slider.value))
+		
+	undo_redo.commit_action()
+func set_slider_value(slider: HSlider, value: float) -> void:
+	slider.value = value
+
+func button_changed(value, button) -> void:
+	if button_states[button] != value:
+		undo_redo.create_action("Change Button Value")
+		undo_redo.add_do_method(set_button_value.bind(value, button))
+		undo_redo.add_undo_method(set_button_value.bind(button_states[button], button))
+		undo_redo.commit_action()
+		
+func set_button_value(value, button) -> void:
+	if button is OptionButton:
+		button.selected = value
+	elif button is CheckButton:
+		button.set_pressed_no_signal(value)
+		
+	button_states[button] = value

+ 17 - 0
scenes/Nodes/valueslider.gd

@@ -2,6 +2,9 @@ extends VBoxContainer
 
 @onready var window := $BreakFileMaker
 @onready var editor := window.get_node("AutomationEditor")
+@onready var slider = $HSplitContainer/HSlider
+var undo_redo: UndoRedo
+var previous_value
 signal meta_changed
 
 
@@ -11,6 +14,8 @@ func _ready() -> void:
 	$HSplitContainer/LineEdit.text = str($HSplitContainer/HSlider.value) # initial value
 	$BreakFileMaker.hide()
 	editor.connect("automation_updated", Callable(self, "_on_automation_data_received"))
+	
+	previous_value = slider.value
 
 
 func _on_h_slider_value_changed(value: float) -> void:
@@ -114,3 +119,15 @@ func _on_break_file_maker_close_requested() -> void:
 
 func _on_meta_changed():
 	emit_signal("meta_changed")
+
+
+func _on_h_slider_drag_ended(value_changed: bool) -> void:
+	if slider.value != previous_value:
+		undo_redo.create_action("Change Slider Value")
+		undo_redo.add_do_method(set_slider_value.bind(slider.value))
+		undo_redo.add_undo_method(set_slider_value.bind(previous_value))
+		undo_redo.commit_action()
+		
+func set_slider_value(value: float) -> void:
+	slider.value = value
+	

+ 1 - 0
scenes/Nodes/valueslider.tscn

@@ -88,6 +88,7 @@ offset_right = 304.0
 offset_bottom = 295.0
 text = "Cancel"
 
+[connection signal="drag_ended" from="HSplitContainer/HSlider" to="." method="_on_h_slider_drag_ended"]
 [connection signal="gui_input" from="HSplitContainer/HSlider" to="." method="_on_h_slider_gui_input"]
 [connection signal="value_changed" from="HSplitContainer/HSlider" to="." method="_on_h_slider_value_changed"]
 [connection signal="index_pressed" from="HSplitContainer/HSlider/PopupMenu" to="." method="_on_popup_menu_index_pressed"]

+ 31 - 9
scenes/main/control.tscn

@@ -1,14 +1,16 @@
-[gd_scene load_steps=13 format=3 uid="uid://bcs87y7ptx3ke"]
+[gd_scene load_steps=15 format=3 uid="uid://bcs87y7ptx3ke"]
 
 [ext_resource type="Script" uid="uid://bdlfvuljckmu1" path="res://scenes/main/scripts/control.gd" id="1_2f0aq"]
 [ext_resource type="Script" uid="uid://l2yejnjysupr" path="res://scenes/main/scripts/graph_edit.gd" id="2_3ioqo"]
 [ext_resource type="Texture2D" uid="uid://drn34trxhf80f" path="res://theme/images/open_explore.png" id="3_4na11"]
 [ext_resource type="PackedScene" uid="uid://b0wdj8v6o0wq0" path="res://scenes/menu/menu.tscn" id="3_dtf4o"]
 [ext_resource type="Texture2D" uid="uid://cdwux1smquvpi" path="res://theme/images/logo.png" id="4_3ioqo"]
+[ext_resource type="Texture2D" uid="uid://cjdlu2gooh81h" path="res://theme/images/undo.png" id="4_c1dxk"]
 [ext_resource type="Script" uid="uid://c503vew41pw80" path="res://scenes/main/scripts/color_rect_theme_invert.gd" id="4_mg8al"]
 [ext_resource type="PackedScene" uid="uid://dta7rfalv4uvd" path="res://scenes/main/audio_settings.tscn" id="5_dtf4o"]
 [ext_resource type="Script" uid="uid://cyhaucukdha8a" path="res://scenes/main/scripts/console.gd" id="5_fbaj0"]
 [ext_resource type="Script" uid="uid://wja0lo4nobh1" path="res://scenes/main/scripts/about_menu.gd" id="5_yf4wl"]
+[ext_resource type="Texture2D" uid="uid://bj7u7rhbxvsrq" path="res://theme/images/redo.png" id="5_yxbua"]
 [ext_resource type="Script" uid="uid://dlcbmyu3s2phc" path="res://scenes/menu/search_menu.gd" id="6_fyarh"]
 [ext_resource type="Script" uid="uid://b6r7k326k3vif" path="res://scenes/Nodes/check_for_updates.gd" id="7_1kc3g"]
 [ext_resource type="PackedScene" uid="uid://c1a6elrpk4eks" path="res://scenes/main/settings.tscn" id="8_16l5g"]
@@ -40,6 +42,24 @@ offset_bottom = 71.0
 tooltip_text = "Explore available processes (Ctrl/Cmd + E)"
 icon = ExtResource("3_4na11")
 
+[node name="UndoButton" type="Button" parent="."]
+layout_mode = 0
+offset_left = 329.0
+offset_top = 44.0
+offset_right = 353.0
+offset_bottom = 71.0
+tooltip_text = "Undo (ctrl/cmd + z)"
+icon = ExtResource("4_c1dxk")
+
+[node name="RedoButton" type="Button" parent="."]
+layout_mode = 0
+offset_left = 357.0
+offset_top = 44.0
+offset_right = 381.0
+offset_bottom = 71.0
+tooltip_text = "Redo (ctrl/cmd + y)"
+icon = ExtResource("5_yxbua")
+
 [node name="FileDialog" type="FileDialog" parent="."]
 title = "Open a Directory"
 ok_button_text = "Select Current Folder"
@@ -575,17 +595,17 @@ text = "Stop Running Thread"
 
 [node name="FFTSizeLabel" type="Label" parent="."]
 layout_mode = 0
-offset_left = 348.0
+offset_left = 393.0
 offset_top = 48.0
-offset_right = 420.0
+offset_right = 465.0
 offset_bottom = 67.0
 text = "FFT Size:"
 
 [node name="FFTSize" type="OptionButton" parent="."]
 layout_mode = 0
-offset_left = 425.0
+offset_left = 470.0
 offset_top = 45.0
-offset_right = 504.0
+offset_right = 549.0
 offset_bottom = 72.0
 tooltip_text = "Adjusts the number of analysis points used by the frequency domain processes in this thread. More points give better frequency resolution but worse time resolution."
 item_count = 14
@@ -620,17 +640,17 @@ popup/item_13/id = 13
 
 [node name="FFTOverlapLabel" type="Label" parent="."]
 layout_mode = 0
-offset_left = 517.0
+offset_left = 562.0
 offset_top = 48.0
-offset_right = 617.0
+offset_right = 662.0
 offset_bottom = 67.0
 text = "FFT Overlap:"
 
 [node name="FFTOverlap" type="OptionButton" parent="."]
 layout_mode = 0
-offset_left = 623.0
+offset_left = 668.0
 offset_top = 45.0
-offset_right = 702.0
+offset_right = 747.0
 offset_bottom = 72.0
 tooltip_text = "Adjusts the number of analysis points used by the frequency domain processes in this thread. More points give better frequency resolution but worse time resolution."
 item_count = 4
@@ -653,6 +673,8 @@ popup/item_3/id = 3
 [connection signal="paste_nodes_request" from="GraphEdit" to="GraphEdit" method="_on_paste_nodes_request"]
 [connection signal="popup_request" from="GraphEdit" to="." method="_on_graph_edit_popup_request"]
 [connection signal="button_down" from="Button" to="." method="open_explore"]
+[connection signal="button_down" from="UndoButton" to="." method="_on_undo_button_button_down"]
+[connection signal="button_down" from="RedoButton" to="." method="_on_redo_button_button_down"]
 [connection signal="dir_selected" from="FileDialog" to="." method="_on_file_dialog_dir_selected"]
 [connection signal="close_requested" from="mainmenu" to="." method="_on_mainmenu_close_requested"]
 [connection signal="meta_clicked" from="NoLocationPopup/RichTextLabel" to="." method="_on_rich_text_label_meta_clicked"]

+ 17 - 3
scenes/main/scripts/control.gd

@@ -54,7 +54,7 @@ func _ready() -> void:
 	$LoadDialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
 	$LoadDialog.filters = ["*.thd"]
 	
-	
+	undo_redo.max_steps = 40
 	
 	get_tree().set_auto_accept_quit(false) #disable closing the app with the x and instead handle it internally
 	
@@ -149,6 +149,7 @@ func new_patch():
 	effect.connect("open_help", Callable(open_help, "show_help_for_node"))
 	if effect.has_signal("node_moved"):
 		effect.node_moved.connect(graph_edit._auto_link_nodes)
+	effect.dragged.connect(graph_edit.node_position_changed.bind(effect))
 	effect.position_offset = Vector2(20,80)
 	default_input_node = effect #store a reference to this node to allow for loading into it directly if software launched with a wav file argument
 	
@@ -159,6 +160,7 @@ func new_patch():
 	effect.connect("open_help", Callable(open_help, "show_help_for_node"))
 	if effect.has_signal("node_moved"):
 		effect.node_moved.connect(graph_edit._auto_link_nodes)
+	effect.dragged.connect(graph_edit.node_position_changed.bind(effect))
 	effect.position_offset = Vector2((DisplayServer.screen_get_size().x - 480) / uiscale, 80)
 	graph_edit._register_node_movement() #link nodes for tracking position changes for changes tracking
 	
@@ -309,8 +311,8 @@ func _input(event):
 		simulate_mouse_click()
 		await get_tree().process_frame
 		undo_redo.undo()
-	#elif event.is_action_pressed("redo"):
-		#undo_redo.redo()
+	elif event.is_action_pressed("redo"):
+		undo_redo.redo()
 	elif event.is_action_pressed("save"):
 		if currentfile == "none":
 			savestate = "saveas"
@@ -871,3 +873,15 @@ func _on_fft_size_item_selected(index: int) -> void:
 
 func _on_fft_overlap_item_selected(index: int) -> void:
 	run_thread.fft_overlap = index + 1
+
+
+func _on_undo_button_button_down() -> void:
+	simulate_mouse_click()
+	await get_tree().process_frame
+	undo_redo.undo()
+	
+
+func _on_redo_button_button_down() -> void:
+	simulate_mouse_click()
+	await get_tree().process_frame
+	undo_redo.redo()

+ 170 - 136
scenes/main/scripts/graph_edit.gd

@@ -7,6 +7,7 @@ var multiple_connections
 var selected_nodes = {} #used to track which nodes in the GraphEdit are selected
 var copied_nodes_data = [] #stores node data on ctrl+c
 var copied_connections = [] #stores all connections on ctrl+c
+var last_pasted_nodes = [] #stores last pasted nodes for undo/redo
 var node_data = {} #stores json with all nodes in it
 var valueslider = preload("res://scenes/Nodes/valueslider.tscn") #slider scene for use in nodes
 var addremoveinlets = preload("res://scenes/Nodes/addremoveinlets.tscn") #add remove inlets scene for use in nodes
@@ -53,25 +54,26 @@ func _make_node(command: String, skip_undo_redo := false) -> GraphNode:
 			#var effect: GraphNode = Nodes.get_node(NodePath(command)).duplicate()
 			var effect = Utilities.nodes[command].instantiate()
 			effect.name = command
-			add_child(effect, true)
+			#add node and register it for undo redo
+			control_script.undo_redo.create_action("Add Node")
+			control_script.undo_redo.add_do_method(add_child.bind(effect, true))
+			control_script.undo_redo.add_do_reference(effect)
+			control_script.undo_redo.add_undo_method(delete_node.bind(effect))
+			control_script.undo_redo.commit_action()
+			
 			if command == "outputfile":
 				effect.init() #initialise ui from user prefs
 			effect.connect("open_help", open_help)
 			if effect.has_signal("node_moved"):
 				effect.node_moved.connect(_auto_link_nodes)
+			effect.dragged.connect(node_position_changed.bind(effect))
 			effect.set_position_offset((control_script.effect_position + graph_edit.scroll_offset) / graph_edit.zoom) #set node to current mouse position in graph edit
 			_register_inputs_in_node(effect) #link sliders for changes tracking
 			_register_node_movement() #link nodes for tracking position changes for changes tracking
 
 			control_script.changesmade = true
 
-			if not skip_undo_redo:
-				# Remove node with UndoRedo
-				control_script.undo_redo.create_action("Add Node")
-				control_script.undo_redo.add_undo_method(Callable(graph_edit, "remove_child").bind(effect))
-				control_script.undo_redo.add_undo_method(Callable(effect, "queue_free"))
-				control_script.undo_redo.add_undo_method(Callable(self, "_track_changes"))
-				control_script.undo_redo.commit_action()
+			
 			
 			return effect
 		else: #auto generate node from json
@@ -129,6 +131,9 @@ func _make_node(command: String, skip_undo_redo := false) -> GraphNode:
 						#instance the slider scene
 						var slider = valueslider.instantiate()
 						
+						slider.undo_redo = control_script.undo_redo
+						
+						
 						#get slider text
 						var slider_label = param_data.get("paramname", "")
 						var slider_tooltip  = param_data.get("paramdescription", "")
@@ -174,6 +179,7 @@ func _make_node(command: String, skip_undo_redo := false) -> GraphNode:
 						hslider.max_value = maxrange
 						hslider.step = step
 						hslider.value = value
+						slider.previous_value = value #used for undo redo
 						hslider.exp_edit = exponential
 						
 						#add output duration meta to main if true
@@ -259,6 +265,7 @@ func _make_node(command: String, skip_undo_redo := false) -> GraphNode:
 					elif param_data.get("uitype", "") == "addremoveinlets":
 						var addremove = addremoveinlets.instantiate()
 						addremove.name = "addremoveinlets"
+						addremove.undo_redo = control_script.undo_redo #link to main undo redo
 						
 						#get parameters
 						var min_inlets = param_data.get("minrange", 0)
@@ -312,20 +319,21 @@ func _make_node(command: String, skip_undo_redo := false) -> GraphNode:
 			
 			graphnode.set_script(node_logic)
 			
-			add_child(graphnode, true)
+			control_script.undo_redo.create_action("Add Node")
+			control_script.undo_redo.add_do_method(add_child.bind(graphnode))
+			control_script.undo_redo.add_do_reference(graphnode)
+			control_script.undo_redo.add_undo_method(delete_node.bind(graphnode))
+			control_script.undo_redo.commit_action()
+			graphnode.undo_redo = control_script.undo_redo
 			graphnode.connect("open_help", open_help)
 			graphnode.connect("inlet_removed", Callable(self, "on_inlet_removed"))
 			graphnode.node_moved.connect(_auto_link_nodes)
+			graphnode.dragged.connect(node_position_changed.bind(graphnode))
 			_register_inputs_in_node(graphnode) #link sliders for changes tracking
 			_register_node_movement() #link nodes for tracking position changes for changes tracking
 			
-			if not skip_undo_redo:
-				# Remove node with UndoRedo
-				control_script.undo_redo.create_action("Add Node")
-				control_script.undo_redo.add_undo_method(Callable(graph_edit, "remove_child").bind(graphnode))
-				control_script.undo_redo.add_undo_method(Callable(graphnode, "queue_free"))
-				control_script.undo_redo.add_undo_method(Callable(self, "_track_changes"))
-				control_script.undo_redo.commit_action()
+			
+			
 			
 			return graphnode
 			
@@ -344,6 +352,10 @@ func _on_connection_request(from_node: StringName, from_port: int, to_node: Stri
 	var to_port_type = to_graph_node.get_input_port_type(to_port)
 	var from_port_type = from_graph_node.get_output_port_type(from_port)
 	
+	#skip if the nodes are already connected
+	if is_node_connected(from_node, from_port, to_node, to_port):
+		return
+	
 	#skip if this isnt a valid connection
 	if to_port_type != from_port_type:
 		return
@@ -365,12 +377,19 @@ func _on_connection_request(from_node: StringName, from_port: int, to_node: Stri
 	if from_graph_node.get_meta("command") == "inputfile" and to_graph_node.get_meta("command") == "outputfile":
 		return
 
+	
 	# If no conflict, allow the connection
-	connect_node(from_node, from_port, to_node, to_port)
+	control_script.undo_redo.create_action("Connect Nodes")
+	control_script.undo_redo.add_do_method(connect_node.bind(from_node, from_port, to_node, to_port))
+	control_script.undo_redo.add_undo_method(disconnect_node.bind(from_node, from_port, to_node, to_port))
+	control_script.undo_redo.commit_action()
 	control_script.changesmade = true
 
 func _on_graph_edit_disconnection_request(from_node: StringName, from_port: int, to_node: StringName, to_port: int) -> void:
-	disconnect_node(from_node, from_port, to_node, to_port)
+	control_script.undo_redo.create_action("Disconnect Nodes")
+	control_script.undo_redo.add_do_method(disconnect_node.bind(from_node, from_port, to_node, to_port))
+	control_script.undo_redo.add_undo_method(connect_node.bind(from_node, from_port, to_node, to_port))
+	control_script.undo_redo.commit_action()
 	control_script.changesmade = true
 
 func _on_graph_edit_node_selected(node: Node) -> void:
@@ -386,46 +405,78 @@ func _unhandled_key_input(event: InputEvent) -> void:
 			pass
 
 func _on_graph_edit_delete_nodes_request(nodes: Array[StringName]) -> void:
-	control_script.undo_redo.create_action("Delete Nodes (Undo only)")
-
+	control_script.undo_redo.create_action("Delete Nodes")
+	
+	#Collect node data for undo
 	for node_name in nodes:
 		var node: GraphNode = get_node_or_null(NodePath(node_name))
 		if node and is_instance_valid(node):
 			# Skip output nodes
 			if node.get_meta("command") == "outputfile":
 				continue
-
-			# Store duplicate and state for undo
-			var node_data = node.duplicate()
-			var position = node.position_offset
-
-			# Store all connections for undo
-			var conns := []
-			for con in get_connection_list():
-				if con["to_node"] == node.name or con["from_node"] == node.name:
-					conns.append(con)
-
-			# Delete
-			remove_connections_to_node(node)
-			node.queue_free()
-			control_script.changesmade = true
-
-			# Register undo restore
-			control_script.undo_redo.add_undo_method(Callable(self, "add_child").bind(node_data, true))
-			control_script.undo_redo.add_undo_method(Callable(node_data, "set_position_offset").bind(position))
-			for con in conns:
-				control_script.undo_redo.add_undo_method(Callable(self, "connect_node").bind(
-					con["from_node"], con["from_port"],
-					con["to_node"], con["to_port"]
-				))
-			control_script.undo_redo.add_undo_method(Callable(self, "set_node_selected").bind(node_data, true))
-			control_script.undo_redo.add_undo_method(Callable(self, "_track_changes"))
-			control_script.undo_redo.add_undo_method(Callable(self, "_register_inputs_in_node").bind(node_data))
-			control_script.undo_redo.add_undo_method(Callable(self, "_register_node_movement"))
-
+			
+			#register redo
+			control_script.undo_redo.add_do_method(delete_node.bind(node))
+			#register undo
+			control_script.undo_redo.add_undo_method(restore_node.bind(node))
+			#store a reference to the removed node
+			control_script.undo_redo.add_undo_reference(node)
+			
+			
+	#remove deleted nodes from the selected nodes dictionary
 	selected_nodes = {}
 
+	#get all connections going to the deleted nodes and store them in an array
+	var connections_to_restore = get_connections_to_nodes(nodes)
+			
+	#register undo method for restoring those connections
+	control_script.undo_redo.add_undo_method(restore_connections.bind(connections_to_restore))
 	control_script.undo_redo.commit_action()
+	
+	force_hide_tooltips()
+	
+
+func delete_node(node_to_delete: GraphNode) -> void:
+	remove_connections_to_node(node_to_delete)
+	#remove child instead of queue free keeps a reference to the node in memory (until undo limit is hit) meaning nodes can be restored easily
+	remove_child(node_to_delete)
+	control_script.changesmade = true
+
+func restore_node(node_to_restore: GraphNode) -> void:
+	add_child(node_to_restore)
+	#relink everything
+	if not node_to_restore.is_connected("open_help", open_help):
+		node_to_restore.connect("open_help", open_help)
+	if not node_to_restore.is_connected("node_moved", _auto_link_nodes):
+		node_to_restore.node_moved.connect(_auto_link_nodes)
+	if "undo_redo" in node_to_restore:
+		node_to_restore.undo_redo = control_script.undo_redo
+	for child in node_to_restore.get_children():
+		if "undo_redo" in child:
+			child.undo_redo = control_script.undo_redo
+	if not node_to_restore.is_connected("dragged", node_position_changed):
+		node_to_restore.dragged.connect(node_position_changed.bind(node_to_restore))
+	set_node_selected(node_to_restore, true)
+	_track_changes()
+	_register_inputs_in_node(node_to_restore)
+	_register_node_movement()
+	control_script.changesmade = true
+	
+func get_connections_to_nodes(nodes: Array) -> Array:
+	var connections_to_nodes = []
+	for con in get_connection_list():
+		if (con["from_node"] in nodes or con["to_node"] in nodes) and !connections_to_nodes.has(con):
+			connections_to_nodes.append(con)
+	return connections_to_nodes
+
+func restore_connections(connections_to_restore: Array) -> void:
+	for con in connections_to_restore:
+		var from_node = con["from_node"]
+		var from_port = con["from_port"]
+		var to_node = con["to_node"]
+		var to_port = con["to_port"]
+		if has_node(NodePath(from_node)) and has_node(NodePath(to_node)):
+			connect_node(from_node, from_port, to_node, to_port)
 
 func set_node_selected(node: Node, selected: bool) -> void:
 	selected_nodes[node] = selected
@@ -438,124 +489,98 @@ func remove_connections_to_node(node):
 			
 #copy and paste nodes with vertical offset on paste
 func copy_selected_nodes():
+	if selected_nodes.size() == 0:
+		return
 	copied_nodes_data.clear()
 	copied_connections.clear()
+	
+	var copied_node_names = []
 
-	# Store selected nodes and their slider values
+	# Store selected nodes
 	for node in get_children():
-		# Check if the node is selected and not an 'inputfile' or 'outputfile'
+		# Check if the node is selected and not an 'outputfile'
 		if node is GraphNode and selected_nodes.get(node, false):
 			if node.get_meta("command") == "outputfile":
 				continue  # Skip these nodes
+			
+			#this is throwing errors, i think this is due to it trying to deep copy engine level things it doesn't need such as file dialog elements, seems to work anyway
+			var copied_node = node.duplicate()
+			
+			copied_nodes_data.append(copied_node)
+			copied_node_names.append(node.name)
+
+	copied_connections = get_connections_to_nodes(copied_node_names)
 
-			var node_data = {
-				"name": node.name,
-				"type": node.get_class(),
-				"offset": node.position_offset,
-				"slider_values": {}
-			}
-
-			for child in node.get_children():
-				if child is HSlider or child is VSlider:
-					node_data["slider_values"][child.name] = child.value
-
-			copied_nodes_data.append(node_data)
-
-	# Store connections between selected nodes
-	for conn in get_connection_list():
-		var from_ref = get_node_or_null(NodePath(conn["from_node"]))
-		var to_ref = get_node_or_null(NodePath(conn["to_node"]))
-
-		var is_from_selected = from_ref != null and selected_nodes.get(from_ref, false)
-		var is_to_selected = to_ref != null and selected_nodes.get(to_ref, false)
-
-		# Skip if any of the connected nodes are 'outputfile'
-		if (from_ref != null and from_ref.get_meta("command") == "outputfile") or (to_ref != null and to_ref.get_meta("command") == "outputfile"):
-			continue
-
-		if is_from_selected and is_to_selected:
-			# Store connection as dictionary
-			var conn_data = {
-				"from_node": conn["from_node"],
-				"from_port": conn["from_port"],
-				"to_node": conn["to_node"],
-				"to_port": conn["to_port"]
-			}
-			copied_connections.append(conn_data)
 
 func paste_copied_nodes():
 	if copied_nodes_data.is_empty():
 		return
 
-	var name_map = {}
+	var pasted_connections = copied_connections.duplicate(true)
+	
+	
+	control_script.undo_redo.create_action("Paste Nodes")
+	
 	var pasted_nodes = []
-
-	# Step 1: Find topmost and bottommost Y of copied nodes
+	#Find topmost and bottommost Y of copied nodes and decide where to paste
 	var min_y = INF
 	var max_y = -INF
-	for node_data in copied_nodes_data:
-		var y = node_data["offset"].y
+	for node in copied_nodes_data:
+		var y = node.position_offset.y
 		min_y = min(min_y, y)
 		max_y = max(max_y, y)
 
-	# Step 2: Decide where to paste the group
 	var base_y_offset = max_y + 350  # Pasting below the lowest node
 
 	# Step 3: Paste nodes, preserving vertical layout
-	for node_data in copied_nodes_data:
-		var original_node = get_node_or_null(NodePath(node_data["name"]))
-		if not original_node:
-			continue
-
-		var new_node = original_node.duplicate()
-		new_node.name = node_data["name"] + "_copy_" + str(randi() % 10000)
-
-		var relative_y = node_data["offset"].y - min_y
-		new_node.position_offset = Vector2(
-			node_data["offset"].x,
-			base_y_offset + relative_y
-		)
+	for node in copied_nodes_data:
+		#duplicate the copied node again so it can be pasted more than once and add using restore_node function
+		var pasted_node = node.duplicate()
+		#give the node a random name
+		pasted_node.name = node.name + "_copy_" + str(randi() % 10000) 
+		control_script.undo_redo.add_do_method(restore_node.bind(pasted_node))
+		control_script.undo_redo.add_do_reference(pasted_node) 
+		#adjust the offset of the pasted node to be below the copied node
+		var relative_y = pasted_node.position_offset.y - min_y
+		pasted_node.position_offset.y = base_y_offset + relative_y
 		
+		for con in pasted_connections:
+			if con["from_node"] == node.name:
+				con["from_node"] = pasted_node.name
+				print(pasted_node.name)
+			if con["to_node"] == node.name:
+				con["to_node"] = pasted_node.name
+		
+		control_script.undo_redo.add_undo_method(delete_node.bind(pasted_node))
+		pasted_nodes.append(pasted_node)
+	
+	control_script.undo_redo.add_do_method(restore_connections.bind(pasted_connections))
 
-		# Restore sliders
-		for child in new_node.get_children():
-			if child.name in node_data["slider_values"]:
-				child.value = node_data["slider_values"][child.name]
-
-		add_child(new_node, true)
-		new_node.connect("open_help", open_help)
-		new_node.node_moved.connect(_auto_link_nodes)
-		_register_inputs_in_node(new_node) #link sliders for changes tracking
-		_register_node_movement() # link nodes for changes tracking
-		name_map[node_data["name"]] = new_node.name
-		pasted_nodes.append(new_node)
-
-
-	# Step 4: Reconnect new nodes
-	for conn_data in copied_connections:
-		var new_from = name_map.get(conn_data["from_node"], null)
-		var new_to = name_map.get(conn_data["to_node"], null)
-
-		if new_from and new_to:
-			connect_node(new_from, conn_data["from_port"], new_to, conn_data["to_port"])
-
-	# Step 5: Select pasted nodes
+	control_script.undo_redo.commit_action()
+	
+	force_hide_tooltips()
+	
+	#Select pasted nodes
 	for pasted_node in pasted_nodes:
+		selected_nodes.clear()
 		set_selected(pasted_node)
 		selected_nodes[pasted_node] = true
 	
 	control_script.changesmade = true
 	
-	# Remove node with UndoRedo
-	control_script.undo_redo.create_action("Paste Nodes")
-	for pasted_node in pasted_nodes:
-		control_script.undo_redo.add_undo_method(Callable(self, "remove_child").bind(pasted_node))
-		control_script.undo_redo.add_undo_method(Callable(pasted_node, "queue_free"))
-		control_script.undo_redo.add_undo_method(Callable(self, "remove_connections_to_node").bind(pasted_node))
-		control_script.undo_redo.add_undo_method(Callable(self, "_track_changes"))
-	control_script.undo_redo.commit_action()
+func force_hide_tooltips():
+	#very janky fix that makes and removes a popup in one frame to force the engine to hide all visible popups to stop popups getting stuck
+	#seems to be more reliable that faking a middle mouse click
+	var popup := Popup.new()
+	add_child(popup)
+	popup.size = Vector2(1,1)
+	popup.transparent_bg = true
+	popup.borderless = true
+	popup.unresizable = true
+	await get_tree().process_frame
+	popup.popup()
+	popup.queue_free()
 	
-
 #functions for tracking changes for save state detection
 func _register_inputs_in_node(node: Node):
 	#tracks input to nodes sliders and codeedit to track if patch is saved
@@ -811,3 +836,12 @@ func _same_port_type(from: String, from_port: int, to: String, to_port: int) ->
 			return false
 	else:
 		return false
+		
+func node_position_changed(from: Vector2, to: Vector2, node: Node) -> void:
+	control_script.undo_redo.create_action("Move Node")
+	control_script.undo_redo.add_do_method(move_node.bind(node, to))
+	control_script.undo_redo.add_undo_method(move_node.bind(node, from))
+	control_script.undo_redo.commit_action()
+
+func move_node(node: Node, to: Vector2) -> void:
+	node.position_offset = to

二进制
theme/images/redo.png


+ 34 - 0
theme/images/redo.png.import

@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bj7u7rhbxvsrq"
+path="res://.godot/imported/redo.png-bf1ca2ad6ca620f0d6fdcc0d1b8140c5.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://theme/images/redo.png"
+dest_files=["res://.godot/imported/redo.png-bf1ca2ad6ca620f0d6fdcc0d1b8140c5.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1

二进制
theme/images/undo.png


+ 34 - 0
theme/images/undo.png.import

@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cjdlu2gooh81h"
+path="res://.godot/imported/undo.png-484eb395cb0f417e87e157c63838458d.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://theme/images/undo.png"
+dest_files=["res://.godot/imported/undo.png-484eb395cb0f417e87e157c63838458d.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1