From 46013300d56bf17ed081cf32e734c54d0a813341 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:34:45 +0500 Subject: [PATCH 1/2] Release a stuck pan when pointer capture is lost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pointer capture is scoped to one browsing context. When a canvas drag leaves an embedded iframe and the primary mouse button is released in the parent document, the iframe never receives `pointerup` — Chrome reports the lost capture only once the pointer re-enters. The gesture stayed live across that gap, so the next move continued panning as though the button were still held. Finalize the gesture on `lostpointercapture`: a pan ends at its last in-frame view and emits its single `end` event, while an unfinished selection or box-zoom rolls back, having no release coordinate to complete with. A buttonless mouse move back over the canvas is the backstop for browsers that omit the lost-capture notification. Covered by a headless probe asserting the re-entry move applies no further pan delta and that exactly one `end` event ships with its interaction id intact. Axis-band drags take capture the same way and do not carry this backstop yet; recorded in the pan-and-zoom spec. --- js/src/53_interaction.ts | 68 +++++++++++++------- spec/api/interaction.md | 9 +++ spec/design/pan-and-zoom-configuration.md | 4 ++ spec/design/view-state.md | 3 + tests/test_view_state_client.py | 77 +++++++++++++++++++++++ 5 files changed, 138 insertions(+), 23 deletions(-) diff --git a/js/src/53_interaction.ts b/js/src/53_interaction.ts index c242d5de..35f78792 100644 --- a/js/src/53_interaction.ts +++ b/js/src/53_interaction.ts @@ -241,7 +241,49 @@ Object.assign(ChartView.prototype, { this._hideTooltip(); } }); + const finishPanDrag = () => { + if (!drag) return; + const finished = drag; + drag = null; + 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 = () => { + 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, "pointermove", (e) => { + // Pointer capture stops at a browsing-context boundary. If a drag leaves + // an iframe and the mouse is released outside it, Chrome omits pointerup + // here and returns with a buttonless lost-capture/move sequence. + if ((band || drag) && e.isTrusted && e.pointerType === "mouse" && !(e.buttons & 1)) { + endGestureWithoutRelease(); + return; + } if (band) { this._updateBand(band, e); return; } if (drag) { drag.moved = true; @@ -327,31 +369,11 @@ 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, "lostpointercapture", endGestureWithoutRelease); 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, diff --git a/spec/api/interaction.md b/spec/api/interaction.md index 2fce6b3d..a4a26866 100644 --- a/spec/api/interaction.md +++ b/spec/api/interaction.md @@ -299,6 +299,15 @@ 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. When a canvas drag leaves +an embedded iframe and the primary mouse button is released in the parent +document, the iframe may receive no `pointerup`; Chrome reports the lost +capture only when the pointer re-enters. The client finalizes a pan at its last +in-frame view before processing that buttonless move, emits the gesture's one +`end` event, and cancels an unfinished selection/box-zoom whose release +coordinate is unavailable. A buttonless mouse move back over the canvas is the +backstop when the browser omits the lost-capture notification as well. + 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..6f534c2b 100644 --- a/spec/design/pan-and-zoom-configuration.md +++ b/spec/design/pan-and-zoom-configuration.md @@ -617,6 +617,10 @@ controls transport work. - DOM events may emit once per animation frame. - Python/Reflex events are coalesced. - Continuous gestures always deliver a final `end` event. +- A plot-canvas pan released outside an embedding iframe finalizes at its last + in-frame view on lost pointer capture (or the first buttonless mouse re-entry + move over the canvas); that re-entry never applies another pan delta. Axis-band + drags take capture the same way and do not carry this backstop yet. - 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..63338775 100644 --- a/spec/design/view-state.md +++ b/spec/design/view-state.md @@ -371,6 +371,9 @@ 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:** a plot-canvas pan whose `pointerup` occurs + outside an embedding iframe ends once at the last in-frame view; the + buttonless re-entry move does not mutate ranges. - **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_view_state_client.py b/tests/test_view_state_client.py index 031c59a3..cd8e7e70 100644 --- a/tests/test_view_state_client.py +++ b/tests/test_view_state_client.py @@ -123,6 +123,83 @@ 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 rect = view.canvas.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + + 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 = (type, clientX, clientY, buttons) => { + view.canvas.dispatchEvent(new PointerEvent(type, { + pointerId: 71, + pointerType: "mouse", + button: 0, + buttons, + clientX, + clientY, + bubbles: true, + cancelable: true, + isPrimary: true, + })); + }; + + pointer("pointerdown", x, y, 1); + pointer("pointermove", 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("lostpointercapture", x + 60, y + 10, 0); + pointer("pointermove", x + 120, y + 20, 0); + flush(); + window.requestAnimationFrame = realRaf; + + document.body.setAttribute("data-xy-lost-capture-probe", JSON.stringify({ + reentryDidNotPan: JSON.stringify(ranges()) === JSON.stringify(atBoundary), + finalEndEmittedOnce: endEvents.length === 1, + finalEndKeptInteraction: endEvents[0]?.interaction_id != null + && endEvents[0]?.axes?.length > 0, + })); + } catch (err) { + document.body.setAttribute( + "data-xy-lost-capture-probe-error", String((err && err.stack) || err)); + } +""" + + +def test_lost_pointer_capture_finishes_pan_before_buttonless_reentry(tmp_path: Path) -> None: + result = _run( + tmp_path, + _chart_html().replace(_RENDER_CALL, _LOST_POINTER_CAPTURE_PROBE), + "data-xy-lost-capture-probe", + label="lost pointer-capture pan probe", + ) + assert result == {key: True for key in result} + + _HISTORY_PROBE = """ const view = xy.renderStandalone(document.getElementById("chart"), spec, buf); try { From 54268a269d784472644c7e9f017fb0cabf784536 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:46:20 +0500 Subject: [PATCH 2/2] Give every capture-owning gesture the same capture-loss policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit fixed the stuck pan only for the plot canvas, but pointer capture is acquired in five places, and the axis-band drag, the lasso vertex handles and the modebar drag all had identical exposure: a button released outside an embedding iframe never delivers pointerup, so the gesture stayed live and resumed on re-entry. Acquire capture in exactly one place. `ChartView._captureGesturePointer` owns the detector — `lostpointercapture`, plus a buttonless mouse move as the backstop for browsers that delay or omit it — and each gesture declares its own end policy: a pan finalizes at its last in-frame view, gestures needing a release coordinate roll back to their last committed state, and chrome drags simply release. The helper registers through `_listen`, so its listener is visible to both consumers of that registry: `destroy()` sweeps it, and context-loss recovery re-binds it onto the replacement canvas. `_unlisten` detaches via the record's live target, so a handler the canvas swap moved still detaches from the node it ended up on. Escape now releases before dropping its gesture records, which previously stranded a listener that kept firing on later gestures. The backstop is mouse-only, since it reads the primary-button bit; pen and touch rely on `lostpointercapture` alone. That scope is recorded in the pan-and-zoom spec rather than left implicit, and the API spec now states the observable contract instead of the call-site count. Covered by a headless probe driving all five gestures through capture loss. The source-level guard asserts the single acquisition site; which gestures inherit the policy is asserted behaviorally instead of by grepping for a call count. --- js/src/50_chartview.ts | 45 +++++++ js/src/53_interaction.ts | 139 ++++++++++++---------- js/src/57_viewstate.ts | 9 +- spec/api/interaction.md | 18 +-- spec/design/pan-and-zoom-configuration.md | 13 +- spec/design/view-state.md | 7 +- tests/test_static_client_security.py | 8 +- tests/test_view_state_client.py | 92 +++++++++++--- 8 files changed, 232 insertions(+), 99 deletions(-) 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 35f78792..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,53 +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(); } }); - const finishPanDrag = () => { - if (!drag) return; - const finished = drag; - drag = null; - 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 = () => { - 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, "pointermove", (e) => { - // Pointer capture stops at a browsing-context boundary. If a drag leaves - // an iframe and the mouse is released outside it, Chrome omits pointerup - // here and returns with a buttonless lost-capture/move sequence. - if ((band || drag) && e.isTrusted && e.pointerType === "mouse" && !(e.buttons & 1)) { - endGestureWithoutRelease(); - return; - } + const capture = band?.capture || drag?.capture; + if (capture && !capture.guard(e)) return; if (band) { this._updateBand(band, e); return; } if (drag) { drag.moved = true; @@ -318,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 @@ -373,7 +381,6 @@ Object.assign(ChartView.prototype, { }; this._listen(c, "pointerup", end); this._listen(c, "pointercancel", cancelPointerGesture); - this._listen(c, "lostpointercapture", endGestureWithoutRelease); 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, @@ -415,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; @@ -1164,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; @@ -1188,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 a4a26866..421d8427 100644 --- a/spec/api/interaction.md +++ b/spec/api/interaction.md @@ -299,14 +299,16 @@ 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. When a canvas drag leaves -an embedded iframe and the primary mouse button is released in the parent -document, the iframe may receive no `pointerup`; Chrome reports the lost -capture only when the pointer re-enters. The client finalizes a pan at its last -in-frame view before processing that buttonless move, emits the gesture's one -`end` event, and cancels an unfinished selection/box-zoom whose release -coordinate is unavailable. A buttonless mouse move back over the canvas is the -backstop when the browser omits the lost-capture notification as well. +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 diff --git a/spec/design/pan-and-zoom-configuration.md b/spec/design/pan-and-zoom-configuration.md index 6f534c2b..6800a2e0 100644 --- a/spec/design/pan-and-zoom-configuration.md +++ b/spec/design/pan-and-zoom-configuration.md @@ -617,10 +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. -- A plot-canvas pan released outside an embedding iframe finalizes at its last - in-frame view on lost pointer capture (or the first buttonless mouse re-entry - move over the canvas); that re-entry never applies another pan delta. Axis-band - drags take capture the same way and do not carry this backstop yet. +- 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 63338775..5f4e9b15 100644 --- a/spec/design/view-state.md +++ b/spec/design/view-state.md @@ -371,9 +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:** a plot-canvas pan whose `pointerup` occurs - outside an embedding iframe ends once at the last in-frame view; the - buttonless re-entry move does not mutate ranges. +- **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 cd8e7e70..5e7bbd01 100644 --- a/tests/test_view_state_client.py +++ b/tests/test_view_state_client.py @@ -129,9 +129,11 @@ def test_state_round_trip_patch_semantics_and_clamps(tmp_path: Path) -> None: view._drawNow(); const ranges = () => Object.fromEntries( view._axisIds().map((id) => [id, [...view._axisRange(id)]])); - const rect = view.canvas.getBoundingClientRect(); - const x = rect.left + rect.width / 2; - const y = rect.top + rect.height / 2; + 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 = []; @@ -151,9 +153,9 @@ def test_state_round_trip_patch_semantics_and_clamps(tmp_path: Path) -> None: endEvents.push(event.detail); } }); - const pointer = (type, clientX, clientY, buttons) => { - view.canvas.dispatchEvent(new PointerEvent(type, { - pointerId: 71, + const pointer = (target, type, pointerId, clientX, clientY, buttons) => { + target.dispatchEvent(new PointerEvent(type, { + pointerId, pointerType: "mouse", button: 0, buttons, @@ -165,23 +167,83 @@ def test_state_round_trip_patch_semantics_and_clamps(tmp_path: Path) -> None: })); }; - pointer("pointerdown", x, y, 1); - pointer("pointermove", x + 60, y + 10, 1); + 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("lostpointercapture", x + 60, y + 10, 0); - pointer("pointermove", x + 120, y + 20, 0); + 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({ - reentryDidNotPan: JSON.stringify(ranges()) === JSON.stringify(atBoundary), - finalEndEmittedOnce: endEvents.length === 1, + canvasReentryDidNotPan, + finalEndEmittedForBothPans: endEvents.length === 2, finalEndKeptInteraction: endEvents[0]?.interaction_id != null - && endEvents[0]?.axes?.length > 0, + && 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( @@ -190,12 +252,12 @@ def test_state_round_trip_patch_semantics_and_clamps(tmp_path: Path) -> None: """ -def test_lost_pointer_capture_finishes_pan_before_buttonless_reentry(tmp_path: Path) -> None: +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="lost pointer-capture pan probe", + label="shared pointer-capture loss probe", ) assert result == {key: True for key in result}