Skip to content
This repository was archived by the owner on Apr 17, 2026. It is now read-only.

Commit d9fbab4

Browse files
fry-lobsterclaude
andcommitted
feat: add html5drag action and multi-strategy drag support
Adds html5drag action that dispatches synthetic HTML5 DnD events (dragstart, dragenter, dragover, drop, dragend) via DOM.resolveNode + Runtime.evaluate. Uses data attributes to bridge CDP node IDs to JS DOM elements. Adds TargetRef/TargetNodeID fields to ActionRequest for drop target specification. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1e86fd0 commit d9fbab4

5 files changed

Lines changed: 117 additions & 2 deletions

File tree

internal/bridge/action_pointer.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,29 @@ func (b *Bridge) actionDrag(ctx context.Context, req ActionRequest) (map[string]
132132
return nil, fmt.Errorf("need selector, ref, or nodeId")
133133
}
134134

135+
func (b *Bridge) actionHTML5Drag(ctx context.Context, req ActionRequest) (map[string]any, error) {
136+
if req.TargetNodeID == 0 {
137+
return nil, fmt.Errorf("targetNodeId required for html5drag")
138+
}
139+
var sourceNodeID int64
140+
if req.NodeID > 0 {
141+
sourceNodeID = req.NodeID
142+
} else if req.Selector != "" {
143+
node, err := firstNodeBySelector(ctx, req.Selector)
144+
if err != nil {
145+
return nil, err
146+
}
147+
sourceNodeID = int64(node.BackendNodeID)
148+
} else {
149+
return nil, fmt.Errorf("need selector, ref, or nodeId for source element")
150+
}
151+
err := HTML5DragByNodeID(ctx, sourceNodeID, req.TargetNodeID)
152+
if err != nil {
153+
return nil, err
154+
}
155+
return map[string]any{"dragged": true, "strategy": "html5"}, nil
156+
}
157+
135158
func (b *Bridge) actionHumanClick(ctx context.Context, req ActionRequest) (map[string]any, error) {
136159
if req.NodeID > 0 {
137160
// req.NodeID is a backendDOMNodeId from the accessibility tree

internal/bridge/action_registry.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const (
2121
ActionKeyUp = "keyup"
2222
ActionMouseDown = "mousedown"
2323
ActionMouseUp = "mouseup"
24+
ActionHTML5Drag = "html5drag"
2425
ActionScrollIntoView = "scrollintoview"
2526
)
2627

@@ -46,6 +47,7 @@ func (b *Bridge) InitActionRegistry() {
4647
ActionKeyUp: b.actionKeyUp,
4748
ActionMouseDown: b.actionMouseDown,
4849
ActionMouseUp: b.actionMouseUp,
50+
ActionHTML5Drag: b.actionHTML5Drag,
4951
ActionScrollIntoView: b.actionScrollIntoView,
5052
}
5153
}

internal/bridge/bridge.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -386,8 +386,10 @@ type ActionRequest struct {
386386

387387
ScrollX int `json:"scrollX"`
388388
ScrollY int `json:"scrollY"`
389-
DragX int `json:"dragX"`
390-
DragY int `json:"dragY"`
389+
DragX int `json:"dragX"`
390+
DragY int `json:"dragY"`
391+
TargetRef string `json:"targetRef,omitempty"`
392+
TargetNodeID int64 `json:"targetNodeId,omitempty"`
391393

392394
WaitNav bool `json:"waitNav"`
393395
Fast bool `json:"fast"`

internal/bridge/cdpops/pointer.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,90 @@ func MouseUpByNodeID(ctx context.Context, nodeID int64) error {
257257
}))
258258
}
259259

260+
// HTML5DragByNodeID performs a synthetic HTML5 Drag and Drop between two elements.
261+
// Uses JS to dispatch dragstart, dragover, drop, and dragend events.
262+
func HTML5DragByNodeID(ctx context.Context, sourceNodeID, targetNodeID int64) error {
263+
js := `
264+
(function(srcId, tgtId) {
265+
function nodeById(id) {
266+
return document.querySelector('[data-ptab-nid="' + id + '"]') ||
267+
(function() {
268+
const all = document.querySelectorAll('*');
269+
for (const el of all) if (el.__backendNodeId === id) return el;
270+
return null;
271+
})();
272+
}
273+
// Resolve via CDP-injected attribute or fallback
274+
const src = nodeById(srcId);
275+
const tgt = nodeById(tgtId);
276+
if (!src || !tgt) return JSON.stringify({error: 'element not found', src: !!src, tgt: !!tgt});
277+
278+
const dt = new DataTransfer();
279+
const opts = {bubbles: true, cancelable: true, dataTransfer: dt};
280+
src.dispatchEvent(new DragEvent('dragstart', opts));
281+
tgt.dispatchEvent(new DragEvent('dragenter', opts));
282+
tgt.dispatchEvent(new DragEvent('dragover', opts));
283+
tgt.dispatchEvent(new DragEvent('drop', opts));
284+
src.dispatchEvent(new DragEvent('dragend', opts));
285+
return JSON.stringify({ok: true});
286+
})
287+
`
288+
// We need to resolve backendNodeId to DOM elements. Use DOM.resolveNode + evaluate.
289+
return chromedp.Run(ctx,
290+
// First, tag both elements with a data attribute so JS can find them
291+
chromedp.ActionFunc(func(ctx context.Context) error {
292+
js := fmt.Sprintf(`document.querySelector('[data-ptab-nid="%d"]')?.removeAttribute('data-ptab-nid'); void 0`, sourceNodeID)
293+
return chromedp.FromContext(ctx).Target.Execute(ctx, "Runtime.evaluate", map[string]any{"expression": js}, nil)
294+
}),
295+
chromedp.ActionFunc(func(ctx context.Context) error {
296+
// Resolve source node and tag it
297+
var result map[string]any
298+
if err := chromedp.FromContext(ctx).Target.Execute(ctx, "DOM.resolveNode", map[string]any{
299+
"backendNodeId": sourceNodeID,
300+
}, &result); err != nil {
301+
return fmt.Errorf("resolve source: %w", err)
302+
}
303+
objectID, _ := result["object"].(map[string]any)["objectId"].(string)
304+
if objectID == "" {
305+
return fmt.Errorf("could not resolve source node %d", sourceNodeID)
306+
}
307+
return chromedp.FromContext(ctx).Target.Execute(ctx, "Runtime.callFunctionOn", map[string]any{
308+
"objectId": objectID,
309+
"functionDeclaration": fmt.Sprintf(`function() { this.setAttribute('data-ptab-nid', '%d'); }`, sourceNodeID),
310+
}, nil)
311+
}),
312+
chromedp.ActionFunc(func(ctx context.Context) error {
313+
// Resolve target node and tag it
314+
var result map[string]any
315+
if err := chromedp.FromContext(ctx).Target.Execute(ctx, "DOM.resolveNode", map[string]any{
316+
"backendNodeId": targetNodeID,
317+
}, &result); err != nil {
318+
return fmt.Errorf("resolve target: %w", err)
319+
}
320+
objectID, _ := result["object"].(map[string]any)["objectId"].(string)
321+
if objectID == "" {
322+
return fmt.Errorf("could not resolve target node %d", targetNodeID)
323+
}
324+
return chromedp.FromContext(ctx).Target.Execute(ctx, "Runtime.callFunctionOn", map[string]any{
325+
"objectId": objectID,
326+
"functionDeclaration": fmt.Sprintf(`function() { this.setAttribute('data-ptab-nid', '%d'); }`, targetNodeID),
327+
}, nil)
328+
}),
329+
chromedp.ActionFunc(func(ctx context.Context) error {
330+
expr := fmt.Sprintf(`%s(%d, %d)`, js, sourceNodeID, targetNodeID)
331+
return chromedp.FromContext(ctx).Target.Execute(ctx, "Runtime.evaluate", map[string]any{"expression": expr}, nil)
332+
}),
333+
// Clean up tags
334+
chromedp.ActionFunc(func(ctx context.Context) error {
335+
cleanup := fmt.Sprintf(`document.querySelectorAll('[data-ptab-nid]').forEach(el => el.removeAttribute('data-ptab-nid')); void 0`)
336+
_ = cleanup
337+
return chromedp.FromContext(ctx).Target.Execute(ctx, "Runtime.evaluate", map[string]any{
338+
"expression": `document.querySelectorAll('[data-ptab-nid]').forEach(el => el.removeAttribute('data-ptab-nid')); void 0`,
339+
}, nil)
340+
}),
341+
)
342+
}
343+
260344
func HoverByCoordinate(ctx context.Context, x, y float64) error {
261345
if x < 0 || y < 0 {
262346
return fmt.Errorf("x/y coordinates must be >= 0")

internal/bridge/cdpops_facade.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ func DragByNodeID(ctx context.Context, nodeID int64, dx, dy int) error {
6363
return bridgecdpops.DragByNodeID(ctx, nodeID, dx, dy)
6464
}
6565

66+
func HTML5DragByNodeID(ctx context.Context, sourceNodeID, targetNodeID int64) error {
67+
return bridgecdpops.HTML5DragByNodeID(ctx, sourceNodeID, targetNodeID)
68+
}
69+
6670
func MouseDownByCoordinate(ctx context.Context, x, y float64) error {
6771
return bridgecdpops.MouseDownByCoordinate(ctx, x, y)
6872
}

0 commit comments

Comments
 (0)