From dbe3eb12be5375831581630ff8c719100f476f8d Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 9 Jul 2026 16:44:49 +0100 Subject: [PATCH] Support HTML5 drag-and-drop and drag modifier keys `Node#drag_to` drove only real mouse events, which never fire the HTML5 drag sequence (dragstart/dragover/drop/dragend + DataTransfer), so suites exercising HTML5 drag-and-drop had to fall back to Selenium. Route drag_to between two paths after the initial mouse-down: a legacy path (real Ferrum mouse events, now applying held modifier keys via the public mouse modifier bitfield) and an HTML5 path (a JS emulation dispatching the drag events with a shared DataTransfer). Detection mirrors Capybara: a draggable source or ancestor takes the HTML5 path, otherwise legacy. The emulation is ported from Capybara's Selenium Html5Drag extension and lives in javascripts/drag.js. Un-skips the 15 Capybara shared drag specs (12 HTML5 + 3 mouse modifier). Uses only public Ferrum API. Element#drop (files/strings) remains out of scope. Closes #314 --- lib/capybara/cuprite/browser.rb | 30 +++++- lib/capybara/cuprite/javascripts/drag.js | 118 ++++++++++++++++++++++ lib/capybara/cuprite/javascripts/index.js | 19 ++++ lib/capybara/cuprite/node.rb | 5 +- spec/spec_helper.rb | 15 --- 5 files changed, 167 insertions(+), 20 deletions(-) create mode 100644 lib/capybara/cuprite/javascripts/drag.js diff --git a/lib/capybara/cuprite/browser.rb b/lib/capybara/cuprite/browser.rb index 9d04f44..192d312 100644 --- a/lib/capybara/cuprite/browser.rb +++ b/lib/capybara/cuprite/browser.rb @@ -7,6 +7,8 @@ module Cuprite class Browser < Ferrum::Browser extend Forwardable + DRAG_HTML5_JS = File.read(File.expand_path("javascripts/drag.js", __dir__)) + delegate %i[send_keys select set hover trigger before_click switch_to_frame find_modal accept_confirm dismiss_confirm accept_prompt dismiss_prompt reset_modals] => :page @@ -139,7 +141,14 @@ def source raise NotImplementedError end - def drag(node, other, steps, delay = nil, scroll = true) + def drag(node, other, drop_modifiers, options = {}) + steps = options.fetch(:steps, 1) + delay = options[:delay] + scroll = options.fetch(:scroll, true) + html5 = options[:html5] + + execute("_cuprite.dragMousedownTracker()") + node.scroll_into_view if scroll x1, y1 = node.find_position @@ -147,11 +156,24 @@ def drag(node, other, steps, delay = nil, scroll = true) mouse.down sleep delay if delay - other.scroll_into_view if scroll + html5 = !evaluate_on(node: node, expression: "_cuprite.legacyDragCheck(this)") if html5.nil? + + if html5 + drag_html5(node, other, drop_modifiers, delay) + else + modifiers = keyboard.modifiers(drop_modifiers) - x2, y2 = other.find_position - mouse.move(x: x2, y: y2, steps: steps) + other.scroll_into_view if scroll + x2, y2 = other.find_position + mouse.move(x: x2, y: y2, steps: steps) + + mouse.up(modifiers: modifiers) + end + end + def drag_html5(node, other, drop_modifiers, delay) + keys = drop_modifiers.map(&:to_s) + evaluate_async(DRAG_HTML5_JS, timeout, node, other, (delay || 0.05) * 1000, keys) mouse.up end diff --git a/lib/capybara/cuprite/javascripts/drag.js b/lib/capybara/cuprite/javascripts/drag.js new file mode 100644 index 0000000..03de508 --- /dev/null +++ b/lib/capybara/cuprite/javascripts/drag.js @@ -0,0 +1,118 @@ +// HTML5 drag-and-drop emulation. +// +// Ported near-verbatim from Capybara's Selenium driver +// (capybara/selenium/extensions/html5_drag.rb, HTML5_DRAG_DROP_SCRIPT) so +// Cuprite matches Capybara's HTML5 drag behaviour and its shared specs. Kept +// close to the source to ease future syncs; known upstream quirks (rectPt.top +// in pointOnRect, undeclared `key`, callback.call(true)) are preserved +// deliberately. See https://github.com/rubycdp/cuprite/issues/314. + +function rectCenter(rect){ + return new DOMPoint( + (rect.left + rect.right)/2, + (rect.top + rect.bottom)/2 + ); +} + +function pointOnRect(pt, rect) { + var rectPt = rectCenter(rect); + var slope = (rectPt.y - pt.y) / (rectPt.x - pt.x); + + if (pt.x <= rectPt.x) { // left side + var minXy = slope * (rect.left - pt.x) + pt.y; + if (rect.top <= minXy && minXy <= rect.bottom) + return new DOMPoint(rect.left, minXy); + } + + if (pt.x >= rectPt.x) { // right side + var maxXy = slope * (rect.right - pt.x) + pt.y; + if (rect.top <= maxXy && maxXy <= rect.bottom) + return new DOMPoint(rect.right, maxXy); + } + + if (pt.y <= rectPt.y) { // top side + var minYx = (rectPt.top - pt.y) / slope + pt.x; + if (rect.left <= minYx && minYx <= rect.right) + return new DOMPoint(minYx, rect.top); + } + + if (pt.y >= rectPt.y) { // bottom side + var maxYx = (rect.bottom - pt.y) / slope + pt.x; + if (rect.left <= maxYx && maxYx <= rect.right) + return new DOMPoint(maxYx, rect.bottom); + } + + return new DOMPoint(pt.x,pt.y); +} + +function dragEnterTarget() { + target.scrollIntoView({behavior: 'instant', block: 'center', inline: 'center'}); + var targetRect = target.getBoundingClientRect(); + var sourceCenter = rectCenter(source.getBoundingClientRect()); + + for (var i = 0; i < drop_modifier_keys.length; i++) { + key = drop_modifier_keys[i]; + if (key == "control"){ + key = "ctrl" + } + opts[key + 'Key'] = true; + } + + var dragEnterEvent = new DragEvent('dragenter', opts); + target.dispatchEvent(dragEnterEvent); + + // fire 2 dragover events to simulate dragging with a direction + var entryPoint = pointOnRect(sourceCenter, targetRect) + var dragOverOpts = Object.assign({clientX: entryPoint.x, clientY: entryPoint.y}, opts); + var dragOverEvent = new DragEvent('dragover', dragOverOpts); + target.dispatchEvent(dragOverEvent); + window.setTimeout(dragOnTarget, step_delay); +} + +function dragOnTarget() { + var targetCenter = rectCenter(target.getBoundingClientRect()); + var dragOverOpts = Object.assign({clientX: targetCenter.x, clientY: targetCenter.y}, opts); + var dragOverEvent = new DragEvent('dragover', dragOverOpts); + target.dispatchEvent(dragOverEvent); + window.setTimeout(dragLeave, step_delay, dragOverEvent.defaultPrevented, dragOverOpts); +} + +function dragLeave(drop, dragOverOpts) { + var dragLeaveOptions = Object.assign({}, opts, dragOverOpts); + var dragLeaveEvent = new DragEvent('dragleave', dragLeaveOptions); + target.dispatchEvent(dragLeaveEvent); + if (drop) { + var dropEvent = new DragEvent('drop', dragLeaveOptions); + target.dispatchEvent(dropEvent); + } + var dragEndEvent = new DragEvent('dragend', dragLeaveOptions); + source.dispatchEvent(dragEndEvent); + callback.call(true); +} + +var source = arguments[0], + target = arguments[1], + step_delay = arguments[2], + drop_modifier_keys = arguments[3], + callback = arguments[4]; + +var dt = new DataTransfer(); +var opts = { cancelable: true, bubbles: true, dataTransfer: dt }; + +while (source && !source.draggable) { + source = source.parentElement; +} + +if (source.tagName == 'A'){ + dt.setData('text/uri-list', source.href); + dt.setData('text', source.href); +} +if (source.tagName == 'IMG'){ + dt.setData('text/uri-list', source.src); + dt.setData('text', source.src); +} + +var dragEvent = new DragEvent('dragstart', opts); +source.dispatchEvent(dragEvent); + +window.setTimeout(dragEnterTarget, step_delay); diff --git a/lib/capybara/cuprite/javascripts/index.js b/lib/capybara/cuprite/javascripts/index.js index 837ee78..7c4a041 100644 --- a/lib/capybara/cuprite/javascripts/index.js +++ b/lib/capybara/cuprite/javascripts/index.js @@ -528,6 +528,25 @@ class Cuprite { return node.contains(selectedNode); } + dragMousedownTracker() { + window._cupriteMousedownPrevented = null; + document.addEventListener('mousedown', ev => { + window._cupriteMousedownPrevented = ev.defaultPrevented; + }, { once: true, passive: true }); + } + + legacyDragCheck(node) { + if ([true, null].indexOf(window._cupriteMousedownPrevented) >= 0) { + return true; + } + + let el = node; + do { + if (el.draggable) return false; + } while (el = el.parentElement); + return true; + } + // This command is purely for testing error handling browserError() { throw new Error("zomg"); diff --git a/lib/capybara/cuprite/node.rb b/lib/capybara/cuprite/node.rb index 45c58eb..9481d55 100644 --- a/lib/capybara/cuprite/node.rb +++ b/lib/capybara/cuprite/node.rb @@ -12,6 +12,8 @@ class Node < Capybara::Driver::Node delegate %i[description] => :node delegate %i[browser] => :driver + DRAG_MODIFIER_ALIASES = { control: :ctrl, command: :meta, cmd: :meta }.freeze + def initialize(driver, node) super(driver, self) @node = node @@ -177,8 +179,9 @@ def hover def drag_to(other, **options) options[:steps] ||= 1 options[:scroll] = true unless options.key?(:scroll) + modifiers = Array(options[:drop_modifiers]).map { |m| DRAG_MODIFIER_ALIASES.fetch(m.to_sym, m.to_sym) } - command(:drag, other.node, options[:steps], options[:delay], options[:scroll]) + command(:drag, other.node, modifiers, options.slice(:steps, :delay, :scroll, :html5)) end def drag_by(x, y, **options) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b7ebf23..e15de12 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -36,21 +36,6 @@ module TestSessions RSpec.configure do |config| config.define_derived_metadata do |metadata| regexes = <<~REGEXP.split("\n").map { |s| Regexp.quote(s.strip) }.join("|") - #drag_to should simulate a single held down modifier key - #drag_to should simulate multiple held down modifier keys - #drag_to should support key aliases - #drag_to HTML5 should HTML5 drag and drop an object - #drag_to HTML5 should HTML5 drag and drop an object child - #drag_to HTML5 should set clientX/Y in dragover events - #drag_to HTML5 should preserve clientX/Y from last dragover event - #drag_to HTML5 should HTML5 drag and drop when scrolling needed - #drag_to HTML5 should drag HTML5 default draggable elements - #drag_to HTML5 should work with SortableJS - #drag_to HTML5 should drag HTML5 default draggable element child - #drag_to HTML5 should simulate a single held down modifier key - #drag_to HTML5 should simulate multiple held down modifier keys - #drag_to HTML5 should support key aliases - #drag_to HTML5 should trigger a dragenter event, before the first dragover event node Element#drop can drop a file node Element#drop can drop multiple files node Element#drop can drop strings