/
githubmirror
/
zulip
Обзор
Документация
Войти
/
githubmirror
/
zulip
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
web/src/lightbox.ts
845 строк
32 KB
Anders Kaseorg
eslint: Fix unicorn/no-unsafe-string-replacement.
29 июл 2026, 02:35
29 июл 2026, 02:35
a2f20bb
Код
Авторство
О чём код?
import {$} from "jquery"; import assert from "minimalistic-assert"; import panzoom from "panzoom"; import type {PanZoom} from "panzoom"; import render_lightbox_overlay from "../templates/lightbox_overlay.hbs"; import * as blueslip from "./blueslip.ts"; import * as message_store from "./message_store.ts"; import * as overlays from "./overlays.ts"; import * as people from "./people.ts"; import * as popovers from "./popovers.ts"; import * as rows from "./rows.ts"; import * as util from "./util.ts"; type MediaType = "image" | "inline-video" | "youtube-video" | "vimeo-video" | "embed-video"; type Media = { // Sender's full name user: string | undefined; // aria-label (or url, as fallback) of media title: string; type: MediaType; // URL to use in message list or carousel preview: string; // URL to use for display in the lightbox source: string; // URL to the original resource for download url: string; original_width_px: number | undefined; original_height_px: number | undefined; }; let is_open = false; let open_image: ($media: JQuery<HTMLImageElement>) => void; let open_video: ($media: JQuery<HTMLMediaElement>) => void; let overlay_restore_callback: (() => void) | undefined; // The asset map is a map of all retrieved images and YouTube videos that are memoized instead of // being looked up multiple times. It is keyed by the message id with each value being the // message's assets map keyed by the asset's "canonical URL," which is likely the `src` used in // the message feed, but for thumbnailed images is the full-resolution original URL. const asset_map = new Map<number, Map<string, Media>>(); export class PanZoomControl { // Class for both initializing and controlling the // the pan/zoom functionality. container: HTMLElement; panzoom: PanZoom; constructor(container: HTMLElement) { this.container = container; this.panzoom = panzoom(this.container, { smoothScroll: false, // Ideally we'd set `bounds` here, but that feature is // currently broken upstream. See // https://github.com/anvaka/panzoom/issues/112. maxZoom: 64, minZoom: 1 / 16, filterKey() { // Disable the library's built in keybindings return true; }, }); // The following events are necessary to prevent the click event // firing where the user "unclicks" at the end of the drag, which // was causing accidental overlay closes in some situations. this.panzoom.on("pan", () => { // Marks this overlay as needing to stay open. $("#lightbox_overlay").data("noclose", true); // Enable the panzoom reset button. $("#lightbox_overlay .lightbox-zoom-reset").removeClass("disabled"); }); this.panzoom.on("panend", (e: PanZoom) => { // Check if the image has been panned out of view. this.constrainImage(e); // Don't remove the noclose attribute on this overlay until after paint, // otherwise it will be removed too early and close the lightbox // unintentionally. setTimeout(() => { $("#lightbox_overlay").data("noclose", false); }, 0); }); this.panzoom.on("zoom", (e: PanZoom) => { // Check if the image has been zoomed out of view. // We are using the zoom event instead of zoomend because the zoomend // event does not fire when using the scroll wheel or pinch to zoom. // https://github.com/anvaka/panzoom/issues/250 this.constrainImage(e); // Enable the panzoom reset button. $("#lightbox_overlay .lightbox-zoom-reset").removeClass("disabled"); }); // key bindings document.addEventListener("keydown", (e) => { if (!overlays.lightbox_open()) { return; } switch (e.key) { case "Z": case "+": this.zoomIn(); break; case "z": case "-": this.zoomOut(); break; } e.preventDefault(); e.stopPropagation(); }); } constrainImage(e: PanZoom): void { if (!this.isActive()) { return; } // Instead of using panzoom's built in bounds option which was buggy // at the time of this writing, we act on pan/zoom events and move the // image back in to view if it is moved beyond the image-preview container. // See https://github.com/anvaka/panzoom/issues/112 for upstream discussion. const {scale, x, y} = e.getTransform(); const image_width = util.the($(".zoom-element > img")).clientWidth * scale; const image_height = util.the($(".zoom-element > img")).clientHeight * scale; const zoom_element_width = util.the($(".zoom-element")).clientWidth * scale; const zoom_element_height = util.the($(".zoom-element")).clientHeight * scale; const max_translate_x = util.the($(".image-preview")).clientWidth; const max_translate_y = util.the($(".image-preview")).clientHeight; // When the image is dragged out of the image-preview container // (max_translate) it will be "snapped" back so that the number // of pixels set below will remain visible in the dimension it was dragged. const return_buffer = 50 * scale; // Move the image if it gets within this many pixels of the edge. const border = 20; const zoom_border_width = (zoom_element_width - image_width) / 2 + image_width; const zoom_border_height = (zoom_element_height - image_height) / 2 + image_height; const modified_x = x + zoom_border_width; const modified_y = y + zoom_border_height; if (modified_x < 0 + border) { // Image has been dragged beyond the LEFT of the view. const move_by = -modified_x; e.moveBy(move_by + return_buffer, 0, false); } else if (modified_x - image_width > max_translate_x - border) { // Image has been dragged beyond the RIGHT of the view. const move_by = modified_x - max_translate_x - image_width; e.moveBy(-move_by - return_buffer, 0, false); } if (modified_y < 0 + border) { // Image has been dragged beyond the TOP of the view. const move_by = -modified_y; e.moveBy(0, move_by + return_buffer, false); } else if (modified_y - image_height > max_translate_y - border) { // Image has been dragged beyond the BOTTOM of the view. const move_by = modified_y - max_translate_y - image_height; e.moveBy(0, -move_by - return_buffer, false); } } reset(): void { // To reset the panzoom state, we want to: // Reset zoom to the initial state. this.panzoom.zoomAbs(0, 0, 1); // Re-center the image. this.panzoom.moveTo(0, 0); // Always ensure that the overlay is available for click to close. // This way we don't rely on the above events firing panend, // of which there is some anecdotal evidence that suggests they // might be prone to race conditions. $("#lightbox_overlay").data("noclose", false); // Disable the lightbox reset button to reflect the state that // the image has not been panned or zoomed. $("#lightbox_overlay .lightbox-zoom-reset").addClass("disabled"); } zoomIn(): void { if (!this.isActive()) { return; } const w = $(".image-preview").width()!; const h = $(".image-preview").height()!; this.panzoom.smoothZoom(w / 2, h / 2, Math.SQRT2); } zoomOut(): void { if (!this.isActive()) { return; } const w = $(".image-preview").width()!; const h = $(".image-preview").height()!; this.panzoom.smoothZoom(w / 2, h / 2, Math.SQRT1_2); } isActive(): boolean { return $(".image-preview .zoom-element img").length > 0; } } export function clear_for_testing(): void { is_open = false; asset_map.clear(); overlay_restore_callback = undefined; } export function invalidate_asset_map_of_message(message_id: number): void { asset_map.delete(message_id); } function set_selected_media_element($media: JQuery<HTMLMediaElement | HTMLImageElement>): void { // Clear out any previously selected element $(".media-to-select-in-lightbox-list").removeClass("media-to-select-in-lightbox-list"); $media.addClass("media-to-select-in-lightbox-list"); } export function canonical_url_of_media(media: HTMLMediaElement | HTMLImageElement): string { let media_string = media.src; if (!media_string) { return ""; } const media_url = new URL(media_string); if ( media_url.origin === window.location.origin && media_url.pathname.startsWith("/user_uploads/thumbnail/") ) { assert(media.parentElement instanceof HTMLAnchorElement); media_string = media.parentElement.href; } return media_string; } export function render_lightbox_media_list(): void { if (!is_open) { const message_media_list = $<HTMLMediaElement | HTMLImageElement>( ".focused-message-list .message-media-inline-image img, .focused-message-list .message-media-preview-image img, .focused-message-list .message_inline_video:not(.video-format-unsupported) video", ).toArray(); const $lightbox_media_list = $("#lightbox_overlay .image-list").empty(); for (const media of message_media_list) { const className = media.classList.contains("media-to-select-in-lightbox-list") ? "image selected" : "image"; const is_video = media.tagName === "VIDEO"; // We parse the data for each image to show in the list, // while we still have its original DOM element handy, so // that navigating within the list only needs the `src` // attribute used to construct the node object above. const payload = parse_media_data(media); let $node: JQuery; if (is_video) { $node = $("<div>") .addClass(className) .addClass("lightbox_video") .attr("data-url", payload.url); const $video = $("<video>"); $video.attr("src", payload.source); $node.append($video); } else { $node = $("<div>") .addClass(className) .attr("data-url", payload.url) .css({ backgroundImage: `url(${CSS.escape(payload.preview || payload.source)})`, }); } $lightbox_media_list.append($node); } } update_arrow_visibility(); } function display_image(payload: Media): void { $(".image-preview, .media-actions, .lightbox-zoom-reset").show(); const $img_container = $("#lightbox_overlay .image-preview > .zoom-element"); const $img = $("<img>"); if (payload.preview && payload.original_width_px && payload.original_height_px) { $img.attr("src", payload.preview); $img.attr("width", payload.original_width_px); $img.attr("height", payload.original_height_px); // srcset contains the reference to the original image, // and will be preferred over the src image when available $img.attr("srcset", `${payload.source} ${payload.original_width_px}w`); $img.attr("sizes", "100vw"); } else { $img.attr("src", payload.source); } $img_container.empty(); $img_container.append($img).show(); } function display_video(payload: Media): void { if (payload.type === "inline-video") { $(".video-player").show(); const $video = $("<video>"); $video.attr("src", payload.source); $video.attr("controls", "true"); $(".video-player").empty(); $(".video-player").append($video); return; } $(".player-container").show(); const $iframe = $("<iframe>"); $iframe.attr( "sandbox", "allow-forms allow-modals allow-orientation-lock allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-presentation allow-same-origin allow-scripts", ); assert(payload.source !== undefined); $iframe.attr("src", payload.source); $iframe.attr("frameborder", 0); $iframe.attr("allowfullscreen", "true"); $("#lightbox_overlay .player-container").empty(); $("#lightbox_overlay .player-container").append($iframe); } function invoke_overlay_restore_callback(): void { const callback = overlay_restore_callback; overlay_restore_callback = undefined; if (callback) { callback(); } } export function build_open_media_function( on_close = (): void => { remove_video_players(); is_open = false; assert(document.activeElement instanceof HTMLElement); document.activeElement.blur(); invoke_overlay_restore_callback(); }, ): ($media: JQuery<HTMLMediaElement | HTMLImageElement>) => void { return function ($media: JQuery<HTMLMediaElement | HTMLImageElement>): void { // This is used both for clicking on media in the messagelist, as well as clicking on images // in the media list under the lightbox when it is open. const payload = parse_media_data(util.the($media)); assert(payload !== undefined); render_lightbox_media_list(); $( "#lightbox_overlay .image-preview, .lightbox-zoom-reset, .player-container, .video-player", ).hide(); if (payload.type === "image") { display_image(payload); } else { display_video(payload); } $(".media-actions .open").attr("href", payload.url); if (payload.type === "image" || payload.type === "inline-video") { $(".media-description").show(); const filename = payload.url?.split("/").pop(); $(".media-description .title") .text(payload.title ?? "N/A") .attr("aria-label", payload.title ?? "N/A") .attr("data-filename", filename ?? "N/A"); if (payload.user !== undefined) { $(".media-description .user").text(payload.user).prop("title", payload.user); } const url = new URL(payload.url, window.location.href); const same_origin = url.origin === window.location.origin; if (same_origin && url.pathname.startsWith("/user_uploads/")) { // Switch to the "download" handler, so S3 URLs set their Content-Disposition url.pathname = "/user_uploads/download/" + url.pathname.slice("/user_uploads/".length); $(".media-actions .download").attr("href", url.href).show(); } else if (same_origin) { $(".media-actions .download").attr("href", payload.url).show(); } else { // If it's not same-origin, and we don't know how to tell the remote service to put a // content-disposition on it, the download can't possibly download, just show -- so hide the // element. $(".media-actions .download").hide(); } } else { // It's an external embed (YouTube/Vimeo) - hide the metadata and download button $(".media-description, .media-actions .download").hide(); } if (is_open) { return; } assert(on_close !== undefined); overlays.open_overlay({ name: "lightbox", $overlay: $("#lightbox_overlay"), on_close, }); popovers.hide_all(); is_open = true; }; } export function show_from_selected_message(): void { const $message_selected = $(".selected_message"); let $message = $message_selected; // This is a function to satisfy eslint unicorn/no-array-callback-reference const media_classes = (): string => ".message-media-inline-image img, .message-media-preview-image img, .message-media-preview-video:not(.video-format-unsupported) video"; let $media = $message.find<HTMLMediaElement | HTMLImageElement>(media_classes()); let $prev_traverse = false; // First, we walk upwards/backwards, starting with the current // message, looking for an media to preview. // // Care must be taken, since both recipient_row elements and // message_row objects have siblings of different types, such as // date elements. while ($media.length === 0) { if ($message.prev().length === 0) { const $prev_message_group = $message.parent().prevAll(".recipient_row").first(); if ($prev_message_group.length === 0) { $prev_traverse = true; $message = $message_selected; break; } $message = rows.last_message_in_group($prev_message_group); $media = $message.find<HTMLMediaElement | HTMLImageElement>(media_classes()); continue; } $message = $message.prev(); $media = $message.find<HTMLMediaElement | HTMLImageElement>(media_classes()); } if ($prev_traverse) { while ($media.length === 0) { if ($message.next().length === 0) { const $next_message_group = $message.parent().nextAll(".recipient_row").first(); if ($next_message_group.length === 0) { break; } $message = rows.first_message_in_group($next_message_group); $media = $message.find<HTMLMediaElement | HTMLImageElement>(media_classes()); continue; } $message = $message.next(); $media = $message.find<HTMLMediaElement | HTMLImageElement>(media_classes()); } } if ($media.length > 0) { const open_media = build_open_media_function(undefined); // Since this function is only called from the "show_lightbox" // hotkey, we don't have a selected image to load. Therefore, // we show the first one returned from the traversal above. set_selected_media_element($media.first()); open_media($media.first()); } } function supports_heic(): boolean { if (!util.is_client_safari()) { return false; } const match = /Version\/(\d+)/.exec(navigator.userAgent); if (!match?.[1]) { return false; } const version = Number.parseInt(match[1], 10); return !Number.isNaN(version) && version >= 17; } // retrieve the metadata from the DOM and store into the asset_map. export function parse_media_data(media: HTMLMediaElement | HTMLImageElement): Media { const $media = $(media); const canonical_url = canonical_url_of_media(media); let message_id; // This includes the preview feature in the message-edit UI as well as compose. const is_compose_preview_media = $media.closest(".preview_content").length > 0; const $message_row = rows.get_closest_row($media); let use_asset_map; let sender_full_name; if (is_compose_preview_media || rows.is_overlay_row($message_row)) { // We don't use the asset map cache in compose/edit UIs or // overlays, since the content is not stable. sender_full_name = people.my_full_name(); use_asset_map = false; } else if ($message_row.length > 0) { use_asset_map = true; message_id = rows.id($message_row); if (asset_map.has(message_id) && asset_map.get(message_id)?.has(canonical_url)) { // Use the cached value const payload = asset_map.get(message_id)!.get(canonical_url); assert(payload !== undefined); return payload; } const message = message_store.get(message_id); if (message === undefined) { blueslip.error("Lightbox for unknown message", {message_id}); } else { sender_full_name = message.sender_full_name; } } // if wrapped in the .youtube-video class, it will be length = 1, and therefore // cast to true. const is_youtube_video = $media.closest(".youtube-video").length > 0; const is_vimeo_video = $media.closest(".vimeo-video").length > 0; const is_embed_video = $media.closest(".embed-video").length > 0; const is_inline_video = $media.closest(".message_inline_video").length > 0; const $parent = $media.parent(); let type: MediaType; let source; // Client-rendered images (e.g., in drafts) are bare <img> tags // not wrapped in an <a>, so fall back to the image's src. const url = $parent.attr("href") ?? $media.attr("src"); assert(url !== undefined); let preview_src = $media.attr("src"); const is_loading_placeholder = $media.hasClass("image-loading-placeholder"); if (is_loading_placeholder) { preview_src = ""; } const original_dimensions = $media.attr("data-original-dimensions"); let original_width_px; let original_height_px; if (original_dimensions) { const found = /^(\d+)x(\d+)$/.exec(original_dimensions); if (found) { original_width_px = Number(found[1]); original_height_px = Number(found[2]); } } const transcoded_image = $media.attr("data-transcoded-image"); if (is_inline_video) { type = "inline-video"; // Render video from original source to reduce load on our own servers. The `url` is the // non-Camo'd version; `preview` is the Camo'd URL. source = url; } else if (is_youtube_video) { type = "youtube-video"; source = "https://www.youtube.com/embed/" + $parent.attr("data-id"); // YouTube URLs support a `start` parameter that can be either // an integer or a string-encoded time offset like // "1h20m12s". The embed API only supports the integer format, // so we may need to convert the format. const start_time = util.parse_youtube_start_time(url); if (start_time !== undefined) { source += "?start=" + start_time; } } else if (is_vimeo_video) { type = "vimeo-video"; source = "https://player.vimeo.com/video/" + $parent.attr("data-id"); } else if (is_embed_video) { type = "embed-video"; source = "data:text/html," + window.encodeURIComponent( "<!DOCTYPE html><style>iframe{position:absolute;left:0;top:0;width:100%;height:100%;box-sizing:border-box}</style>" + $parent.attr("data-id"), ); } else { type = "image"; if ($media.attr("data-src-fullsize")) { source = $media.attr("data-src-fullsize"); } else if (transcoded_image && preview_src) { if ($media.attr("data-original-content-type") === "image/heic" && supports_heic()) { // It's an HEIC and we support it -- don't use the transcoded version source = url; } else { source = preview_src.replace(/\/[^/]+$/, () => "/" + transcoded_image); } } else { source = url; } } const payload = { user: sender_full_name, title: $parent.attr("aria-label") ?? url, type, preview: preview_src && util.is_valid_url(preview_src) ? preview_src : "", original_width_px, original_height_px, source: source && util.is_valid_url(source) ? source : "", url: util.is_valid_url(url) ? url : "", }; if (use_asset_map && !is_loading_placeholder && canonical_url !== "") { // Update the asset_map, if we had a message ID involved. assert(message_id !== undefined); if (!asset_map.has(message_id)) { asset_map.set(message_id, new Map<string, Media>()); } asset_map.get(message_id)!.set(canonical_url, payload); } return payload; } export function prev(): void { $(".image-list .image.selected").prev().trigger("click"); } export function next(): void { $(".image-list .image.selected").next().trigger("click"); } function update_arrow_visibility(): void { const $selected = $(".image-list .image.selected"); const has_prev = $selected.prev(".image").length > 0; const has_next = $selected.next(".image").length > 0; $("#lightbox_overlay .center .arrow[data-direction='prev']").toggleClass( "invisible", !has_prev, ); $("#lightbox_overlay .center .arrow[data-direction='next']").toggleClass( "invisible", !has_next, ); } function remove_video_players(): void { // Remove video players from the DOM. Used when closing lightbox // so that videos doesn't keep playing in the background. $(".player-container iframe").remove(); $("#lightbox_overlay .video-player").html(""); } export function handle_inline_media_element_click( $media: JQuery<HTMLMediaElement> | JQuery<HTMLImageElement>, hide_navigation_arrows = false, ): void { set_selected_media_element($media); const media_element = $media[0]; assert(media_element !== undefined); if (media_element instanceof HTMLImageElement) { open_image($(media_element)); } else { open_video($(media_element)); } $("#lightbox_overlay .center").toggleClass("invisible", hide_navigation_arrows); } export function handle_overlay_media_element_click( $media: JQuery<HTMLMediaElement> | JQuery<HTMLImageElement>, on_lightbox_close: () => void, ): void { overlay_restore_callback = on_lightbox_close; handle_inline_media_element_click($media, true); } // this is a block of events that are required for the lightbox to work. export function initialize(): void { // 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( util.the($("#lightbox_overlay .image-preview > .zoom-element")), ); const reset_lightbox_state = function (): void { remove_video_players(); is_open = false; assert(document.activeElement instanceof HTMLElement); document.activeElement.blur(); if (pan_zoom_control.isActive()) { pan_zoom_control.reset(); } invoke_overlay_restore_callback(); }; open_image = build_open_media_function(reset_lightbox_state); open_video = build_open_media_function(undefined); $("#main_div, #compose .preview_content").on( "click", ".message-media-inline-image a, .message-media-preview-image:not(.message_inline_video) a, .message_inline_animated_image_still", function (e) { // prevent the link from opening in a new page. e.preventDefault(); // prevent the message compose dialog from happening. e.stopPropagation(); const $img = $(this).find<HTMLImageElement>("img"); handle_inline_media_element_click($img); }, ); $("#main_div, #compose .preview_content").on("click", ".message_inline_video", (e) => { e.preventDefault(); e.stopPropagation(); const $video = $(e.currentTarget).find<HTMLMediaElement>("video"); handle_inline_media_element_click($video); }); $("#lightbox_overlay .download").on("click", function () { this.blur(); }); $("#lightbox_overlay").on("click", ".image-list .image", function (this: HTMLElement) { // Remove any video players so sound does not continue when // navigating away from a video that might be playing remove_video_players(); const $media_list = $(this).parent(); let $original_media_element; const is_video = $(this).hasClass("lightbox_video"); // Because multiple media elements may match on the same data-url, // we capture the first of these if (is_video) { $original_media_element = $<HTMLMediaElement>( `.message_row a[href='${CSS.escape($(this).attr("data-url")!)}'] video`, )?.first(); } else { $original_media_element = $<HTMLImageElement>( `.message_row a[href='${CSS.escape($(this).attr("data-url")!)}'] img`, )?.first(); } // If $original_media_element comes with a 0 length, that means that // something in the message list has changed (e.g., a moved or // deleted message, or the deletion of a media element that was // available when the lightbox opened). In that event, we continue // to display whatever media is up in the lightbox, and remove // the thumbnail from the media list. if ($original_media_element.length === 0) { $(this).remove(); return; } // Because a user could conceivably copy and paste a media reference // from another message, and because we are selecting the original // element above based on a data-url value that is therefore not // guaranteed to be unique, we pass the first (possibly only) media // element returned. The logic below for removing and adding the // "selected" class ensures that the correct thumbnail will // still be highlighted. const media_element = $original_media_element[0]; if (media_element instanceof HTMLImageElement) { open_image($(media_element)); } else { assert(media_element instanceof HTMLMediaElement); open_video($(media_element)); } if (!$(".image-list .image.selected").hasClass("lightbox_video") || !is_video) { pan_zoom_control.reset(); } $(".image-list .image.selected").removeClass("selected"); $(this).addClass("selected"); update_arrow_visibility(); const parentOffset = this.parentElement!.clientWidth + this.parentElement!.scrollLeft; // this is the left and right of the image compared to its parent. const coords = { left: this.offsetLeft, right: this.offsetLeft + this.clientWidth, }; if (coords.right > parentOffset) { // add 2px margin $media_list.animate( { scrollLeft: coords.right - this.parentElement!.clientWidth + 2, }, 100, ); } else if (coords.left < this.parentElement!.scrollLeft) { // subtract 2px margin $media_list.animate({scrollLeft: coords.left - 2}, 100); } }); $("#lightbox_overlay").on("click", ".center .arrow", function () { const direction = $(this).attr("data-direction"); if (direction === "next") { next(); } else if (direction === "prev") { prev(); } }); $("#lightbox_overlay").on("click", ".lightbox-zoom-reset", () => { if (!$("#lightbox_overlay .lightbox-zoom-reset").hasClass("disabled")) { pan_zoom_control.reset(); } }); $("#lightbox_overlay .player-container").on("click", function () { if ($(this).is(".player-container")) { reset_lightbox_state(); overlays.close_active(); } }); $("#lightbox_overlay").on("click", ".media-info-wrapper, .center", (e) => { if ($(e.target).is(".media-info-wrapper, .center")) { reset_lightbox_state(); overlays.close_overlay("lightbox"); } }); $("#lightbox_overlay .image-preview").on("click", (e) => { // Ensure that the click isn't on the image itself, and that // the window isn't marked as disabled to click to close. if (!$(e.target).is("img") && !$("#lightbox_overlay").data("noclose")) { reset_lightbox_state(); overlays.close_overlay("lightbox"); } }); $("#lightbox_overlay .video-player").on("click", (e) => { // Close lightbox when clicked outside video. if (!$(e.target).is("video")) { overlays.close_overlay("lightbox"); } }); }