From a3d76594a57226489a266c35eae69e31a769f72d Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Sat, 14 Sep 2024 00:42:35 -0500 Subject: [PATCH] HUGE refactor of Drag and Drop (for clips and transitions), now supporting multi-selection for Files. They are added in the order they are selected. --- src/timeline/js/controllers.js | 174 ++++++++++++++++++---------- src/windows/views/files_listview.py | 58 +++++++--- src/windows/views/files_treeview.py | 58 +++++++--- src/windows/views/timeline.py | 112 +++++++++++------- 4 files changed, 273 insertions(+), 129 deletions(-) diff --git a/src/timeline/js/controllers.js b/src/timeline/js/controllers.js index 3554c3a38..eec0e8d85 100644 --- a/src/timeline/js/controllers.js +++ b/src/timeline/js/controllers.js @@ -911,44 +911,51 @@ App.controller("TimelineCtrl", function ($scope) { } }; - // Get JSON of most recent item (used by Qt) - $scope.updateRecentItemJSON = function (item_type, item_id, item_tid) { +// Get JSON of most recent items (used by Qt) +$scope.updateRecentItemJSON = function (item_type, item_ids, item_tid) { + // Ensure item_ids is an array for consistency + item_ids = JSON.parse(item_ids); + // Iterate through each item_id + item_ids.forEach(function (item_id) { // Find item in JSON var item_object = null; if (item_type === "clip") { item_object = findElement($scope.project.clips, "id", item_id); - } - else if (item_type === "transition") { + } else if (item_type === "transition") { item_object = findElement($scope.project.effects, "id", item_id); - } - else { - // Bail out if no id found + } else { + // Bail out if no item_type matches + console.error("Invalid item_type: ", item_type); return; } + // Get recent move data + var element_id = item_type + "_" + item_id; + var top = bounding_box.move_clips[element_id].top; + var left = bounding_box.move_clips[element_id].left; + // Get position of item (snapped to FPS grid) - var clip_position = snapToFPSGridTime($scope, pixelToTime($scope, parseFloat(bounding_box.left))); + var clip_position = snapToFPSGridTime($scope, pixelToTime($scope, parseFloat(left))); // Get the nearest track var layer_num = 0; - var nearest_track = findTrackAtLocation($scope, bounding_box.top); + var nearest_track = findTrackAtLocation($scope, top); if (nearest_track !== null) { layer_num = nearest_track.number; } - // update scope with final position of items + // Update scope with final position of the item $scope.$apply(function () { - // update item + // Update item with new position and layer item_object.position = clip_position; item_object.layer = layer_num; }); - // update clip in Qt (very important =) + // Update clip or transition in Qt (very important) if (item_type === "clip") { timeline.update_clip_data(JSON.stringify(item_object), true, true, false, item_tid); - } - else if (item_type === "transition") { + } else if (item_type === "transition") { timeline.update_transition_data(JSON.stringify(item_object), true, false, item_tid); } @@ -963,59 +970,89 @@ App.controller("TimelineCtrl", function ($scope) { if ($scope.Qt && missing_transition_details !== null) { timeline.add_missing_transition(JSON.stringify(missing_transition_details)); } - // Remove manual move stylesheet - if (bounding_box.element) { - bounding_box.element.removeClass("manual-move"); - } + }); - // Remove CSS class (after the drag) - bounding_box = {}; - }; - - // Init bounding boxes for manual move - $scope.startManualMove = function (item_type, item_id) { - // Select the item - $scope.$apply(function () { - if (item_type === "clip") { - $scope.selectClip(item_id, true); - } - else if (item_type === "transition") { - $scope.selectTransition(item_id, true); - } + // Remove manual move stylesheet + if (bounding_box.elements) { + bounding_box.elements.each(function () { + $(this).removeClass("manual-move"); }); + } + + // Reset bounding box + bounding_box = {}; +}; + +// Init bounding boxes for manual move +$scope.startManualMove = function (item_type, item_ids) { + console.log("Start manual move: " + item_ids); + // Ensure item_ids is an array for consistency + item_ids = JSON.parse(item_ids); + + // Select new objects (and unselect others) + $scope.$apply(function () { + $scope.selectClip("", true); + $scope.selectTransition("", true); - // Select new clip object (and unselect others) - // This needs to be done inline due to async issues with the - // above calls to selectClip/selectTransition for (var clip_index = 0; clip_index < $scope.project.clips.length; clip_index++) { - $scope.project.clips[clip_index].selected = $scope.project.clips[clip_index].id === item_id; + $scope.project.clips[clip_index].selected = item_ids.includes($scope.project.clips[clip_index].id); } - // Select new transition object (and unselect others) + // Select new transition objects (and unselect others) for (var tran_index = 0; tran_index < $scope.project.effects.length; tran_index++) { - $scope.project.effects[tran_index].selected = $scope.project.effects[tran_index].id === item_id; + $scope.project.effects[tran_index].selected = item_ids.includes($scope.project.effects[tran_index].id); } + }); - // JQuery selector for element (clip or transition) - var element_id = "#" + item_type + "_" + item_id; - - // Init bounding box - bounding_box = {}; - setBoundingBox($scope, $(element_id)); - - // Init some variables to track the changing position - bounding_box.previous_x = bounding_box.left; - bounding_box.previous_y = bounding_box.top; - bounding_box.offset_x = 0; - bounding_box.offset_y = 0; - bounding_box.element = $(element_id); - bounding_box.track_position = 0; - - // Set z-order to be above other clips/transitions - if (item_type !== "os_drop" && bounding_box.element) { - bounding_box.element.addClass("manual-move"); - } - }; + // Prepare to store clip positions + var scrolling_tracks = $("#scrolling_tracks"); + var vert_scroll_offset = scrolling_tracks.scrollTop(); + var horz_scroll_offset = scrolling_tracks.scrollLeft(); + + // Init bounding box + bounding_box = {}; + + // Set bounding box that contains all selected clips/transitions + var selectedClips = $(".ui-selected"); + selectedClips.each(function () { + // Send each selected clip or transition to the bounding box builder + setBoundingBox($scope, $(this)); // Pass the element and scope to setBoundingBox + }); + + // After calling setBoundingBox, now initialize the start_clips and move_clips + bounding_box.start_clips = {}; + bounding_box.move_clips = {}; + + // Iterate again to set start_clips and move_clips properties + selectedClips.each(function () { + var element_id = $(this).attr("id"); + + // Store initial positions after setBoundingBox is called + bounding_box.start_clips[element_id] = { + "top": $(this).position().top + vert_scroll_offset, + "left": $(this).position().left + horz_scroll_offset + }; + bounding_box.move_clips[element_id] = { + "top": $(this).position().top + vert_scroll_offset, + "left": $(this).position().left + horz_scroll_offset + }; + }); + + // Init some variables to track the changing position + bounding_box.previous_x = bounding_box.left; + bounding_box.previous_y = bounding_box.top; + bounding_box.offset_x = 0; + bounding_box.offset_y = 0; + bounding_box.elements = selectedClips; + bounding_box.track_position = 0; + + // Set z-order to be above other clips/transitions + if (item_type !== "os_drop") { + selectedClips.each(function () { + $(this).addClass("manual-move"); + }); + } +}; $scope.moveItem = function (x, y) { // Adjust x and y to account for the scroll position @@ -1038,10 +1075,23 @@ $scope.moveItem = function (x, y) { bounding_box.previous_x = results.position.left; bounding_box.previous_y = results.position.top; - // Update the element's position - if (bounding_box.element) { - bounding_box.element.css("left", results.position.left + "px"); - bounding_box.element.css("top", results.position.top + "px"); + // Apply snapping results to the first clip and calculate the delta for the remaining clips + var delta_x = results.position.left - bounding_box.start_clips[bounding_box.elements.first().attr("id")].left; + var delta_y = results.position.top - bounding_box.start_clips[bounding_box.elements.first().attr("id")].top; + + // Update the position of each selected element by applying the delta + if (bounding_box.elements) { + bounding_box.elements.each(function () { + var element_id = $(this).attr("id"); + // Apply x_offset and y_offset to the starting position of each selected clip + var new_left = bounding_box.start_clips[element_id].left + delta_x; + var new_top = bounding_box.start_clips[element_id].top + delta_y; + bounding_box.move_clips[element_id]["top"] = new_top; + bounding_box.move_clips[element_id]["left"] = new_left; + // Set the new position for the element + $(this).css("left", new_left + "px"); + $(this).css("top", new_top + "px"); + }); } }; diff --git a/src/windows/views/files_listview.py b/src/windows/views/files_listview.py index 92f852706..22264d444 100644 --- a/src/windows/views/files_listview.py +++ b/src/windows/views/files_listview.py @@ -27,7 +27,7 @@ """ from PyQt5.QtCore import QSize, Qt, QPoint, QRegExp -from PyQt5.QtGui import QDrag, QCursor +from PyQt5.QtGui import QDrag, QCursor, QPixmap, QPainter from PyQt5.QtWidgets import QListView, QAbstractItemView from classes import info @@ -103,24 +103,54 @@ def startDrag(self, supportedActions): # Get first column indexes for all selected rows selected = self.selectionModel().selectedRows(0) - # Get image of current item - current = self.selectionModel().currentIndex() - if not current.isValid() and selected: - current = selected[0] - - if not current.isValid(): + # Check if there are any selected items + if not selected: log.warning("No draggable items found in model!") return False - # Get icon from column 0 on same row as current item - icon = current.sibling(current.row(), 0).data(Qt.DecorationRole) + # Get icons from up to 3 selected items + icons = [] + for i in range(min(3, len(selected))): + current = selected[i] + icon = current.sibling(current.row(), 0).data(Qt.DecorationRole) + if icon: + icons.append(icon.pixmap(self.drag_item_size)) + + # If no icons were retrieved, abort the drag + if not icons: + log.warning("No valid icons found for dragging!") + return False + + # Calculate the total width of the composite pixmap including gaps + gap = 1 # 1 pixel gap between icons + total_width = (self.drag_item_size.width() * len(icons)) + (gap * (len(icons) - 1)) + + # Create a composite pixmap to hold the icons in a row + composite_pixmap = QPixmap(total_width, self.drag_item_size.height()) + composite_pixmap.fill(Qt.transparent) # Start with a transparent background + + # Use a QPainter to draw the icons in a row with 1 pixel gap between them + painter = QPainter(composite_pixmap) + for idx, icon_pixmap in enumerate(icons): + x_offset = idx * (self.drag_item_size.width() + gap) # Position each icon with a gap + painter.drawPixmap(int(x_offset), 0, icon_pixmap) + painter.end() - # Start drag operation + # Start the drag operation drag = QDrag(self) - drag.setMimeData(self.model().mimeData(selected)) - drag.setPixmap(icon.pixmap(self.drag_item_size)) - drag.setHotSpot(self.drag_item_center) - drag.exec_() + + # Combine all selected items into the mime data + mime_data = self.model().mimeData(selected) + drag.setMimeData(mime_data) + + # Set the composite pixmap for the drag operation + drag.setPixmap(composite_pixmap) + + # Set the hot spot to the center of the composite pixmap + drag.setHotSpot(composite_pixmap.rect().center()) + + # Execute the drag operation + drag.exec_(supportedActions) # Without defining this method, the 'copy' action doesn't show with cursor def dragMoveEvent(self, event): diff --git a/src/windows/views/files_treeview.py b/src/windows/views/files_treeview.py index 6ab825a9f..ac077fa6f 100644 --- a/src/windows/views/files_treeview.py +++ b/src/windows/views/files_treeview.py @@ -30,7 +30,7 @@ import os from PyQt5.QtCore import QSize, Qt, QPoint -from PyQt5.QtGui import QDrag, QCursor +from PyQt5.QtGui import QDrag, QCursor, QPixmap, QPainter from PyQt5.QtWidgets import QTreeView, QAbstractItemView, QSizePolicy, QHeaderView from classes import info @@ -104,24 +104,54 @@ def startDrag(self, supportedActions): # Get first column indexes for all selected rows selected = self.selectionModel().selectedRows(0) - # Get image of current item - current = self.selectionModel().currentIndex() - if not current.isValid() and selected: - current = selected[0] - - if not current.isValid(): + # Check if there are any selected items + if not selected: log.warning("No draggable items found in model!") return False - # Get icon from column 0 on same row as current item - icon = current.sibling(current.row(), 0).data(Qt.DecorationRole) + # Get icons from up to 3 selected items + icons = [] + for i in range(min(3, len(selected))): + current = selected[i] + icon = current.sibling(current.row(), 0).data(Qt.DecorationRole) + if icon: + icons.append(icon.pixmap(self.drag_item_size)) + + # If no icons were retrieved, abort the drag + if not icons: + log.warning("No valid icons found for dragging!") + return False + + # Calculate the total width of the composite pixmap including gaps + gap = 1 # 1 pixel gap between icons + total_width = (self.drag_item_size.width() * len(icons)) + (gap * (len(icons) - 1)) + + # Create a composite pixmap to hold the icons in a row + composite_pixmap = QPixmap(total_width, self.drag_item_size.height()) + composite_pixmap.fill(Qt.transparent) # Start with a transparent background + + # Use a QPainter to draw the icons in a row with 1 pixel gap between them + painter = QPainter(composite_pixmap) + for idx, icon_pixmap in enumerate(icons): + x_offset = idx * (self.drag_item_size.width() + gap) # Position each icon with a gap + painter.drawPixmap(int(x_offset), 0, icon_pixmap) + painter.end() - # Start drag operation + # Start the drag operation drag = QDrag(self) - drag.setMimeData(self.model().mimeData(selected)) - drag.setPixmap(icon.pixmap(self.drag_item_size)) - drag.setHotSpot(self.drag_item_center) - drag.exec_() + + # Combine all selected items into the mime data + mime_data = self.model().mimeData(selected) + drag.setMimeData(mime_data) + + # Set the composite pixmap for the drag operation + drag.setPixmap(composite_pixmap) + + # Set the hot spot to the center of the composite pixmap + drag.setHotSpot(composite_pixmap.rect().center()) + + # Execute the drag operation + drag.exec_(supportedActions) # Without defining this method, the 'copy' action doesn't show with cursor def dragMoveEvent(self, event): diff --git a/src/windows/views/timeline.py b/src/windows/views/timeline.py index f6a579828..c8b2bf2b9 100644 --- a/src/windows/views/timeline.py +++ b/src/windows/views/timeline.py @@ -2993,26 +2993,60 @@ def update_zoom(self, newScale): # An item is being dragged onto the timeline (mouse is entering the timeline now) def dragEnterEvent(self, event): + # Clear previous selections + self.ClearAllSelections() + get_app().processEvents() # If a plain text drag accept if not self.new_item and not event.mimeData().hasUrls() and event.mimeData().html(): - # get type of dropped data + # Get type of dropped data self.item_type = event.mimeData().html() # Track that a new item is being 'added' self.new_item = True + self.item_ids = [] # Get the mime data (i.e. list of files, list of transitions, etc...) + # Assuming the data contains a list of clips or transitions data = json.loads(event.mimeData().text()) pos = event.posF() - # create the item - if self.item_type == "clip": - self.addClip(data, pos) - elif self.item_type == "transition": - self.addTransition(data, pos) + # Ensure data is a list (to support multiple items) + if not isinstance(data, list): + data = [data] + + # Get FPS from project and calculate the FPS float value + fps_float = float(get_app().project.get("fps")["num"]) / float(get_app().project.get("fps")["den"]) + current_scale = float(get_app().project.get("scale") or 15.0) + pixels_per_second = 100.0 / current_scale + snap_to_grid = lambda t: round(t * fps_float) / fps_float + + # Snap the initial position (X value) to the FPS grid + pos.setX(snap_to_grid(pos.x())) - # accept all events, even if a new clip is not being added + # Create the items (multiple clips or transitions) + for item_id in data: + if self.item_type == "clip": + file = File.get(id=item_id) + if not file: + continue + # Calculate the duration in frames, snapping it to the FPS grid + duration_in_seconds = float(file.data["duration"]) + if file.data["media_type"] == "image": + duration_in_seconds = get_app().get_settings().get("default-image-length") + duration = snap_to_grid(duration_in_seconds) + + # Add Clip + self.addClip(item_id, pos) + pos += QPointF(duration * pixels_per_second, 0) + + elif self.item_type == "transition": + self.addTransition(item_id, pos) + duration_in_seconds = get_app().get_settings().get("default-transition-length") + duration = snap_to_grid(duration_in_seconds) + pos += QPointF(duration * pixels_per_second, 0) + + # Accept all events, even if a new clip is not being added event.accept() # Accept a plain file URL (from the OS) @@ -3021,19 +3055,18 @@ def dragEnterEvent(self, event): self.new_item = True self.item_type = "os_drop" - # accept event + # Accept event event.accept() # Add Clip - def addClip(self, data, event_position): + def addClip(self, file_id, event_position): # Callback function, to actually add the clip object - def callback(self, data, callback_data): + def callback(self, file_id, callback_data): js_position = callback_data.get('position', 0.0) js_nearest_track = callback_data.get('track', 0) # Search for matching file in project data (if any) - file_id = data[0] file = File.get(id=file_id) if not file: @@ -3083,15 +3116,15 @@ def callback(self, data, callback_data): self.update_clip_data(new_clip, only_basic_props=False, transaction_id=tid) # temp hold item_id - self.item_id = new_clip.get('id') + self.item_ids.append(new_clip.get('id')) self.item_tid = tid # Init javascript bounding box (for snapping support) - self.run_js(JS_SCOPE_SELECTOR + ".startManualMove('{}', '{}');".format(self.item_type, self.item_id)) + self.run_js(JS_SCOPE_SELECTOR + ".startManualMove('{}', '{}');".format(self.item_type, json.dumps(self.item_ids))) # Find position from javascript self.run_js(JS_SCOPE_SELECTOR + ".getJavaScriptPosition({}, {});" - .format(event_position.x(), event_position.y()), partial(callback, self, data)) + .format(event_position.x(), event_position.y()), partial(callback, self, file_id)) @pyqtSlot(list) def ScrollbarChanged(self, new_positions): @@ -3112,10 +3145,10 @@ def resizeTimeline(self, new_duration): log.debug("Duration unchanged. Not updating") # Add Transition - def addTransition(self, file_ids, event_position): + def addTransition(self, file_path, event_position): # Callback function, to actually add the transition object - def callback(self, file_ids, callback_data): + def callback(self, file_path, callback_data): js_position = callback_data.get('position', 0.0) js_nearest_track = callback_data.get('track', 0) @@ -3127,7 +3160,7 @@ def callback(self, file_ids, callback_data): tid = self.get_uuid() # Open up QtImageReader for transition Image - transition_reader = openshot.QtImageReader(file_ids[0]) + transition_reader = openshot.QtImageReader(file_path) brightness = openshot.Keyframe() brightness.AddPoint(1, 1.0, openshot.BEZIER) @@ -3142,7 +3175,7 @@ def callback(self, file_ids, callback_data): "type": "Mask", "position": js_position, "start": 0, - "end": 10, + "end": get_app().get_settings().get("default-transition-length"), "brightness": json.loads(brightness.Json()), "contrast": json.loads(contrast.Json()), "reader": json.loads(transition_reader.Json()), @@ -3153,16 +3186,16 @@ def callback(self, file_ids, callback_data): self.update_transition_data(transitions_data, only_basic_props=False, transaction_id=tid) # temp keep track of id - self.item_id = transitions_data.get('id') + self.item_ids.append(transitions_data.get('id')) self.item_tid = tid # Init javascript bounding box (for snapping support) - self.run_js(JS_SCOPE_SELECTOR + ".startManualMove('{}','{}');".format(self.item_type, self.item_id)) + self.run_js(JS_SCOPE_SELECTOR + ".startManualMove('{}','{}');".format(self.item_type, json.dumps(self.item_ids))) # Find position from javascript self.run_js(JS_SCOPE_SELECTOR + ".getJavaScriptPosition({}, {});" .format(event_position.x(), event_position.y()), - partial(callback, self, file_ids)) + partial(callback, self, file_path)) # Add Effect def addEffect(self, effect_names, event_position): @@ -3252,7 +3285,7 @@ def dragMoveEvent(self, event): # Drop an item on the timeline def dropEvent(self, event): - log.info("Dropping item on timeline - item_id: %s, item_type: %s" % (self.item_id, self.item_type)) + log.info("Dropping item on timeline - item_ids: %s, item_type: %s" % (self.item_ids, self.item_type)) # Accept event event.accept() @@ -3260,10 +3293,10 @@ def dropEvent(self, event): # Get position of cursor pos = event.posF() - if self.item_type in ["clip", "transition"] and self.item_id: + if self.item_type in ["clip", "transition"] and self.item_ids: # Update most recent clip self.run_js(JS_SCOPE_SELECTOR + ".updateRecentItemJSON('{}', '{}', '{}');".format(self.item_type, - self.item_id, + json.dumps(self.item_ids), self.item_tid)) elif self.item_type == "effect": @@ -3310,13 +3343,13 @@ def dropEvent(self, event): duration = snap_to_grid(duration_in_seconds) # Add clip at snapped position and increment position for the next clip - self.addClip([file.id], pos) + self.addClip(file.id, pos) pos += QPointF(duration * pixels_per_second, 0.0) # Clear new clip self.new_item = False self.item_type = None - self.item_id = None + self.item_ids = [] # Update the preview and reselct current frame in properties self.window.refreshFrameSignal.emit() @@ -3330,24 +3363,25 @@ def dragLeaveEvent(self, event): event.accept() # Clear selected clips - self.window.removeSelection(self.item_id, self.item_type) + for item_id in self.item_ids: + self.window.removeSelection(item_id, self.item_type) - if self.item_type == "clip": - # Delete dragging clip - clips = Clip.filter(id=self.item_id) - for c in clips: - c.delete() + if self.item_type == "clip": + # Delete dragging clip + clips = Clip.filter(id=item_id) + for c in clips: + c.delete() - elif self.item_type == "transition": - # Delete dragging transitions - transitions = Transition.filter(id=self.item_id) - for t in transitions: - t.delete() + elif self.item_type == "transition": + # Delete dragging transitions + transitions = Transition.filter(id=item_id) + for t in transitions: + t.delete() # Clear new clip self.new_item = False self.item_type = None - self.item_id = None + self.item_ids = None self.item_tid = None def redraw_audio_onTimeout(self): @@ -3424,7 +3458,7 @@ def __init__(self, window): # Init New clip self.new_item = False self.item_type = None - self.item_id = None + self.item_ids = [] self.item_tid = None # Delayed zoom audio redraw