mirror of
https://github.com/rstudio/shiny.git
synced 2026-01-09 15:08:04 -05:00
442 lines
14 KiB
TypeScript
442 lines
14 KiB
TypeScript
import $ from "jquery";
|
|
import type { InputRatePolicy } from "../inputPolicies";
|
|
import { shinySetInputValue } from "../shiny/initedMethods";
|
|
import { Debouncer, Throttler } from "../time";
|
|
import type { Bounds, BoundsCss, BrushOpts } from "./createBrush";
|
|
import { createBrush } from "./createBrush";
|
|
import type { Offset } from "./findbox";
|
|
import { findImageOutputs } from "./imageBindingUtils";
|
|
import type { Coordmap } from "./initCoordmap";
|
|
import type { Panel } from "./initPanelScales";
|
|
|
|
// ----------------------------------------------------------
|
|
// Handler creators for click, hover, brush.
|
|
// Each of these returns an object with a few public members. These public
|
|
// members are callbacks that are meant to be bound to events on $el with
|
|
// the same name (like 'mousedown').
|
|
// ----------------------------------------------------------
|
|
|
|
type CreateHandler = {
|
|
mousemove?: (e: JQuery.MouseMoveEvent) => void;
|
|
mouseout?: (e: JQuery.MouseOutEvent) => void;
|
|
mousedown?: (e: JQuery.MouseDownEvent) => void;
|
|
onResetImg: () => void;
|
|
onResize: ((e: JQuery.ResizeEvent) => void) | null;
|
|
};
|
|
|
|
type BrushInfo = {
|
|
xmin: number;
|
|
xmax: number;
|
|
ymin: number;
|
|
ymax: number;
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
coords_css?: BoundsCss;
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
coords_img?: Bounds;
|
|
x?: number;
|
|
y?: number;
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
img_css_ratio?: Offset;
|
|
mapping?: Panel["mapping"];
|
|
domain?: Panel["domain"];
|
|
range?: Panel["range"];
|
|
log?: Panel["log"];
|
|
direction?: BrushOpts["brushDirection"];
|
|
brushId?: string;
|
|
outputId?: string;
|
|
};
|
|
|
|
type InputId = Parameters<Coordmap["mouseCoordinateSender"]>[0];
|
|
type Clip = Parameters<Coordmap["mouseCoordinateSender"]>[1];
|
|
type NullOutside = Parameters<Coordmap["mouseCoordinateSender"]>[2];
|
|
|
|
function createClickHandler(
|
|
inputId: InputId,
|
|
clip: Clip,
|
|
coordmap: Coordmap,
|
|
): CreateHandler {
|
|
const clickInfoSender = coordmap.mouseCoordinateSender(inputId, clip);
|
|
|
|
// Send initial (null) value on creation.
|
|
clickInfoSender(null);
|
|
|
|
return {
|
|
mousedown: function (e) {
|
|
// Listen for left mouse button only
|
|
if (e.which !== 1) return;
|
|
clickInfoSender(e);
|
|
},
|
|
onResetImg: function () {
|
|
clickInfoSender(null);
|
|
},
|
|
onResize: null,
|
|
};
|
|
}
|
|
|
|
function createHoverHandler(
|
|
inputId: InputId,
|
|
delay: number,
|
|
delayType: string | "throttle",
|
|
clip: Clip,
|
|
nullOutside: NullOutside,
|
|
coordmap: Coordmap,
|
|
): CreateHandler {
|
|
const sendHoverInfo = coordmap.mouseCoordinateSender(
|
|
inputId,
|
|
clip,
|
|
nullOutside,
|
|
);
|
|
|
|
let hoverInfoSender: InputRatePolicy<typeof sendHoverInfo>;
|
|
|
|
if (delayType === "throttle")
|
|
hoverInfoSender = new Throttler(null, sendHoverInfo, delay);
|
|
else hoverInfoSender = new Debouncer(null, sendHoverInfo, delay);
|
|
|
|
// Send initial (null) value on creation.
|
|
hoverInfoSender.immediateCall(null);
|
|
|
|
// What to do when mouse exits the image
|
|
let mouseout: () => void;
|
|
|
|
if (nullOutside)
|
|
mouseout = function () {
|
|
hoverInfoSender.normalCall(null);
|
|
};
|
|
else
|
|
mouseout = function () {
|
|
// do nothing
|
|
};
|
|
|
|
return {
|
|
mousemove: function (e) {
|
|
hoverInfoSender.normalCall(e);
|
|
},
|
|
mouseout: mouseout,
|
|
onResetImg: function () {
|
|
hoverInfoSender.immediateCall(null);
|
|
},
|
|
onResize: null,
|
|
};
|
|
}
|
|
|
|
// Returns a brush handler object. This has three public functions:
|
|
// mousedown, mousemove, and onResetImg.
|
|
function createBrushHandler(
|
|
inputId: InputId,
|
|
$el: JQuery<HTMLElement>,
|
|
opts: BrushOpts,
|
|
coordmap: Coordmap,
|
|
outputId: BrushInfo["outputId"],
|
|
): CreateHandler {
|
|
// Parameter: expand the area in which a brush can be started, by this
|
|
// many pixels in all directions. (This should probably be a brush option)
|
|
const expandPixels = 20;
|
|
|
|
// Represents the state of the brush
|
|
const brush = createBrush($el, opts, coordmap, expandPixels);
|
|
|
|
// Brush IDs can span multiple image/plot outputs. When an output is brushed,
|
|
// if a brush with the same ID is active on a different image/plot, it must
|
|
// be dismissed (but without sending any data to the server). We implement
|
|
// this by sending the shiny-internal:brushed event to all plots, and letting
|
|
// each plot decide for itself what to do.
|
|
//
|
|
// The decision to have the event sent to each plot (as opposed to a single
|
|
// event triggered on, say, the document) was made to make cleanup easier;
|
|
// listening on an event on the document would prevent garbage collection
|
|
// of plot outputs that are removed from the document.
|
|
$el.on("shiny-internal:brushed.image_output", function (e, coords) {
|
|
// If the new brush shares our ID but not our output element ID, we
|
|
// need to clear our brush (if any).
|
|
if (coords.brushId === inputId && coords.outputId !== outputId) {
|
|
$el.data("mostRecentBrush", false);
|
|
brush.reset();
|
|
}
|
|
});
|
|
|
|
// Set cursor to one of 7 styles. We need to set the cursor on the whole
|
|
// el instead of the brush div, because the brush div has
|
|
// 'pointer-events:none' so that it won't intercept pointer events.
|
|
// If `style` is null, don't add a cursor style.
|
|
function setCursorStyle(
|
|
style:
|
|
| "crosshair"
|
|
| "ew-resize"
|
|
| "grabbable"
|
|
| "grabbing"
|
|
| "nesw-resize"
|
|
| "ns-resize"
|
|
| "nwse-resize"
|
|
| null,
|
|
) {
|
|
$el.removeClass(
|
|
"crosshair grabbable grabbing ns-resize ew-resize nesw-resize nwse-resize",
|
|
);
|
|
|
|
if (style) $el.addClass(style);
|
|
}
|
|
|
|
function sendBrushInfo() {
|
|
const coords: BrushInfo = brush.boundsData();
|
|
|
|
// We're in a new or reset state
|
|
if (isNaN(coords.xmin)) {
|
|
shinySetInputValue(inputId, null);
|
|
// Must tell other brushes to clear.
|
|
findImageOutputs(document.documentElement).trigger(
|
|
"shiny-internal:brushed",
|
|
{
|
|
brushId: inputId,
|
|
outputId: null,
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
const panel = brush.getPanel()!;
|
|
|
|
// Add the panel (facet) variables, if present
|
|
$.extend(coords, panel.panel_vars);
|
|
|
|
coords.coords_css = brush.boundsCss();
|
|
|
|
coords.coords_img = coordmap.scaleCssToImg(coords.coords_css);
|
|
|
|
coords.img_css_ratio = coordmap.cssToImgScalingRatio();
|
|
|
|
// Add variable name mappings
|
|
coords.mapping = panel.mapping;
|
|
|
|
// Add scaling information
|
|
coords.domain = panel.domain;
|
|
coords.range = panel.range;
|
|
coords.log = panel.log;
|
|
|
|
coords.direction = opts.brushDirection;
|
|
|
|
coords.brushId = inputId;
|
|
coords.outputId = outputId;
|
|
|
|
// Send data to server
|
|
shinySetInputValue(inputId, coords);
|
|
|
|
$el.data("mostRecentBrush", true);
|
|
findImageOutputs(document.documentElement).trigger(
|
|
"shiny-internal:brushed",
|
|
coords,
|
|
);
|
|
}
|
|
|
|
let brushInfoSender:
|
|
| Debouncer<typeof sendBrushInfo>
|
|
| Throttler<typeof sendBrushInfo>;
|
|
|
|
if (opts.brushDelayType === "throttle") {
|
|
brushInfoSender = new Throttler(null, sendBrushInfo, opts.brushDelay);
|
|
} else {
|
|
brushInfoSender = new Debouncer(null, sendBrushInfo, opts.brushDelay);
|
|
}
|
|
|
|
// Send initial (null) value on creation.
|
|
if (!brush.hasOldBrush()) {
|
|
brushInfoSender.immediateCall();
|
|
}
|
|
|
|
function mousedown(e: JQuery.MouseDownEvent) {
|
|
// This can happen when mousedown inside the graphic, then mouseup
|
|
// outside, then mousedown inside. Just ignore the second
|
|
// mousedown.
|
|
if (brush.isBrushing() || brush.isDragging() || brush.isResizing()) return;
|
|
|
|
// Listen for left mouse button only
|
|
if (e.which !== 1) return;
|
|
|
|
// In general, brush uses css pixels, and coordmap uses img pixels.
|
|
const offsetCss = coordmap.mouseOffsetCss(e);
|
|
|
|
// Ignore mousedown events outside of plotting region, expanded by
|
|
// a number of pixels specified in expandPixels.
|
|
if (opts.brushClip && !coordmap.isInPanelCss(offsetCss, expandPixels))
|
|
return;
|
|
|
|
brush.up({ x: NaN, y: NaN });
|
|
brush.down(offsetCss);
|
|
|
|
if (brush.isInResizeArea(offsetCss)) {
|
|
// @ts-expect-error; TODO-barret; Remove the variable? it is not used
|
|
brush.startResizing(offsetCss);
|
|
|
|
// Attach the move and up handlers to the window so that they respond
|
|
// even when the mouse is moved outside of the image.
|
|
$(document)
|
|
.on("mousemove.image_brush", mousemoveResizing)
|
|
.on("mouseup.image_brush", mouseupResizing);
|
|
} else if (brush.isInsideBrush(offsetCss)) {
|
|
// @ts-expect-error; TODO-barret this variable is not respected
|
|
brush.startDragging(offsetCss);
|
|
setCursorStyle("grabbing");
|
|
|
|
// Attach the move and up handlers to the window so that they respond
|
|
// even when the mouse is moved outside of the image.
|
|
$(document)
|
|
.on("mousemove.image_brush", mousemoveDragging)
|
|
.on("mouseup.image_brush", mouseupDragging);
|
|
} else {
|
|
const panel = coordmap.getPanelCss(offsetCss, expandPixels);
|
|
|
|
// @ts-expect-error; TODO-barret start brushing does not take any args; Either change the function to ignore, or do not send to function;
|
|
brush.startBrushing(panel.clipImg(coordmap.scaleCssToImg(offsetCss)));
|
|
|
|
// Attach the move and up handlers to the window so that they respond
|
|
// even when the mouse is moved outside of the image.
|
|
$(document)
|
|
.on("mousemove.image_brush", mousemoveBrushing)
|
|
.on("mouseup.image_brush", mouseupBrushing);
|
|
}
|
|
}
|
|
|
|
// This sets the cursor style when it's in the el
|
|
function mousemove(e: JQuery.MouseMoveEvent) {
|
|
// In general, brush uses css pixels, and coordmap uses img pixels.
|
|
const offsetCss = coordmap.mouseOffsetCss(e);
|
|
|
|
if (!(brush.isBrushing() || brush.isDragging() || brush.isResizing())) {
|
|
// Set the cursor depending on where it is
|
|
if (brush.isInResizeArea(offsetCss)) {
|
|
const r = brush.whichResizeSides(offsetCss);
|
|
|
|
if ((r.left && r.top) || (r.right && r.bottom)) {
|
|
setCursorStyle("nwse-resize");
|
|
} else if ((r.left && r.bottom) || (r.right && r.top)) {
|
|
setCursorStyle("nesw-resize");
|
|
} else if (r.left || r.right) {
|
|
setCursorStyle("ew-resize");
|
|
} else if (r.top || r.bottom) {
|
|
setCursorStyle("ns-resize");
|
|
}
|
|
} else if (brush.isInsideBrush(offsetCss)) {
|
|
setCursorStyle("grabbable");
|
|
} else if (coordmap.isInPanelCss(offsetCss, expandPixels)) {
|
|
setCursorStyle("crosshair");
|
|
} else {
|
|
setCursorStyle(null);
|
|
}
|
|
}
|
|
}
|
|
|
|
// mousemove handlers while brushing or dragging
|
|
function mousemoveBrushing(e: JQuery.MouseMoveEvent) {
|
|
brush.brushTo(coordmap.mouseOffsetCss(e));
|
|
brushInfoSender.normalCall();
|
|
}
|
|
|
|
function mousemoveDragging(e: JQuery.MouseMoveEvent) {
|
|
brush.dragTo(coordmap.mouseOffsetCss(e));
|
|
brushInfoSender.normalCall();
|
|
}
|
|
|
|
function mousemoveResizing(e: JQuery.MouseMoveEvent) {
|
|
brush.resizeTo(coordmap.mouseOffsetCss(e));
|
|
brushInfoSender.normalCall();
|
|
}
|
|
|
|
// mouseup handlers while brushing or dragging
|
|
function mouseupBrushing(e: JQuery.MouseUpEvent) {
|
|
// Listen for left mouse button only
|
|
if (e.which !== 1) return;
|
|
|
|
$(document).off("mousemove.image_brush").off("mouseup.image_brush");
|
|
|
|
brush.up(coordmap.mouseOffsetCss(e));
|
|
|
|
brush.stopBrushing();
|
|
setCursorStyle("crosshair");
|
|
|
|
// If the brush didn't go anywhere, hide the brush, clear value,
|
|
// and return.
|
|
if (brush.down().x === brush.up().x && brush.down().y === brush.up().y) {
|
|
brush.reset();
|
|
brushInfoSender.immediateCall();
|
|
return;
|
|
}
|
|
|
|
// Send info immediately on mouseup, but only if needed. If we don't
|
|
// do the pending check, we might send the same data twice (with
|
|
// with difference nonce).
|
|
if (brushInfoSender.isPending()) brushInfoSender.immediateCall();
|
|
}
|
|
|
|
function mouseupDragging(e: JQuery.MouseUpEvent) {
|
|
// Listen for left mouse button only
|
|
if (e.which !== 1) return;
|
|
|
|
$(document).off("mousemove.image_brush").off("mouseup.image_brush");
|
|
|
|
brush.up(coordmap.mouseOffsetCss(e));
|
|
|
|
brush.stopDragging();
|
|
setCursorStyle("grabbable");
|
|
|
|
if (brushInfoSender.isPending()) brushInfoSender.immediateCall();
|
|
}
|
|
|
|
function mouseupResizing(e: JQuery.MouseUpEvent) {
|
|
// Listen for left mouse button only
|
|
if (e.which !== 1) return;
|
|
|
|
$(document).off("mousemove.image_brush").off("mouseup.image_brush");
|
|
|
|
brush.up(coordmap.mouseOffsetCss(e));
|
|
brush.stopResizing();
|
|
|
|
if (brushInfoSender.isPending()) brushInfoSender.immediateCall();
|
|
}
|
|
|
|
// Brush maintenance: When an image is re-rendered, the brush must either
|
|
// be removed (if brushResetOnNew) or imported (if !brushResetOnNew). The
|
|
// "mostRecentBrush" bit is to ensure that when multiple outputs share the
|
|
// same brush ID, inactive brushes don't send null values up to the server.
|
|
|
|
// This should be called when the img (not the el) is reset
|
|
function onResetImg() {
|
|
if (opts.brushResetOnNew) {
|
|
if ($el.data("mostRecentBrush")) {
|
|
brush.reset();
|
|
brushInfoSender.immediateCall();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!opts.brushResetOnNew) {
|
|
if ($el.data("mostRecentBrush")) {
|
|
// Importing an old brush must happen after the image data has loaded
|
|
// and the <img> DOM element has the updated size. If importOldBrush()
|
|
// is called before this happens, then the css-img coordinate mappings
|
|
// will give the wrong result, and the brush will have the wrong
|
|
// position.
|
|
//
|
|
// jcheng 09/26/2018: This used to happen in img.onLoad, but recently
|
|
// we moved to all brush initialization moving to img.onLoad so this
|
|
// logic can be executed inline.
|
|
brush.importOldBrush();
|
|
brushInfoSender.immediateCall();
|
|
}
|
|
}
|
|
|
|
function onResize() {
|
|
brush.onResize();
|
|
brushInfoSender.immediateCall();
|
|
}
|
|
|
|
return {
|
|
mousedown: mousedown,
|
|
mousemove: mousemove,
|
|
onResetImg: onResetImg,
|
|
onResize: onResize,
|
|
};
|
|
}
|
|
|
|
export { createBrushHandler, createClickHandler, createHoverHandler };
|
|
export type { BrushInfo };
|