Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions js/src/50_chartview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
121 changes: 77 additions & 44 deletions js/src/53_interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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();
});
Expand All @@ -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;
Expand All @@ -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");
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
9 changes: 6 additions & 3 deletions js/src/57_viewstate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions spec/api/interaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions spec/design/pan-and-zoom-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 4 additions & 0 deletions spec/design/view-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 6 additions & 2 deletions tests/test_static_client_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down
Loading
Loading