diff --git a/lib/capybara/cuprite/browser.rb b/lib/capybara/cuprite/browser.rb index 9d04f44..533b680 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) + + other.scroll_into_view if scroll + x2, y2 = other.find_position + mouse.move(x: x2, y: y2, steps: steps) - 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 @@ -170,6 +192,18 @@ def drag_by(node, dx, dy, steps, delay = nil, scroll = true) mouse.up end + def drop(node, *args) + if args[0].is_a?(String) + execute("_cuprite.attachDropInput()") + input = find(:css, "#_cuprite_drop_file").first + select_file(input, args) + evaluate_on(node: node, expression: "_cuprite.dropFile(this)") + else + strings = args.flat_map { |arg| arg.map { |type, data| { "type" => type, "data" => data } } } + evaluate_on(node: node, expression: "_cuprite.dropString(#{strings.to_json}, this)") + end + end + def select_file(node, value) node.select_file(value) 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..7a0861a 100644 --- a/lib/capybara/cuprite/javascripts/index.js +++ b/lib/capybara/cuprite/javascripts/index.js @@ -528,6 +528,66 @@ 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; + } + + // Ported from Capybara's Selenium html5_drag.rb (DROP_STRING). + dropString(strings, target) { + let dt = new DataTransfer(); + let opts = { cancelable: true, bubbles: true, dataTransfer: dt }; + for (let i = 0; i < strings.length; i++) { + if (dt.items) { + dt.items.add(strings[i]["data"], strings[i]["type"]); + } else { + dt.setData(strings[i]["type"], strings[i]["data"]); + } + } + target.dispatchEvent(new DragEvent("drop", opts)); + } + + // Ported from Capybara's Selenium html5_drag.rb (ATTACH_FILE). + attachDropInput() { + document.getElementById("_cuprite_drop_file")?.remove(); + let input = document.createElement("INPUT"); + input.type = "file"; + input.id = "_cuprite_drop_file"; + input.multiple = true; + document.body.appendChild(input); + } + + // Ported from Capybara's Selenium html5_drag.rb (DROP_FILE). + dropFile(target) { + let input = document.getElementById("_cuprite_drop_file"); + let files = input.files; + let dt = new DataTransfer(); + let opts = { cancelable: true, bubbles: true, dataTransfer: dt }; + input.parentElement.removeChild(input); + if (dt.items) { + for (let i = 0; i < files.length; i++) { + dt.items.add(files[i]); + } + } else { + Object.defineProperty(dt, "files", { value: files, writable: false }); + } + target.dispatchEvent(new DragEvent("drop", opts)); + } + // 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..f32e9cf 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) @@ -188,6 +191,8 @@ def drag_by(x, y, **options) command(:drag_by, x, y, options[:steps], options[:delay], options[:scroll]) end + def drop(...) = command(:drop, ...) + def trigger(event) command(:trigger, event) end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b7ebf23..80939f3 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -36,26 +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 - node Element#drop can drop multiple strings - node Element#drop can drop a pathname node #visible? details non-summary descendants should be non-visible node #visible? works when details is toggled open and closed node #set should submit single text input forms if ended with