mirror of https://github.com/zulip/zulip.git
lightbox: Replace lightbox_canvas with PanZoom library.
This PR changes how the Pan & Zoom feature of images displayed in the attachment lightbox are handled. The existing method of using a canvas element is replaced by the Panzoom library (timmywil/panzoom). This library is lightweight and has 0 transitive dependencies. This fixes #20759 where the issue is that the viewport of a zoomed image was not expanding to fill the available space on the page. Switching to this new library also solves several other UX issues: * Images are no longer blurred when in Pan & Zoom mode. * The zoom behavior itself uses focal point zooming: zooming occurs where the cursor is on the image instead of at the center of the image, reducing the need for extra panning. * CSS transitions are used for a more visually pleasing experience when switching images, toggling zoom off, etc. * The library has the potential to open other file types which leaves that option open for us in the future.
This commit is contained in:
parent
97ffe2b123
commit
5f83bc5cfe
|
@ -14,6 +14,7 @@
|
|||
"@formatjs/intl": "^1.9.7",
|
||||
"@giphy/js-components": "^5.0.5",
|
||||
"@giphy/js-fetch-api": "^4.0.1",
|
||||
"@panzoom/panzoom": "^4.4.3",
|
||||
"@uppy/core": "^1.7.1",
|
||||
"@uppy/progress-bar": "^1.3.4",
|
||||
"@uppy/xhr-upload": "^1.4.2",
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import panzoom from "@panzoom/panzoom";
|
||||
import $ from "jquery";
|
||||
|
||||
import render_lightbox_overlay from "../templates/lightbox_overlay.hbs";
|
||||
|
||||
import * as blueslip from "./blueslip";
|
||||
import {LightboxCanvas} from "./lightbox_canvas";
|
||||
import * as message_store from "./message_store";
|
||||
import * as overlays from "./overlays";
|
||||
import * as people from "./people";
|
||||
|
@ -15,6 +15,64 @@ let is_open = false;
|
|||
// memoized instead of being looked up multiple times.
|
||||
const asset_map = new Map();
|
||||
|
||||
export class PanZoomControl {
|
||||
// Class for both initializing and controlling the
|
||||
// the pan/zoom functionality.
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
this.panzoom = panzoom(this.container, {
|
||||
disablePan: true,
|
||||
disableZoom: true,
|
||||
cursor: "auto",
|
||||
});
|
||||
|
||||
// keybinds
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (!overlays.lightbox_open()) {
|
||||
return;
|
||||
}
|
||||
switch (e.key) {
|
||||
case "Z":
|
||||
case "+":
|
||||
this.zoomIn();
|
||||
break;
|
||||
case "z":
|
||||
case "-":
|
||||
this.zoomOut();
|
||||
break;
|
||||
case "v":
|
||||
overlays.close_overlay("lightbox");
|
||||
break;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.panzoom.reset();
|
||||
}
|
||||
|
||||
disablePanZoom() {
|
||||
this.container.removeEventListener("wheel", this.panzoom.zoomWithWheel);
|
||||
this.panzoom.setOptions({disableZoom: true, disablePan: true, cursor: "auto"});
|
||||
this.reset();
|
||||
}
|
||||
|
||||
enablePanZoom() {
|
||||
this.panzoom.setOptions({disableZoom: false, disablePan: false, cursor: "move"});
|
||||
this.container.addEventListener("wheel", this.panzoom.zoomWithWheel);
|
||||
}
|
||||
|
||||
zoomIn() {
|
||||
this.panzoom.zoomIn();
|
||||
}
|
||||
|
||||
zoomOut() {
|
||||
this.panzoom.zoomOut();
|
||||
}
|
||||
}
|
||||
|
||||
export function clear_for_testing() {
|
||||
is_open = false;
|
||||
asset_map.clear();
|
||||
|
@ -51,21 +109,10 @@ function display_image(payload) {
|
|||
$(".player-container").hide();
|
||||
$(".image-actions, .image-description, .download, .lightbox-canvas-trigger").show();
|
||||
|
||||
const lightbox_canvas = $(".lightbox-canvas-trigger").hasClass("enabled");
|
||||
|
||||
if (lightbox_canvas === true) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.dataset.src = payload.source;
|
||||
|
||||
$("#lightbox_overlay .image-preview").html(canvas).show();
|
||||
const photo = new LightboxCanvas(canvas);
|
||||
photo.speed(2.3);
|
||||
} else {
|
||||
const img = new Image();
|
||||
img.src = payload.source;
|
||||
|
||||
$("#lightbox_overlay .image-preview").html(img).show();
|
||||
}
|
||||
const img_container = $("#lightbox_overlay .image-preview > .zoom-element");
|
||||
const img = new Image();
|
||||
img.src = payload.source;
|
||||
img_container.html(img).show();
|
||||
|
||||
$(".image-description .title").text(payload.title || "N/A");
|
||||
$(".image-description .user").text(payload.user);
|
||||
|
@ -282,9 +329,15 @@ export function next() {
|
|||
|
||||
// this is a block of events that are required for the lightbox to work.
|
||||
export function initialize() {
|
||||
// Renders the DOM for the lightbox.
|
||||
const rendered_lightbox_overlay = render_lightbox_overlay();
|
||||
$("body").append(rendered_lightbox_overlay);
|
||||
|
||||
// Bind the pan/zoom control the newly created element.
|
||||
const pan_zoom_control = new PanZoomControl(
|
||||
$("#lightbox_overlay .image-preview > .zoom-element")[0],
|
||||
);
|
||||
|
||||
$("#main_div, #compose .preview_content").on("click", ".message_inline_image a", function (e) {
|
||||
// prevent the link from opening in a new page.
|
||||
e.preventDefault();
|
||||
|
@ -308,6 +361,7 @@ export function initialize() {
|
|||
|
||||
$(".image-list .image.selected").removeClass("selected");
|
||||
$(this).addClass("selected");
|
||||
pan_zoom_control.reset();
|
||||
|
||||
const parentOffset = this.parentNode.clientWidth + this.parentNode.scrollLeft;
|
||||
// this is the left and right of the image compared to its parent.
|
||||
|
@ -341,18 +395,15 @@ export function initialize() {
|
|||
});
|
||||
|
||||
$("#lightbox_overlay").on("click", ".lightbox-canvas-trigger", function () {
|
||||
let $img = $("#lightbox_overlay").find(".image-preview img");
|
||||
|
||||
if ($img.length) {
|
||||
$(this).addClass("enabled");
|
||||
// the `lightbox.open` function will see the enabled class and
|
||||
// enable the `LightboxCanvas` class.
|
||||
open($img);
|
||||
} else {
|
||||
$img = $($("#lightbox_overlay").find(".image-preview canvas")[0].image);
|
||||
const $img = $("#lightbox_overlay").find(".image-preview img");
|
||||
open($img);
|
||||
|
||||
if ($(this).hasClass("enabled")) {
|
||||
pan_zoom_control.disablePanZoom();
|
||||
$(this).removeClass("enabled");
|
||||
open($img);
|
||||
} else {
|
||||
pan_zoom_control.enablePanZoom();
|
||||
$(this).addClass("enabled");
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -1,279 +0,0 @@
|
|||
import * as blueslip from "./blueslip";
|
||||
import * as overlays from "./overlays";
|
||||
|
||||
const funcs = {
|
||||
setZoom(meta, zoom) {
|
||||
// condition to handle zooming event by zoom hotkeys
|
||||
if (zoom === "+") {
|
||||
zoom = meta.zoom * 1.2;
|
||||
} else if (zoom === "-") {
|
||||
zoom = meta.zoom / 1.2;
|
||||
}
|
||||
// make sure the zoom is above 1 and below the maxZoom.
|
||||
meta.zoom = Math.min(Math.max(zoom, 1), meta.maxZoom);
|
||||
},
|
||||
|
||||
// this is a function given a canvas that attaches all of the events
|
||||
// required to pan and zoom.
|
||||
attachEvents(canvas, context, meta) {
|
||||
let mousedown = false;
|
||||
|
||||
// wheelEvent.deltaMode is a value that describes what the unit is
|
||||
// for the `deltaX`, `deltaY`, and `deltaZ` properties.
|
||||
const DELTA_MODE = {
|
||||
PIXEL: 0,
|
||||
LINE: 1,
|
||||
PAGE: 2,
|
||||
};
|
||||
|
||||
// use the wheel event rather than scroll because this isn't
|
||||
// actually an element that can scroll. The wheel event will
|
||||
// detect the *gesture* of scrolling over an element, without actually
|
||||
// worrying about scrollable content.
|
||||
canvas.addEventListener("wheel", (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// this is to reverse scrolling directions for the image.
|
||||
let delta = meta.direction * e.deltaY;
|
||||
|
||||
if (e.deltaMode === DELTA_MODE.LINE) {
|
||||
// the vertical height in pixels of an approximate line.
|
||||
delta *= 15;
|
||||
}
|
||||
|
||||
if (e.deltaMode === DELTA_MODE.PAGE) {
|
||||
// the vertical height in pixels of an approximate page.
|
||||
delta *= 300;
|
||||
}
|
||||
|
||||
// this is calculated as the user defined speed times the normalizer
|
||||
// (which just is what it takes to take the raw delta and transform
|
||||
// it to a normal speed), multiply it against the current zoom.
|
||||
// Example:
|
||||
// delta = 8
|
||||
// normalizedDelta = delta * (1 / 20) * 1 = 0.4
|
||||
// zoom = zoom * (0.4 / 100) + 1
|
||||
const zoom =
|
||||
meta.zoom * ((meta.speed * meta.internalSpeedMultiplier * delta) / 100 + 1);
|
||||
|
||||
funcs.setZoom(meta, zoom);
|
||||
funcs.displayImage(canvas, context, meta);
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// the only valid mousedown events should originate inside of the
|
||||
// canvas.
|
||||
canvas.addEventListener("mousedown", () => {
|
||||
mousedown = true;
|
||||
});
|
||||
|
||||
// on mousemove, actually run the pan events.
|
||||
canvas.addEventListener("mousemove", (e) => {
|
||||
// to pan, there must be mousedown and mousemove, check if valid.
|
||||
if (mousedown === true) {
|
||||
// find the percent of movement relative to the canvas width
|
||||
// since e.movementX, e.movementY are in px.
|
||||
const percentMovement = {
|
||||
x: e.movementX / canvas.width,
|
||||
y: e.movementY / canvas.height,
|
||||
};
|
||||
|
||||
// add the percentMovement to the meta coordinates but divide
|
||||
// out by the zoom ratio because when zoomed in 10x for example
|
||||
// moving the photo by 1% will appear like 10% on the <canvas>.
|
||||
meta.coords.x += (percentMovement.x * 2) / meta.zoom;
|
||||
meta.coords.y += (percentMovement.y * 2) / meta.zoom;
|
||||
|
||||
// redraw the image.
|
||||
funcs.displayImage(canvas, context, meta);
|
||||
}
|
||||
});
|
||||
|
||||
// event listener to handle zoom in and out from using keyboard keys z/Z and +/-
|
||||
// in the canvas
|
||||
// these hotkeys are not implemented in static/js/hotkey.js as the code in
|
||||
// static/js/lightbox_canvas.js and static/js/lightbox.js isn't written a way
|
||||
// that the LightboxCanvas instance created in lightbox.js can be
|
||||
// accessed from hotkey.js. Major code refactoring is required in lightbox.js
|
||||
// to implement these keyboard shortcuts in hotkey.js
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (!overlays.lightbox_open()) {
|
||||
return;
|
||||
}
|
||||
switch (e.key) {
|
||||
case "Z":
|
||||
case "+":
|
||||
funcs.setZoom(meta, "+");
|
||||
funcs.displayImage(canvas, context, meta);
|
||||
break;
|
||||
case "z":
|
||||
case "-":
|
||||
funcs.setZoom(meta, "-");
|
||||
funcs.displayImage(canvas, context, meta);
|
||||
break;
|
||||
case "v":
|
||||
overlays.close_overlay("lightbox");
|
||||
break;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// make sure that when the mousedown is lifted on <canvas>to prevent
|
||||
// panning events.
|
||||
canvas.addEventListener("mouseup", () => {
|
||||
mousedown = false;
|
||||
});
|
||||
|
||||
// do so on the document.body as well, though depending on the infra,
|
||||
// these are less reliable as preventDefault may prevent these events
|
||||
// from propagating all the way to the <body>.
|
||||
document.body.addEventListener("mouseup", function body_mouseup() {
|
||||
if (document.body.contains(canvas)) {
|
||||
mousedown = false;
|
||||
} else {
|
||||
document.body.removeEventListener("mouseup", body_mouseup);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("resize", function window_resize() {
|
||||
if (document.body.contains(canvas)) {
|
||||
funcs.sizeCanvas(canvas, meta);
|
||||
funcs.displayImage(canvas, context, meta);
|
||||
} else {
|
||||
window.removeEventListener("resize", window_resize);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
imageRatio(image) {
|
||||
return image.naturalWidth / image.naturalHeight;
|
||||
},
|
||||
|
||||
displayImage(canvas, context, meta) {
|
||||
meta.coords.x = Math.max(1 / (meta.zoom * 2), meta.coords.x);
|
||||
meta.coords.x = Math.min(1 - 1 / (meta.zoom * 2), meta.coords.x);
|
||||
|
||||
meta.coords.y = Math.max(1 / (meta.zoom * 2), meta.coords.y);
|
||||
meta.coords.y = Math.min(1 - 1 / (meta.zoom * 2), meta.coords.y);
|
||||
|
||||
const c = {
|
||||
x: meta.coords.x - 1,
|
||||
y: meta.coords.y - 1,
|
||||
};
|
||||
|
||||
const x = meta.zoom * c.x * canvas.width + canvas.width / 2;
|
||||
const y = meta.zoom * c.y * canvas.height + canvas.height / 2;
|
||||
const w = canvas.width * meta.zoom;
|
||||
const h = canvas.height * meta.zoom;
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.imageSmoothingEnabled = false;
|
||||
|
||||
context.drawImage(meta.image, x, y, w, h);
|
||||
},
|
||||
|
||||
// the `sizeCanvas` method figures out the appropriate bounding box for
|
||||
// the canvas given a parent that has constraints.
|
||||
// for example, if a photo has a ration of 1.5:1 (w:h), and the parent
|
||||
// box is 1:1 respectively, we want to stretch the photo to be as large
|
||||
// as we can, which means that we check if having the photo width = 100%
|
||||
// means that the height is less than 100% of the parent height. If so,
|
||||
// then we size the photo as w = 100%, h = 100% / 1.5.
|
||||
sizeCanvas(canvas, meta) {
|
||||
if (canvas.parentNode === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof meta.resize_handler === "function") {
|
||||
meta.resize_handler(canvas);
|
||||
}
|
||||
|
||||
const parent = {
|
||||
width: canvas.parentNode.clientWidth,
|
||||
height: canvas.parentNode.clientHeight,
|
||||
};
|
||||
|
||||
if (parent.height * meta.ratio > parent.width) {
|
||||
canvas.width = parent.width * 2;
|
||||
canvas.style.width = parent.width + "px";
|
||||
|
||||
canvas.height = (parent.width / meta.ratio) * 2;
|
||||
canvas.style.height = parent.width / meta.ratio + "px";
|
||||
} else {
|
||||
canvas.height = parent.height * 2;
|
||||
canvas.style.height = parent.height + "px";
|
||||
|
||||
canvas.width = parent.height * meta.ratio * 2;
|
||||
canvas.style.width = parent.height * meta.ratio + "px";
|
||||
}
|
||||
|
||||
blueslip.warn("Please specify a 'data-width' or 'data-height' argument for canvas.");
|
||||
},
|
||||
};
|
||||
|
||||
export class LightboxCanvas {
|
||||
meta = {
|
||||
direction: -1,
|
||||
zoom: 1,
|
||||
image: null,
|
||||
coords: {
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
},
|
||||
speed: 1,
|
||||
// this is to normalize the speed to what I would consider to be
|
||||
// "standard" zoom speed.
|
||||
internalSpeedMultiplier: 0.05,
|
||||
maxZoom: 10,
|
||||
};
|
||||
|
||||
constructor(el) {
|
||||
if (el instanceof Node) {
|
||||
this.canvas = el;
|
||||
} else if (typeof el === "string") {
|
||||
this.canvas = document.querySelector(el);
|
||||
} else {
|
||||
throw new TypeError("'LightboxCanvas' accepts either string selector or node.");
|
||||
}
|
||||
|
||||
this.context = this.canvas.getContext("2d");
|
||||
|
||||
this.meta.image = new Image();
|
||||
this.meta.image.src = this.canvas.dataset.src;
|
||||
this.meta.image.addEventListener("load", () => {
|
||||
this.meta.ratio = funcs.imageRatio(this.meta.image);
|
||||
|
||||
funcs.sizeCanvas(this.canvas, this.meta);
|
||||
funcs.displayImage(this.canvas, this.context, this.meta);
|
||||
});
|
||||
|
||||
this.canvas.image = this.meta.image;
|
||||
|
||||
funcs.attachEvents(this.canvas, this.context, this.meta);
|
||||
}
|
||||
|
||||
// set the speed at which scrolling zooms in on a photo.
|
||||
speed(speed) {
|
||||
this.meta.speed = speed;
|
||||
}
|
||||
|
||||
// set the max zoom of the `LightboxCanvas` canvas as a mult of the total width.
|
||||
maxZoom(maxZoom) {
|
||||
this.meta.maxZoom = maxZoom;
|
||||
}
|
||||
|
||||
reverseScrollDirection() {
|
||||
this.meta.direction = 1;
|
||||
}
|
||||
|
||||
setZoom(zoom) {
|
||||
funcs.setZoom(this.meta, zoom);
|
||||
funcs.displayImage(this.canvas, this.context, this.meta);
|
||||
}
|
||||
|
||||
resize(callback) {
|
||||
this.meta.resize_handler = callback;
|
||||
}
|
||||
}
|
|
@ -36,6 +36,14 @@
|
|||
canvas {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.zoom-element {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.exit {
|
||||
|
|
|
@ -16,7 +16,9 @@
|
|||
<div class="clear-float"></div>
|
||||
</div>
|
||||
|
||||
<div class="image-preview overlay-content no-select"></div>
|
||||
<div class="image-preview overlay-content no-select">
|
||||
<div class="zoom-element"></div>
|
||||
</div>
|
||||
<div class="player-container"></div>
|
||||
<div class="center">
|
||||
<div class="arrow no-select" data-direction="prev"><</div>
|
||||
|
|
|
@ -93,7 +93,6 @@ EXEMPT_FILES = make_set(
|
|||
"static/js/info_overlay.js",
|
||||
"static/js/invite.js",
|
||||
"static/js/lightbox.js",
|
||||
"static/js/lightbox_canvas.js",
|
||||
"static/js/list_util.ts",
|
||||
"static/js/loading.ts",
|
||||
"static/js/local_message.js",
|
||||
|
|
|
@ -48,4 +48,4 @@ API_FEATURE_LEVEL = 115
|
|||
# historical commits sharing the same major version, in which case a
|
||||
# minor version bump suffices.
|
||||
|
||||
PROVISION_VERSION = "173.3"
|
||||
PROVISION_VERSION = "173.4"
|
||||
|
|
|
@ -1377,6 +1377,11 @@
|
|||
mkdirp "^1.0.4"
|
||||
rimraf "^3.0.2"
|
||||
|
||||
"@panzoom/panzoom@^4.4.3":
|
||||
version "4.4.3"
|
||||
resolved "https://registry.yarnpkg.com/@panzoom/panzoom/-/panzoom-4.4.3.tgz#439ef0c3eba1cba0ad9b661fda5961aa2e2eec64"
|
||||
integrity sha512-fTAr7/bc9ukvWKxxqdoAuIhKhvu6TwuNiGcA0N3lrSj5OZGlISGLXcSZZyN7kgqH/6icYS7b18UT/Iq/W2rTOA==
|
||||
|
||||
"@plotly/d3-sankey-circular@0.33.1":
|
||||
version "0.33.1"
|
||||
resolved "https://registry.yarnpkg.com/@plotly/d3-sankey-circular/-/d3-sankey-circular-0.33.1.tgz#15d1e0337e0e4b1135bdf0e2195c88adacace1a7"
|
||||
|
|
Loading…
Reference in New Issue