diff --git a/CHANGELOG.md b/CHANGELOG.md index 950114d..f92d816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ### Added - `:emulate` driver option to emulate a device listed in Chrome DevTools with its user agent, e.g. `emulate: :iphone_14`, along with `:mobile` and `:scale_factor` options it sets, kept across window resizes [#216] +- `Node#drag_to` accepts `x:` and `y:` to drop at an offset from the target, measured from its center or top left like + `click`, so drop targets that tell the upper half from the lower one can be driven [#325] ### Changed - Ferrum is bumped to `~> 0.19.0` @@ -11,6 +13,9 @@ `#active_element` and `focused:` specs run in the suite [#327] ### Fixed +- `Node#drag_to` fired `dragleave` right before `drop` on an HTML5 drop target, which browsers never do, so libraries such + as Pragmatic drag and drop dropped onto nothing. A drag now ends with `drop` if the target accepted it, otherwise + with `dragleave` [#325] - `window_size: nil` raised `Ferrum::BrowserError: Invalid parameters` on every page instead of leaving the window size to Chrome, so `start-maximized` couldn't be used [#278] - `Driver#reset!` kept the page it had cached when the browser failed to reset, so every later example raised diff --git a/docs/6-node.md b/docs/6-node.md index 218144e..6af9c4b 100644 --- a/docs/6-node.md +++ b/docs/6-node.md @@ -79,9 +79,13 @@ automatically. * :scroll `Boolean` scroll elements into view, `true` by default * :html5 `Boolean` force HTML5 or legacy mouse drag * :drop_modifiers `Symbol` | `Array` modifier keys held on drop, e.g. `:shift`, `:ctrl`, `:alt`, `:meta` + * :x, :y `Integer` drop at this offset instead of the center of `other`, both are required + * :offset `:center` | `:top` where the offset is measured from, the center or the top left corner of `other`. + Follows `Capybara.w3c_click_offset` by default, like `click` ```ruby find("#item").drag_to(find("#basket"), steps: 10, drop_modifiers: :shift) +find("#item").drag_to(find("#list li", text: "Milk"), x: 0, y: 10) # drop on the lower half ``` #### drag_by(x, y, \*\*options) diff --git a/lib/capybara/cuprite/browser.rb b/lib/capybara/cuprite/browser.rb index 7c385a0..b3b82ee 100644 --- a/lib/capybara/cuprite/browser.rb +++ b/lib/capybara/cuprite/browser.rb @@ -155,6 +155,7 @@ def drag(node, other, drop_modifiers, options = {}) delay = options[:delay] scroll = options.fetch(:scroll, true) html5 = options[:html5] + drop_offset = options[:drop_offset] execute("_cuprite.dragMousedownTracker()") @@ -168,21 +169,21 @@ def drag(node, other, drop_modifiers, options = {}) html5 = !evaluate_on(node: node, expression: "_cuprite.legacyDragCheck(this)") if html5.nil? if html5 - drag_html5(node, other, drop_modifiers, delay) + drag_html5(node, other, drop_modifiers, delay, drop_offset) else modifiers = keyboard.modifiers(drop_modifiers) other.scroll_into_view if scroll - x2, y2 = other.find_position + x2, y2 = other.find_position(**drop_offset.to_h) mouse.move(x: x2, y: y2, steps: steps) mouse.up(modifiers: modifiers) end end - def drag_html5(node, other, drop_modifiers, delay) + def drag_html5(node, other, drop_modifiers, delay, drop_offset = nil) keys = drop_modifiers.map(&:to_s) - evaluate_async(DRAG_HTML5_JS, timeout, node, other, (delay || 0.05) * 1000, keys) + evaluate_async(DRAG_HTML5_JS, timeout, node, other, (delay || 0.05) * 1000, keys, drop_offset) mouse.up end diff --git a/lib/capybara/cuprite/javascripts/drag.js b/lib/capybara/cuprite/javascripts/drag.js index 03de508..cbbc96b 100644 --- a/lib/capybara/cuprite/javascripts/drag.js +++ b/lib/capybara/cuprite/javascripts/drag.js @@ -1,11 +1,9 @@ // 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. +// Ported from Capybara's Selenium driver (capybara/selenium/extensions/html5_drag.rb, +// HTML5_DRAG_DROP_SCRIPT), see https://github.com/rubycdp/cuprite/issues/314. +// Unlike the original, a drag ends the way it does in a browser: with `drop` if +// the target accepted it, otherwise with `dragleave`, never both [#325]. function rectCenter(rect){ return new DOMPoint( @@ -31,7 +29,7 @@ function pointOnRect(pt, rect) { } if (pt.y <= rectPt.y) { // top side - var minYx = (rectPt.top - pt.y) / slope + pt.x; + var minYx = (rect.top - pt.y) / slope + pt.x; if (rect.left <= minYx && minYx <= rect.right) return new DOMPoint(minYx, rect.top); } @@ -51,7 +49,7 @@ function dragEnterTarget() { var sourceCenter = rectCenter(source.getBoundingClientRect()); for (var i = 0; i < drop_modifier_keys.length; i++) { - key = drop_modifier_keys[i]; + var key = drop_modifier_keys[i]; if (key == "control"){ key = "ctrl" } @@ -69,23 +67,26 @@ function dragEnterTarget() { window.setTimeout(dragOnTarget, step_delay); } +function dropPoint(rect) { + if (!drop_offset) return rectCenter(rect); + + var origin = drop_offset.position == 'center' ? rectCenter(rect) : new DOMPoint(rect.left, rect.top); + return new DOMPoint(origin.x + drop_offset.x, origin.y + drop_offset.y); +} + function dragOnTarget() { - var targetCenter = rectCenter(target.getBoundingClientRect()); - var dragOverOpts = Object.assign({clientX: targetCenter.x, clientY: targetCenter.y}, opts); + var point = dropPoint(target.getBoundingClientRect()); + var dragOverOpts = Object.assign({clientX: point.x, clientY: point.y}, opts); var dragOverEvent = new DragEvent('dragover', dragOverOpts); target.dispatchEvent(dragOverEvent); - window.setTimeout(dragLeave, step_delay, dragOverEvent.defaultPrevented, dragOverOpts); + window.setTimeout(dragEnd, 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); +function dragEnd(drop, dragOverOpts) { + var dragEndOptions = Object.assign({}, opts, dragOverOpts); + var dropOrLeaveEvent = new DragEvent(drop ? 'drop' : 'dragleave', dragEndOptions); + target.dispatchEvent(dropOrLeaveEvent); + var dragEndEvent = new DragEvent('dragend', dragEndOptions); source.dispatchEvent(dragEndEvent); callback.call(true); } @@ -94,7 +95,8 @@ var source = arguments[0], target = arguments[1], step_delay = arguments[2], drop_modifier_keys = arguments[3], - callback = arguments[4]; + drop_offset = arguments[4], + callback = arguments[5]; var dt = new DataTransfer(); var opts = { cancelable: true, bubbles: true, dataTransfer: dt }; diff --git a/lib/capybara/cuprite/node.rb b/lib/capybara/cuprite/node.rb index d14144d..8160703 100644 --- a/lib/capybara/cuprite/node.rb +++ b/lib/capybara/cuprite/node.rb @@ -181,11 +181,14 @@ def hover end def drag_to(other, **options) + raise ArgumentError, "You must specify both x: and y: for a drop offset" if options[:x].nil? ^ options[:y].nil? + options[:steps] ||= 1 options[:scroll] = true unless options.key?(:scroll) + options[:drop_offset] = drop_offset(*options.values_at(:x, :y, :offset)) modifiers = Array(options[:drop_modifiers]).map { |m| DRAG_MODIFIER_ALIASES.fetch(m.to_sym, m.to_sym) } - command(:drag, other.node, modifiers, options.slice(:steps, :delay, :scroll, :html5)) + command(:drag, other.node, modifiers, options.slice(:steps, :delay, :scroll, :html5, :drop_offset)) end def drag_by(x, y, **options) @@ -282,6 +285,13 @@ def prepare_and_click(mode, name, keys, options) node.click(mode: mode, keys: keys, offset: offset, delay: delay) end + def drop_offset(x, y, position) + return unless x + + position ||= driver.session_options.w3c_click_offset ? :center : :top + { x: x, y: y, position: position.to_sym } + end + def filter_text(text) text.gsub(/[\u200b\u200e\u200f]/, "") .gsub(/[\ \n\f\t\v\u2028\u2029]+/, " ") diff --git a/spec/features/session_spec.rb b/spec/features/session_spec.rb index c5339b2..224d013 100644 --- a/spec/features/session_spec.rb +++ b/spec/features/session_spec.rb @@ -867,6 +867,52 @@ end end + context "HTML5 dragging support" do + before do + @session.visit "/cuprite/html5_drag" + end + + it "ends with drop and no dragleave on a target that accepts it" do + @session.find(:css, "#source").drag_to(@session.find(:css, "#accepting")) + + expect(@session.evaluate_script("window.events.accepting")).to eq(%w[dragenter dragover drop]) + end + + it "ends with dragleave and no drop on a target that refuses it" do + @session.find(:css, "#source").drag_to(@session.find(:css, "#refusing")) + + expect(@session.evaluate_script("window.events.refusing")).to eq(%w[dragenter dragover dragleave]) + end + + [true, false].each do |html5| + it "drops at an offset from the target's center with html5: #{html5}" do + @session.find(:css, "#source").drag_to(@session.find(:css, "#accepting"), x: 10, y: 5, html5: html5) + + expect(@session.evaluate_script("window.dropPoint")).to eq([110, 55]) + end + + it "drops at an offset from the target's top left with html5: #{html5}" do + @session.find(:css, "#source") + .drag_to(@session.find(:css, "#accepting"), x: 10, y: 5, offset: :top, html5: html5) + + expect(@session.evaluate_script("window.dropPoint")).to eq([10, 5]) + end + end + + it "drops at an offset from the target's top left when w3c_click_offset is off" do + Capybara.w3c_click_offset = false + @session.find(:css, "#source").drag_to(@session.find(:css, "#accepting"), x: 10, y: 5) + + expect(@session.evaluate_script("window.dropPoint")).to eq([10, 5]) + end + + it "requires both x: and y: for a drop offset" do + expect do + @session.find(:css, "#source").drag_to(@session.find(:css, "#accepting"), x: 10) + end.to raise_error(ArgumentError, "You must specify both x: and y: for a drop offset") + end + end + context "window switching support" do it "waits for the window to load" do @session.visit "/" diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 74f9f6e..ea16b31 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -61,8 +61,15 @@ module TestSessions node #set should submit single text input forms if ended with REGEXP + browser_behavior = <<~REGEXP.split("\n").map { |s| Regexp.quote(s.strip) }.join("|") + #drag_to HTML5 should preserve clientX/Y from last dragover event + REGEXP + metadata[:skip] = true if metadata[:full_description].match(/#{regexes}/) metadata[:skip] = "Intentionally skipped" if metadata[:full_description].match(/#{intentional_skip}/) + if metadata[:full_description].match(/#{browser_behavior}/) + metadata[:skip] = "Expects behavior browsers don't have, e.g. dragleave together with drop" + end end config.around do |example| diff --git a/spec/support/views/html5_drag.erb b/spec/support/views/html5_drag.erb new file mode 100644 index 0000000..0f9dc10 --- /dev/null +++ b/spec/support/views/html5_drag.erb @@ -0,0 +1,38 @@ + + + + cuprite html5 drag_to + + + +
Drag me
+
Drop here
+
Not here
+ + + +