diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 3b261b0c..67746d43 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -806,6 +806,51 @@ export class ChartView { return handler; } + // Detach a handler registered through `_listen`. The record carries the live + // target, so a listener that context-loss recovery re-bound onto a + // replacement canvas still detaches from the node it ended up on. + _unlisten(handler) { + const index = this._listeners.findIndex((record) => record.handler === handler); + if (index === -1) return; + const [record] = this._listeners.splice(index, 1); + record.target.removeEventListener(record.type, record.handler, record.options); + } + + _captureGesturePointer(owner, event, onLost) { + const pointerId = event.pointerId; + let active = true; + const release = () => { + if (!active) return; + active = false; + this._unlisten(lost); + // Guarded, so this is a no-op when the browser already took capture back + // (a real `lostpointercapture`, or the implicit release after pointerup). + try { + if (owner.hasPointerCapture(pointerId)) owner.releasePointerCapture(pointerId); + } catch (_err) { /* synthetic event */ } + }; + const lost = (lostEvent) => { + if (!active || lostEvent.pointerId !== pointerId) return; + release(); + onLost(lostEvent); + }; + const guard = (moveEvent) => { + if (!active || moveEvent.pointerId !== pointerId) return false; + // Pointer capture cannot cross a browsing-context boundary. A mouse + // released outside an iframe can return without pointerup; treat the + // first trusted buttonless move as the missing capture-loss signal. + if (moveEvent.type === "pointermove" && moveEvent.isTrusted + && moveEvent.pointerType === "mouse" && !(moveEvent.buttons & 1)) { + lost(moveEvent); + return false; + } + return true; + }; + this._listen(owner, "lostpointercapture", lost); + try { owner.setPointerCapture(pointerId); } catch (_err) { /* synthetic event */ } + return { guard, release }; + } + _interactionFlag(name, fallback = false) { const value = this.interaction && this.interaction[name]; return value === undefined ? fallback : value === true; diff --git a/js/src/53_interaction.ts b/js/src/53_interaction.ts index c242d5de..72215af5 100644 --- a/js/src/53_interaction.ts +++ b/js/src/53_interaction.ts @@ -55,8 +55,8 @@ Object.assign(ChartView.prototype, { }; const moveLassoHandle = (e) => { - if (!lassoHandleDrag || e.pointerId !== lassoHandleDrag.pointerId - || !this._lassoPolygon) return; + if (!lassoHandleDrag || !this._lassoPolygon) return; + if (!lassoHandleDrag.capture.guard(e)) return; const distance = Math.hypot( e.clientX - lassoHandleDrag.startX, e.clientY - lassoHandleDrag.startY, @@ -78,6 +78,19 @@ Object.assign(ChartView.prototype, { e.preventDefault(); e.stopPropagation(); }; + const cancelLassoHandleDrag = (e) => { + if (!lassoHandleDrag || e.pointerId !== lassoHandleDrag.pointerId) return; + const cancelledDrag = lassoHandleDrag; + lassoHandleDrag = null; + cancelledDrag.capture.release(); + delete cancelledDrag.handle.dataset.xyActive; + lassoHandleClick = null; + if (this._lassoPolygon) { + this._lassoPolygon[cancelledDrag.index] = cancelledDrag.original; + this._renderLassoSelection(); + } + e.stopPropagation(); + }; this._listen(this.selLasso, "pointerdown", (e) => { const handle = e.target.closest?.("[data-xy-selection-lasso-handle]"); if (!handle || !this._lassoPolygon) return; @@ -93,9 +106,13 @@ Object.assign(ChartView.prototype, { moved: false, interactionId: null, }; + lassoHandleDrag.capture = this._captureGesturePointer( + this.selLasso, + e, + cancelLassoHandleDrag, + ); handle.dataset.xyActive = ""; this._hideTooltip(); - try { this.selLasso.setPointerCapture(e.pointerId); } catch (_err) { /* synthetic event */ } e.preventDefault(); e.stopPropagation(); }); @@ -105,6 +122,7 @@ Object.assign(ChartView.prototype, { moveLassoHandle(e); const completedDrag = lassoHandleDrag; lassoHandleDrag = null; + completedDrag.capture.release(); delete completedDrag.handle.dataset.xyActive; if (completedDrag.moved && this._lassoPolygon) { lassoHandleClick = null; @@ -125,17 +143,7 @@ Object.assign(ChartView.prototype, { removeLassoHandle(completedDrag.index); } }); - this._listen(this.selLasso, "pointercancel", (e) => { - if (!lassoHandleDrag || e.pointerId !== lassoHandleDrag.pointerId) return; - if (this._lassoPolygon) { - this._lassoPolygon[lassoHandleDrag.index] = lassoHandleDrag.original; - } - delete lassoHandleDrag.handle.dataset.xyActive; - lassoHandleDrag = null; - lassoHandleClick = null; - if (this._lassoPolygon) this._renderLassoSelection(); - e.stopPropagation(); - }); + this._listen(this.selLasso, "pointercancel", cancelLassoHandleDrag); if (this._interactionFlag("crosshair")) { this.crosshairX = document.createElement("div"); @@ -177,6 +185,45 @@ Object.assign(ChartView.prototype, { return true; }; + const finishPanDrag = () => { + if (!drag) return; + const finished = drag; + drag = null; + finished.capture.release(); + if (finished.moved) { + this._ignoreNextClick = true; + if (finished.changedAxes.length) this._emitViewChange("pan_drag", { + axes: finished.changedAxes, + phase: "end", + interactionId: finished.interactionId, + }); + } else { + this._hideTooltip(); + } + }; + const cancelPointerGesture = () => { + band?.capture.release(); + drag?.capture.release(); + this.selRect.style.display = "none"; + this.selLasso.style.display = "none"; + if (band?.previousLasso) { + this._lassoPolygon = band.previousLasso; + this._renderLassoSelection(); + } else if (band?.previousBox) { + this._boxSelection = band.previousBox; + this._renderBoxSelection(); + } + band = null; + drag = null; + }; + // A capture-owning gesture ended without a release this document saw: a pan + // keeps the view it already reached, while an unfinished selection/box-zoom + // has no release coordinate to complete with and therefore rolls back. + const endGestureWithoutRelease = () => { + if (drag) finishPanDrag(); + else if (band) cancelPointerGesture(); + }; + this._listen(c, "pointerdown", (e) => { this._cancelViewAnimation(); // A browser reports the click count on the second pointer press, before @@ -217,7 +264,7 @@ Object.assign(ChartView.prototype, { previousBox, replacingLasso: false, }; - try { c.setPointerCapture(e.pointerId); } catch (_err) { /* synthetic event */ } + band.capture = this._captureGesturePointer(c, e, endGestureWithoutRelease); this._hideTooltip(); return; } @@ -237,11 +284,13 @@ Object.assign(ChartView.prototype, { ])], changedAxes: [], }; - try { c.setPointerCapture(e.pointerId); } catch (_err) { /* synthetic event */ } + drag.capture = this._captureGesturePointer(c, e, endGestureWithoutRelease); this._hideTooltip(); } }); this._listen(c, "pointermove", (e) => { + const capture = band?.capture || drag?.capture; + if (capture && !capture.guard(e)) return; if (band) { this._updateBand(band, e); return; } if (drag) { drag.moved = true; @@ -276,6 +325,7 @@ Object.assign(ChartView.prototype, { }); const end = (e) => { if (band) { + band.capture.release(); // Pointermove is not guaranteed to run at the pointer-up coordinate. // Capture that final vertex before deciding whether the gesture moved; // a naturally closed lasso finishes near its start and therefore has @@ -327,31 +377,10 @@ Object.assign(ChartView.prototype, { band = null; return; } - if (drag && drag.moved) { - this._ignoreNextClick = true; - if (drag.changedAxes.length) this._emitViewChange("pan_drag", { - axes: drag.changedAxes, - phase: "end", - interactionId: drag.interactionId, - }); - } - if (drag && !drag.moved) this._hideTooltip(); - drag = null; + finishPanDrag(); }; this._listen(c, "pointerup", end); - this._listen(c, "pointercancel", () => { - this.selRect.style.display = "none"; - this.selLasso.style.display = "none"; - if (band?.previousLasso) { - this._lassoPolygon = band.previousLasso; - this._renderLassoSelection(); - } else if (band?.previousBox) { - this._boxSelection = band.previousBox; - this._renderBoxSelection(); - } - band = null; - drag = null; - }); + this._listen(c, "pointercancel", cancelPointerGesture); this._listen(c, "pointerleave", () => this._pointerHoverExit()); // Backstop for missed canvas pointerleave: browsers skip boundary events // when the element under a stationary cursor changes (page scroll, @@ -393,6 +422,11 @@ Object.assign(ChartView.prototype, { }); this._listen(c, "keydown", (e) => { if (e.key === "Escape" && (band || drag)) { + // Release before dropping the records: the capture object is the only + // handle on its `lostpointercapture` listener, so an unreleased gesture + // strands that listener on the canvas for the life of the view. + band?.capture.release(); + drag?.capture.release(); this.selRect.style.display = "none"; this.selLasso.style.display = "none"; band = null; @@ -1142,11 +1176,11 @@ Object.assign(ChartView.prototype, { dy: e.clientY - barRect.top, moved: false, }; - try { bar.setPointerCapture(e.pointerId); } catch (_err) { /* synthetic event */ } + modebarDrag.capture = this._captureGesturePointer(bar, e, endModebarDrag); setVisible(true); }); this._listen(bar, "pointermove", (e) => { - if (!modebarDrag || e.pointerId !== modebarDrag.pointerId) return; + if (!modebarDrag || !modebarDrag.capture.guard(e)) return; const distance = Math.hypot(e.clientX - modebarDrag.startX, e.clientY - modebarDrag.startY); if (!modebarDrag.moved) { if (distance < DRAG_THRESHOLD_PX) return; @@ -1166,15 +1200,14 @@ Object.assign(ChartView.prototype, { }); const endModebarDrag = (e) => { if (!modebarDrag || e.pointerId !== modebarDrag.pointerId) return; + const completedDrag = modebarDrag; modebarDrag = null; + completedDrag.capture.release(); this._modebarDragging = false; bar.style.transition = "opacity .15s"; setVisible(root.matches(":hover")); bar.classList.remove("xy-dragging"); updateDragPeekSide(); - try { - if (bar.hasPointerCapture(e.pointerId)) bar.releasePointerCapture(e.pointerId); - } catch (_err) { /* synthetic event */ } }; this._listen(bar, "pointerup", endModebarDrag); this._listen(bar, "pointercancel", endModebarDrag); diff --git a/js/src/57_viewstate.ts b/js/src/57_viewstate.ts index 0e3f3461..fd462a92 100644 --- a/js/src/57_viewstate.ts +++ b/js/src/57_viewstate.ts @@ -382,7 +382,7 @@ Object.assign(ChartView.prototype, { interactionId: ++this._interactionSeq, changedAxes: [], }; - try { band.setPointerCapture(e.pointerId); } catch (_err) { /* synthetic event */ } + drag.capture = this._captureGesturePointer(band, e, end); this.tooltip.style.display = "none"; e.preventDefault(); }); @@ -397,7 +397,7 @@ Object.assign(ChartView.prototype, { && this._axisPolicy("zoom_axes").includes(axisId); this._listen(band, "pointermove", (e) => { - if (!drag || e.pointerId !== drag.pointerId) return; + if (!drag || !drag.capture.guard(e)) return; const dx = e.clientX - drag.sx; const dy = e.clientY - drag.sy; if (!drag.mode) { @@ -469,9 +469,12 @@ Object.assign(ChartView.prototype, { if (!drag || e.pointerId !== drag.pointerId) return; const finished = drag; drag = null; + finished.capture.release(); band.style.cursor = this._axisBandCursor(axisId, dim); if (finished.mode === "span") this.selRect.style.display = "none"; - if (e.type === "pointercancel") return; + // Only a real release commits a coordinate-dependent gesture; a pan keeps + // the view it already reached however the gesture ended. + if (e.type !== "pointerup" && finished.mode !== "pan") return; if (finished.mode === "pan" && finished.changedAxes.length) { this._emitViewChange("pan_drag", { axes: finished.changedAxes, diff --git a/spec/api/interaction.md b/spec/api/interaction.md index 2fce6b3d..421d8427 100644 --- a/spec/api/interaction.md +++ b/spec/api/interaction.md @@ -299,6 +299,17 @@ An existing lasso remains rendered until a replacement selection gesture crosses that movement threshold; a plain click or sub-threshold pointer jitter does not temporarily hide or replace it. +Pointer capture is scoped to one browsing context, so a gesture whose button is +released outside an embedding iframe may never deliver `pointerup` to the +chart. Every capture-owning gesture still terminates deterministically: a pan +finalizes at its last in-frame view and emits its one `end` event, and a +gesture that needs a release coordinate it never received rolls back to its +last committed state — selection and box-zoom restore the previous selection, +a lasso-handle edit restores the prior vertex, and modebar dragging ends at its +last in-frame position. Re-entering the chart never resumes the gesture. The +detection mechanism and its scope are in +[`../design/pan-and-zoom-configuration.md`](../design/pan-and-zoom-configuration.md). + Axis bands are geometric scopes, not new state: secondary axes get their band on their own side (`y` left, `y2` right, top-side x axes on top), so scoping needs no modifier keys, and a band gesture is an ordinary interaction with a diff --git a/spec/design/pan-and-zoom-configuration.md b/spec/design/pan-and-zoom-configuration.md index c43cb660..6800a2e0 100644 --- a/spec/design/pan-and-zoom-configuration.md +++ b/spec/design/pan-and-zoom-configuration.md @@ -617,6 +617,15 @@ controls transport work. - DOM events may emit once per animation frame. - Python/Reflex events are coalesced. - Continuous gestures always deliver a final `end` event. +- Every capture-owning gesture acquires its pointer through one shared + capture-loss detector (`ChartView._captureGesturePointer`), so the behavior + cannot be opted out of; each gesture then declares its own end policy — a pan + finalizes at its last in-frame view, coordinate-dependent gestures cancel, and + chrome drags release. The detector fires on `lostpointercapture`, and falls + back to the first trusted buttonless re-entry move for browsers that delay or + omit it. That backstop is mouse-only (it reads the primary-button bit, which + has no equivalent for pen or touch); pen and touch rely on + `lostpointercapture` alone. - LOD and view-event throttles may differ. - Linked peers receive browser-local updates without a Python round trip. diff --git a/spec/design/view-state.md b/spec/design/view-state.md index ee371a70..5f4e9b15 100644 --- a/spec/design/view-state.md +++ b/spec/design/view-state.md @@ -371,6 +371,10 @@ Locked in before implementation, in the PR #117 fail-first style: the pre-reset view. - **Ordering:** `on_brush` before `on_select` holds for programmatic geometric selects. +- **Browsing-context release:** every capture-owning gesture follows the same + loss detector. Pans end once at the last in-frame view; selection/box-zoom + and lasso-handle edits restore committed state; axis and modebar drags do + not resume on buttonless re-entry. - **Rows non-durability:** a rows-selection never enters the history stack, `view_state()` reports the `{"rows": true}` marker rather than indices, and Back after a rows-select restores the prior geometric state. diff --git a/tests/test_static_client_security.py b/tests/test_static_client_security.py index abaa91e1..bed55840 100644 --- a/tests/test_static_client_security.py +++ b/tests/test_static_client_security.py @@ -199,8 +199,12 @@ def test_pointer_capture_tolerates_synthetic_accessibility_clicks() -> None: capture_lines = [ line.strip() for line in text.splitlines() if ".setPointerCapture(" in line ] - # canvas drag, band select, lasso handle, modebar surface, axis band - assert len(capture_lines) == 5, f"{path} has an unexpected capture site" + # One acquisition site: every capture-owning gesture goes through + # `_captureGesturePointer`, so capture-loss handling cannot be opted out + # of. Which gestures inherit it is asserted behaviorally instead, by + # tests/test_view_state_client.py:: + # test_capture_loss_policy_covers_every_capture_owning_gesture. + assert len(capture_lines) == 1, f"{path} acquires pointer capture outside the shared policy" assert all(line.startswith("try {") and "catch (_err)" in line for line in capture_lines), ( f"{path} leaves pointer capture unguarded for synthetic events" ) diff --git a/tests/test_view_state_client.py b/tests/test_view_state_client.py index 031c59a3..5e7bbd01 100644 --- a/tests/test_view_state_client.py +++ b/tests/test_view_state_client.py @@ -123,6 +123,145 @@ def test_state_round_trip_patch_semantics_and_clamps(tmp_path: Path) -> None: assert result == {key: True for key in result} +_LOST_POINTER_CAPTURE_PROBE = """ + const view = xy.renderStandalone(document.getElementById("chart"), spec, buf); + try { + view._drawNow(); + const ranges = () => Object.fromEntries( + view._axisIds().map((id) => [id, [...view._axisRange(id)]])); + const center = (el) => { + const r = el.getBoundingClientRect(); + return [r.left + r.width / 2, r.top + r.height / 2]; + }; + const [x, y] = center(view.canvas); + + const realRaf = window.requestAnimationFrame; + let frames = []; + let ts = 0; + window.requestAnimationFrame = (fn) => { frames.push(fn); return frames.length; }; + const flush = () => { + for (let round = 0; round < 300 && (frames.length || view._viewAnim); round++) { + const queued = frames; frames = []; + ts += 100; + for (const fn of queued) fn(ts); + } + }; + + const endEvents = []; + view.root.addEventListener("xy:view_change", (event) => { + if (event.detail.source === "pan_drag" && event.detail.phase === "end") { + endEvents.push(event.detail); + } + }); + const pointer = (target, type, pointerId, clientX, clientY, buttons) => { + target.dispatchEvent(new PointerEvent(type, { + pointerId, + pointerType: "mouse", + button: 0, + buttons, + clientX, + clientY, + bubbles: true, + cancelable: true, + isPrimary: true, + })); + }; + + pointer(view.canvas, "pointerdown", 71, x, y, 1); + pointer(view.canvas, "pointermove", 71, x + 60, y + 10, 1); + const atBoundary = ranges(); + + // Chrome's iframe sequence when the primary button is released in the + // parent document: no pointerup in this document, then lost capture and + // a buttonless pointermove when the cursor re-enters. + pointer(view.canvas, "lostpointercapture", 71, x + 60, y + 10, 0); + pointer(view.canvas, "pointermove", 71, x + 120, y + 20, 0); + flush(); + const canvasReentryDidNotPan = + JSON.stringify(ranges()) === JSON.stringify(atBoundary); + + // Coordinate-dependent canvas gestures cannot invent an endpoint outside + // this document, so capture loss cancels their transient overlay/state. + view._setDragMode("select"); + pointer(view.canvas, "pointerdown", 72, x, y, 1); + pointer(view.canvas, "pointermove", 72, x + 50, y + 40, 1); + const selectionWasActive = view.selRect.style.display === "block"; + pointer(view.canvas, "lostpointercapture", 72, x + 50, y + 40, 0); + const selectionCancelled = view.root.xy.state().selection === null + && view.selRect.style.display === "none"; + + // Editable lasso handles restore the last committed vertex. + view._sendSelectPolygon([[0, 0], [4, 0], [4, 16], [0, 16]], { history: false }); + const lassoBefore = JSON.stringify(view._lassoPolygon); + const handle = view.selLassoHandles.children[1]; + const [hx, hy] = center(handle); + pointer(handle, "pointerdown", 73, hx, hy, 1); + pointer(view.selLasso, "pointermove", 73, hx + 30, hy + 20, 1); + const lassoMoved = JSON.stringify(view._lassoPolygon) !== lassoBefore; + pointer(view.selLasso, "lostpointercapture", 73, hx + 30, hy + 20, 0); + const lassoRestored = JSON.stringify(view._lassoPolygon) === lassoBefore + && !handle.hasAttribute("data-xy-active"); + + // Axis-band pan owns the same finish-at-last-valid-frame policy. + view._setDragMode("pan"); + const axisBand = view.root.querySelector('[data-xy-axis-band="x"]'); + const [ax, ay] = center(axisBand); + pointer(axisBand, "pointerdown", 74, ax, ay, 1); + pointer(axisBand, "pointermove", 74, ax + 45, ay, 1); + const axisAtBoundary = ranges(); + pointer(axisBand, "lostpointercapture", 74, ax + 45, ay, 0); + pointer(axisBand, "pointermove", 74, ax + 90, ay, 0); + const axisReentryDidNotPan = + JSON.stringify(ranges()) === JSON.stringify(axisAtBoundary); + + // Chrome owned by the modebar cannot remain in its dragging state either. + view.root.dispatchEvent(new PointerEvent("pointerenter", { bubbles: true })); + const modebar = view._modebar; + const [mx, my] = center(modebar); + pointer(modebar, "pointerdown", 75, mx, my, 1); + pointer(modebar, "pointermove", 75, mx + 40, my + 20, 1); + const modebarAtBoundary = [modebar.style.left, modebar.style.top]; + pointer(modebar, "lostpointercapture", 75, mx + 40, my + 20, 0); + pointer(modebar, "pointermove", 75, mx + 80, my + 40, 0); + const modebarReleased = !view._modebarDragging + && !modebar.classList.contains("xy-dragging") + && JSON.stringify([modebar.style.left, modebar.style.top]) + === JSON.stringify(modebarAtBoundary); + + flush(); + window.requestAnimationFrame = realRaf; + + document.body.setAttribute("data-xy-lost-capture-probe", JSON.stringify({ + canvasReentryDidNotPan, + finalEndEmittedForBothPans: endEvents.length === 2, + finalEndKeptInteraction: endEvents[0]?.interaction_id != null + && endEvents[0]?.axes?.length > 0 + && endEvents[1]?.interaction_id != null + && endEvents[1]?.axes?.length > 0, + selectionWasActive, + selectionCancelled, + lassoMoved, + lassoRestored, + axisReentryDidNotPan, + modebarReleased, + })); + } catch (err) { + document.body.setAttribute( + "data-xy-lost-capture-probe-error", String((err && err.stack) || err)); + } +""" + + +def test_capture_loss_policy_covers_every_capture_owning_gesture(tmp_path: Path) -> None: + result = _run( + tmp_path, + _chart_html().replace(_RENDER_CALL, _LOST_POINTER_CAPTURE_PROBE), + "data-xy-lost-capture-probe", + label="shared pointer-capture loss probe", + ) + assert result == {key: True for key in result} + + _HISTORY_PROBE = """ const view = xy.renderStandalone(document.getElementById("chart"), spec, buf); try {