diff --git a/app/assets/javascripts/discourse/app/components/d-button.hbs b/app/assets/javascripts/discourse/app/components/d-button.hbs
index 0b9fc386241..ab6dc82ca2e 100644
--- a/app/assets/javascripts/discourse/app/components/d-button.hbs
+++ b/app/assets/javascripts/discourse/app/components/d-button.hbs
@@ -27,7 +27,13 @@
{{~d-icon "spinner" class="loading-icon"~}}
{{else}}
{{#if @icon}}
- {{~d-icon @icon~}}
+ {{#if @ariaHidden}}
+
+ {{~d-icon @icon~}}
+
+ {{else}}
+ {{~d-icon @icon~}}
+ {{/if}}
{{/if}}
{{/if}}
diff --git a/app/assets/javascripts/discourse/app/components/d-lightbox.hbs b/app/assets/javascripts/discourse/app/components/d-lightbox.hbs
new file mode 100644
index 00000000000..fc7d3ca867d
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/d-lightbox.hbs
@@ -0,0 +1,83 @@
+
+ {{#if this.isVisible}}
+
+
+
+
+ {{#if this.shouldDisplayCarousel}}
+
+ {{/if}}
+
+
+
+
+ {{/if}}
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/d-lightbox.js b/app/assets/javascripts/discourse/app/components/d-lightbox.js
new file mode 100644
index 00000000000..87d3e6abe11
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/d-lightbox.js
@@ -0,0 +1,470 @@
+import {
+ ANIMATION_DURATION,
+ KEYBOARD_SHORTCUTS,
+ LAYOUT_TYPES,
+ LIGHTBOX_APP_EVENT_NAMES,
+ LIGHTBOX_ELEMENT_ID,
+ SWIPE_DIRECTIONS,
+ TITLE_ELEMENT_ID,
+} from "discourse/lib/lightbox/constants";
+import {
+ createDownloadLink,
+ getSwipeDirection,
+ openImageInNewTab,
+ preloadItemImages,
+ scrollParentToElementCenter,
+ setCarouselScrollPosition,
+ setSiteThemeColor,
+} from "discourse/lib/lightbox/helpers";
+
+import Component from "@glimmer/component";
+import { bind } from "discourse-common/utils/decorators";
+import discourseLater from "discourse-common/lib/later";
+import { htmlSafe } from "@ember/template";
+import { inject as service } from "@ember/service";
+import { tracked } from "@glimmer/tracking";
+
+export default class DLightbox extends Component {
+ @service appEvents;
+
+ @tracked isVisible = false;
+ @tracked isLoading = false;
+ @tracked currentIndex = 0;
+ @tracked currentItem = {};
+
+ @tracked isZoomed = false;
+ @tracked isRotated = false;
+ @tracked isFullScreen = false;
+ @tracked rotationAmount = 0;
+
+ @tracked hasCarousel = false;
+ @tracked hasExpandedTitle = false;
+
+ elementId = LIGHTBOX_ELEMENT_ID;
+ titleElementId = TITLE_ELEMENT_ID;
+ animationDuration = ANIMATION_DURATION;
+
+ get layoutType() {
+ return window.innerWidth > window.innerHeight
+ ? LAYOUT_TYPES.HORIZONTAL
+ : LAYOUT_TYPES.VERTICAL;
+ }
+
+ get CSSVars() {
+ const base = "--d-lightbox-image";
+
+ const variables = [
+ `${base}-animation-duration: ${this.animationDuration}ms;`,
+ ];
+
+ if (!this.currentItem) {
+ return htmlSafe(variables.join(""));
+ }
+
+ const { width, height, aspectRatio, dominantColor, fullsizeURL, smallURL } =
+ this.currentItem;
+
+ variables.push(
+ `${base}-rotation: ${this.rotationAmount}deg`,
+ `${base}-width: ${width}px`,
+ `${base}-height: ${height}px`,
+ `${base}-aspect-ratio: ${aspectRatio}`,
+ `${base}-dominant-color: #${dominantColor}`,
+ `${base}-full-size-url: url(${encodeURI(fullsizeURL)})`,
+ `${base}-small-url: url(${encodeURI(smallURL)})`
+ );
+
+ return htmlSafe(variables.filter(Boolean).join(";"));
+ }
+
+ get HTMLClassList() {
+ const base = "d-lightbox";
+
+ const classNames = [base];
+
+ if (!this.isVisible) {
+ return classNames.join("");
+ }
+
+ classNames.push(
+ this.layoutType && `is-${this.layoutType}`,
+ this.isVisible && `is-visible`,
+ this.isLoading ? `is-loading` : `is-finished-loading`,
+ this.isFullScreen && `is-fullscreen`,
+ this.isZoomed && `is-zoomed`,
+ this.isRotated && `is-rotated`,
+ this.canZoom && `can-zoom`,
+ this.hasExpandedTitle && `has-expanded-title`,
+ this.hasCarousel && `has-carousel`,
+ this.hasLoadingError && `has-loading-error`,
+ this.willClose && `will-close`,
+ this.isRotated &&
+ this.rotationAmount &&
+ `is-rotated-${this.rotationAmount}`
+ );
+
+ return classNames.filter(Boolean).join(" ");
+ }
+
+ get shouldDisplayMainImageArrows() {
+ return (
+ !this.options.isMobile &&
+ this.canNavigate &&
+ !this.hasCarousel &&
+ !this.isZoomed &&
+ !this.isRotated
+ );
+ }
+
+ get shouldDisplayCarousel() {
+ return this.hasCarousel && !this.isZoomed && !this.isRotated;
+ }
+
+ get shouldDisplayCarouselArrows() {
+ return (
+ !this.options.isMobile &&
+ this.totalItemCount >= this.options.minCarosuelArrowItemCount
+ );
+ }
+
+ get shouldDisplayTitle() {
+ return !this.hasLoadingError && !this.isZoomed && !this.isRotated;
+ }
+
+ get totalItemCount() {
+ return this.items?.length || 0;
+ }
+
+ get counterIndex() {
+ return this.currentIndex ? this.currentIndex + 1 : 1;
+ }
+
+ get canNavigate() {
+ return this.items?.length > 1;
+ }
+
+ get canZoom() {
+ return !this.hasLoadingError && this.currentItem?.canZoom;
+ }
+
+ get canRotate() {
+ return !this.hasLoadingError;
+ }
+
+ get canDownload() {
+ return !this.hasLoadingError && this.options.canDownload;
+ }
+
+ get canFullscreen() {
+ return !this.hasLoadingError;
+ }
+
+ get hasLoadingError() {
+ return this.currentItem?.hasLoadingError;
+ }
+
+ get nextButtonIcon() {
+ return this.options.isRTL ? "chevron-left" : "chevron-right";
+ }
+
+ get previousButtonIcon() {
+ return this.options.isRTL ? "chevron-right" : "chevron-left";
+ }
+
+ get zoomButtonIcon() {
+ return this.isZoomed ? "search-minus" : "search-plus";
+ }
+
+ @bind
+ registerAppEventListeners() {
+ this.appEvents.on(LIGHTBOX_APP_EVENT_NAMES.OPEN, this.open);
+ this.appEvents.on(LIGHTBOX_APP_EVENT_NAMES.CLOSE, this.close);
+ }
+
+ @bind
+ deregisterAppEventListners() {
+ this.appEvents.off(LIGHTBOX_APP_EVENT_NAMES.OPEN, this.open);
+ this.appEvents.off(LIGHTBOX_APP_EVENT_NAMES.CLOSE, this.close);
+ }
+
+ @bind
+ async open({ items, startingIndex, callbacks, options }) {
+ this.options = options;
+
+ this.items = items;
+ this.currentIndex = startingIndex;
+ this.callbacks = callbacks;
+
+ this.isLoading = true;
+ this.isVisible = true;
+
+ await this.#setCurrentItem(this.currentIndex);
+
+ if (
+ this.options.zoomOnOpen &&
+ this.currentItem?.canZoom &&
+ !this.currentItem?.isZoomed
+ ) {
+ this.toggleZoom();
+ }
+
+ this.callbacks.onOpen?.({
+ items: this.items,
+ currentItem: this.currentItem,
+ });
+ }
+
+ @bind
+ close() {
+ this.willClose = true;
+
+ discourseLater(this.cleanup, this.animationDuration);
+
+ this.callbacks.onClose?.();
+ }
+
+ async #setCurrentItem(index) {
+ this.#onBeforeItemChange();
+
+ this.currentIndex = (index + this.totalItemCount) % this.totalItemCount;
+ this.currentItem = await preloadItemImages(this.items[this.currentIndex]);
+
+ this.#onAfterItemChange();
+ }
+
+ #onBeforeItemChange() {
+ this.callbacks.onItemWillChange?.({
+ currentItem: this.currentItem,
+ });
+
+ this.isLoading = true;
+ this.isZoomed = false;
+ this.isRotated = false;
+ }
+
+ #onAfterItemChange() {
+ this.isLoading = false;
+
+ setSiteThemeColor(this.currentItem.dominantColor);
+
+ setCarouselScrollPosition({
+ behavior: "smooth",
+ });
+
+ this.callbacks.onItemDidChange?.({
+ currentItem: this.currentItem,
+ });
+
+ const nextItem = this.items[this.currentIndex + 1];
+ return nextItem ? preloadItemImages(nextItem) : false;
+ }
+
+ @bind
+ centerZoomedBackgroundPosition(zoomedImageContainer) {
+ return this.options.isMobile
+ ? scrollParentToElementCenter({
+ element: zoomedImageContainer,
+ isRTL: this.options.isRTL,
+ })
+ : false;
+ }
+
+ zoomOnMouseover(event) {
+ const zoomedImageContainer = event.target;
+
+ const offsetX = event.offsetX;
+ const offsetY = event.offsetY;
+
+ const x = (offsetX / zoomedImageContainer.offsetWidth) * 100;
+ const y = (offsetY / zoomedImageContainer.offsetHeight) * 100;
+
+ zoomedImageContainer.style.backgroundPosition = x + "% " + y + "%";
+ }
+
+ @bind
+ toggleZoom() {
+ if (this.isLoading || !this.canZoom) {
+ return;
+ }
+
+ this.isZoomed = !this.isZoomed;
+ document.querySelector(".d-lightbox__close-button")?.focus();
+ }
+
+ @bind
+ rotateImage() {
+ this.rotationAmount = (this.rotationAmount + 90) % 360;
+ this.isRotated = this.rotationAmount !== 0;
+ }
+
+ @bind
+ toggleFullScreen() {
+ this.isFullScreen = !this.isFullScreen;
+
+ return this.isFullScreen
+ ? document.documentElement.requestFullscreen()
+ : document.exitFullscreen();
+ }
+
+ @bind
+ downloadImage() {
+ return createDownloadLink(this.currentItem);
+ }
+
+ @bind
+ openInNewTab() {
+ return openImageInNewTab(this.currentItem);
+ }
+
+ @bind
+ reloadImage() {
+ this.#setCurrentItem(this.currentIndex);
+ }
+
+ @bind
+ toggleCarousel() {
+ this.hasCarousel = !this.hasCarousel;
+
+ requestAnimationFrame(setCarouselScrollPosition);
+ }
+
+ @bind
+ showNextItem() {
+ this.#setCurrentItem(this.currentIndex + 1);
+ }
+
+ @bind
+ showPreviousItem() {
+ this.#setCurrentItem(this.currentIndex - 1);
+ }
+
+ @bind
+ showSelectedImage(event) {
+ const targetIndex = event.target.dataset?.lightboxItemIndex;
+ return targetIndex ? this.#setCurrentItem(Number(targetIndex)) : false;
+ }
+
+ @bind
+ toggleExpandTitle() {
+ this.hasExpandedTitle = !this.hasExpandedTitle;
+ }
+
+ @bind
+ onKeyup({ key }) {
+ if (KEYBOARD_SHORTCUTS.PREVIOUS.includes(key)) {
+ return this.showPreviousItem();
+ }
+
+ if (KEYBOARD_SHORTCUTS.NEXT.includes(key)) {
+ return this.showNextItem();
+ }
+
+ if (key === KEYBOARD_SHORTCUTS.CLOSE) {
+ return this.close();
+ }
+
+ if (key === KEYBOARD_SHORTCUTS.ZOOM) {
+ return this.toggleZoom();
+ }
+
+ if (key === KEYBOARD_SHORTCUTS.FULLSCREEN) {
+ return this.toggleFullScreen();
+ }
+
+ if (key === KEYBOARD_SHORTCUTS.ROTATE) {
+ return this.rotateImage();
+ }
+
+ if (key === KEYBOARD_SHORTCUTS.DOWNLOAD) {
+ return this.downloadImage();
+ }
+
+ if (key === KEYBOARD_SHORTCUTS.CAROUSEL) {
+ return this.toggleCarousel();
+ }
+
+ if (key === KEYBOARD_SHORTCUTS.TITLE) {
+ return this.toggleExpandTitle();
+ }
+
+ if (key === KEYBOARD_SHORTCUTS.NEWTAB) {
+ return this.openInNewTab();
+ }
+ }
+
+ @bind
+ onTouchstart(event = Event) {
+ if (this.isZoomed) {
+ return false;
+ }
+
+ this.touchstartX = event.changedTouches[0].screenX;
+ this.touchstartY = event.changedTouches[0].screenY;
+ }
+
+ @bind
+ async onTouchend(event) {
+ if (this.isZoomed) {
+ return false;
+ }
+
+ event.stopPropagation();
+
+ const touchendY = event.changedTouches[0].screenY;
+ const touchendX = event.changedTouches[0].screenX;
+
+ const swipeDirection = await getSwipeDirection({
+ touchstartX: this.touchstartX,
+ touchstartY: this.touchstartY,
+ touchendX,
+ touchendY,
+ });
+
+ switch (swipeDirection) {
+ case SWIPE_DIRECTIONS.LEFT:
+ this.options.isRTL ? this.showNextItem() : this.showPreviousItem();
+ break;
+ case SWIPE_DIRECTIONS.RIGHT:
+ this.options.isRTL ? this.showPreviousItem() : this.showNextItem();
+ break;
+ case SWIPE_DIRECTIONS.UP:
+ this.close();
+ break;
+ case SWIPE_DIRECTIONS.DOWN:
+ this.toggleCarousel();
+ break;
+ }
+ }
+
+ @bind
+ cleanup() {
+ if (this.isVisible) {
+ this.hasCarousel = !!document.querySelector(".d-lightbox.has-carousel");
+ this.hasExpandedTitle = false;
+ this.isLoading = false;
+ this.items = [];
+ this.currentIndex = 0;
+ this.isZoomed = false;
+ this.isRotated = false;
+ this.rotationAmount = 0;
+
+ if (this.isFullScreen) {
+ this.toggleFullScreen();
+ this.isFullScreen = false;
+ }
+
+ this.isVisible = false;
+ this.willClose = false;
+
+ this.callbacks.onCleanup?.();
+
+ this.callbacks = {};
+ this.options = {};
+ }
+ }
+
+ willDestroy() {
+ super.willDestroy(...arguments);
+ this.cleanup();
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/components/d-lightbox/backdrop.hbs b/app/assets/javascripts/discourse/app/components/d-lightbox/backdrop.hbs
new file mode 100644
index 00000000000..5ef4361fd8f
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/d-lightbox/backdrop.hbs
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/d-lightbox/body.hbs b/app/assets/javascripts/discourse/app/components/d-lightbox/body.hbs
new file mode 100644
index 00000000000..75348d76d23
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/d-lightbox/body.hbs
@@ -0,0 +1,59 @@
+
+ {{#if @shouldDisplayMainImageArrows}}
+
+ {{/if}}
+ {{#if @isLoading}}
+
+ {{loading-spinner size="large"}}
+
+ {{else if @hasLoadingError}}
+
+
+ {{i18n "experimental_lightbox.image_load_error"}}
+
+ {{else if @isZoomed}}
+
+ {{else}}
+
+
+ {{/if}}
+ {{#if @shouldDisplayMainImageArrows}}
+
+ {{/if}}
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/d-lightbox/carousel.hbs b/app/assets/javascripts/discourse/app/components/d-lightbox/carousel.hbs
new file mode 100644
index 00000000000..c5867364eff
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/d-lightbox/carousel.hbs
@@ -0,0 +1,49 @@
+
+ {{#if @shouldDisplayCarouselArrows}}
+
+ {{/if}}
+
+ {{#each @items as |item|}}
+
+ {{/each}}
+
+ {{#if @shouldDisplayCarouselArrows}}
+
+ {{/if}}
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/d-lightbox/footer.hbs b/app/assets/javascripts/discourse/app/components/d-lightbox/footer.hbs
new file mode 100644
index 00000000000..3427e2b7dc2
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/d-lightbox/footer.hbs
@@ -0,0 +1,48 @@
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/d-lightbox/header.hbs b/app/assets/javascripts/discourse/app/components/d-lightbox/header.hbs
new file mode 100644
index 00000000000..e9068d69288
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/d-lightbox/header.hbs
@@ -0,0 +1,54 @@
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/d-lightbox/screen-reader-announcer.hbs b/app/assets/javascripts/discourse/app/components/d-lightbox/screen-reader-announcer.hbs
new file mode 100644
index 00000000000..5220c12c72f
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/d-lightbox/screen-reader-announcer.hbs
@@ -0,0 +1,16 @@
+
+
+ {{i18n
+ "experimental_lightbox.screen_reader_image_title"
+ current=@counterIndex
+ total=@totalItemCount
+ title=@currentItem.title
+ }}
+
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/uppy-image-uploader.hbs b/app/assets/javascripts/discourse/app/components/uppy-image-uploader.hbs
index c363d8255e0..dd3e863d9e6 100644
--- a/app/assets/javascripts/discourse/app/components/uppy-image-uploader.hbs
+++ b/app/assets/javascripts/discourse/app/components/uppy-image-uploader.hbs
@@ -25,14 +25,14 @@
@icon="far-trash-alt"
@type="button"
/>
-
{{/if}}
diff --git a/app/assets/javascripts/discourse/app/components/uppy-image-uploader.js b/app/assets/javascripts/discourse/app/components/uppy-image-uploader.js
index 7dd74d69807..8818ffb4d38 100644
--- a/app/assets/javascripts/discourse/app/components/uppy-image-uploader.js
+++ b/app/assets/javascripts/discourse/app/components/uppy-image-uploader.js
@@ -1,10 +1,15 @@
import Component from "@ember/component";
+import { action } from "@ember/object";
import { or } from "@ember/object/computed";
import UppyUploadMixin from "discourse/mixins/uppy-upload";
import discourseComputed, { on } from "discourse-common/utils/decorators";
import { getURLWithCDN } from "discourse-common/lib/get-url";
import { isEmpty } from "@ember/utils";
-import lightbox from "discourse/lib/lightbox";
+import {
+ cleanupLightboxes,
+ default as lightbox,
+ setupLightboxes,
+} from "discourse/lib/lightbox";
import { next } from "@ember/runloop";
import { htmlSafe } from "@ember/template";
import { authorizesOneOrMoreExtensions } from "discourse/lib/uploads";
@@ -14,6 +19,11 @@ export default Component.extend(UppyUploadMixin, {
classNames: ["image-uploader"],
disabled: or("notAllowed", "uploading", "processing"),
+ @discourseComputed("siteSettings.enable_experimental_lightbox")
+ experimentalLightboxEnabled(experimentalLightboxEnabled) {
+ return experimentalLightboxEnabled;
+ },
+
@discourseComputed("disabled", "notAllowed")
disabledReason(disabled, notAllowed) {
if (disabled && notAllowed) {
@@ -97,21 +107,33 @@ export default Component.extend(UppyUploadMixin, {
@on("didRender")
_applyLightbox() {
- next(() => lightbox(this.element, this.siteSettings));
+ if (this.experimentalLightboxEnabled) {
+ setupLightboxes({
+ container: this.element,
+ selector: ".lightbox",
+ });
+ } else {
+ next(() => lightbox(this.element, this.siteSettings));
+ }
},
@on("willDestroyElement")
_closeOnRemoval() {
- if ($.magnificPopup?.instance) {
- $.magnificPopup.instance.close();
+ if (this.experimentalLightboxEnabled) {
+ cleanupLightboxes();
+ } else {
+ if ($.magnificPopup?.instance) {
+ $.magnificPopup.instance.close();
+ }
}
},
- actions: {
- toggleLightbox() {
- $(this.element.querySelector("a.lightbox"))?.magnificPopup("open");
- },
+ @action
+ toggleLightbox() {
+ $(this.element.querySelector("a.lightbox"))?.magnificPopup("open");
+ },
+ actions: {
trash() {
// uppy needs to be reset to allow for more uploads
this._reset();
diff --git a/app/assets/javascripts/discourse/app/instance-initializers/document-scrollbar-width.js b/app/assets/javascripts/discourse/app/instance-initializers/document-scrollbar-width.js
new file mode 100644
index 00000000000..639ce6cf445
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/instance-initializers/document-scrollbar-width.js
@@ -0,0 +1,17 @@
+export default {
+ async initialize(owner) {
+ const siteSettings = owner.lookup("service:site-settings");
+
+ if (siteSettings.enable_experimental_lightbox) {
+ const viewportWidth = window.innerWidth;
+ const bodyRects = document.body.getBoundingClientRect();
+ let scrollbarWidth = viewportWidth - bodyRects.width;
+ scrollbarWidth = scrollbarWidth = Math.round(scrollbarWidth * 100) / 100;
+
+ document.documentElement.style.setProperty(
+ "--document-scrollbar-width",
+ `${scrollbarWidth}px`
+ );
+ }
+ },
+};
diff --git a/app/assets/javascripts/discourse/app/instance-initializers/post-decorations.js b/app/assets/javascripts/discourse/app/instance-initializers/post-decorations.js
index 1b831e54236..13db688c21a 100644
--- a/app/assets/javascripts/discourse/app/instance-initializers/post-decorations.js
+++ b/app/assets/javascripts/discourse/app/instance-initializers/post-decorations.js
@@ -10,6 +10,7 @@ import { nativeLazyLoading } from "discourse/lib/lazy-load-images";
import { withPluginApi } from "discourse/lib/plugin-api";
import { create } from "virtual-dom";
import FullscreenTableModal from "discourse/components/modal/fullscreen-table";
+import { SELECTORS } from "discourse/lib/lightbox/constants";
export default {
initialize(owner) {
@@ -18,6 +19,8 @@ export default {
const session = owner.lookup("service:session");
const site = owner.lookup("service:site");
const modal = owner.lookup("service:modal");
+ // will eventually just be called lightbox
+ const lightboxService = owner.lookup("service:lightbox");
api.decorateCookedElement(
(elem) => {
return highlightSyntax(elem, siteSettings, session);
@@ -27,12 +30,32 @@ export default {
}
);
- api.decorateCookedElement(
- (elem) => {
- return lightbox(elem, siteSettings);
- },
- { id: "discourse-lightbox" }
- );
+ if (siteSettings.enable_experimental_lightbox) {
+ api.decorateCookedElement(
+ (element, helper) => {
+ return helper &&
+ element.querySelector(SELECTORS.DEFAULT_ITEM_SELECTOR)
+ ? lightboxService.setupLightboxes({
+ container: element,
+ selector: SELECTORS.DEFAULT_ITEM_SELECTOR,
+ })
+ : null;
+ },
+ {
+ id: "experimental-discourse-lightbox",
+ onlyStream: true,
+ }
+ );
+
+ api.cleanupStream(lightboxService.cleanupLightboxes);
+ } else {
+ api.decorateCookedElement(
+ (elem) => {
+ return lightbox(elem, siteSettings);
+ },
+ { id: "discourse-lightbox" }
+ );
+ }
api.decorateCookedElement(
(elem) => {
diff --git a/app/assets/javascripts/discourse/app/lib/lightbox.js b/app/assets/javascripts/discourse/app/lib/lightbox.js
index 27704d0fcdf..eb5c92170a5 100644
--- a/app/assets/javascripts/discourse/app/lib/lightbox.js
+++ b/app/assets/javascripts/discourse/app/lib/lightbox.js
@@ -2,22 +2,50 @@ import {
escapeExpression,
postRNWebviewMessage,
} from "discourse/lib/utilities";
+
import I18n from "I18n";
import User from "discourse/models/user";
+import deprecated from "discourse-common/lib/deprecated";
+import { getOwner } from "discourse-common/lib/get-owner";
+import { helperContext } from "discourse-common/lib/helpers";
+import { isTesting } from "discourse-common/config/environment";
import loadScript from "discourse/lib/load-script";
import { renderIcon } from "discourse-common/lib/icon-library";
import { spinnerHTML } from "discourse/helpers/loading-spinner";
-import { helperContext } from "discourse-common/lib/helpers";
-import { isTesting } from "discourse-common/config/environment";
+import { SELECTORS } from "discourse/lib/lightbox/constants";
+
+export async function setupLightboxes({ container, selector }) {
+ const lightboxService = getOwner(this).lookup("service:lightbox");
+ lightboxService.setupLightboxes({ container, selector });
+}
+
+export function cleanupLightboxes() {
+ const lightboxService = getOwner(this).lookup("service:lightbox");
+ return lightboxService.cleanupLightboxes();
+}
+
+export default function lightbox(elem, siteSettings) {
+ if (siteSettings.enable_experimental_lightbox) {
+ deprecated(
+ "Accessing the default `lightbox` export is deprecated. Import setupLightboxes and cleanupLightboxes from `discourse/lib/lightbox` instead.",
+ {
+ since: "3.0.0.beta16",
+ dropFrom: "3.2.0",
+ id: "discourse.lightbox.default-export",
+ }
+ );
+
+ return setupLightboxes({
+ container: elem,
+ selector: SELECTORS.DEFAULT_ITEM_SELECTOR,
+ });
+ }
-export default function (elem, siteSettings) {
if (!elem) {
return;
}
- const lightboxes = elem.querySelectorAll(
- "*:not(.spoiler):not(.spoiled) a.lightbox"
- );
+ const lightboxes = elem.querySelectorAll(SELECTORS.DEFAULT_ITEM_SELECTOR);
if (!lightboxes.length) {
return;
diff --git a/app/assets/javascripts/discourse/app/lib/lightbox/constants.js b/app/assets/javascripts/discourse/app/lib/lightbox/constants.js
new file mode 100644
index 00000000000..c0cb341d149
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/lib/lightbox/constants.js
@@ -0,0 +1,81 @@
+import { isTesting } from "discourse-common/config/environment";
+
+export const ANIMATION_DURATION =
+ isTesting() || window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ ? 0
+ : 150;
+
+export const MIN_CAROUSEL_ARROW_ITEM_COUNT = 5;
+
+export const SWIPE_THRESHOLD = 50;
+
+export const SWIPE_DIRECTIONS = {
+ DOWN: "down",
+ LEFT: "left",
+ RIGHT: "right",
+ UP: "up",
+};
+
+export const DOCUMENT_ELEMENT_LIGHTBOX_OPEN_CLASS = "has-lightbox";
+export const LIGHTBOX_ELEMENT_ID = "discourse-lightbox";
+export const TITLE_ELEMENT_ID = "d-lightbox-image-title";
+
+export const SELECTORS = {
+ ACTIVE_CAROUSEL_ITEM: "[data-lightbox-carousel-item='current']",
+ DEFAULT_ITEM_SELECTOR: "*:not(.spoiler):not(.spoiled) a.lightbox",
+ FILE_DETAILS_CONTAINER: ".informations",
+ LIGHTBOX_CONTAINER: ".d-lightbox",
+ LIGHTBOX_CONTENT: ".d-lightbox__content",
+ LIGHTBOX_BODY: ".d-lightbox__body",
+ FOCUS_TRAP: ".d-lightbox__focus-trap",
+ MAIN_IMAGE: ".d-lightbox__main-image",
+ MULTI_BUTTONS: ".d-lightbox__multi-item-controls",
+ CAROUSEL_BUTTON: ".d-lightbox__carousel-button",
+ PREV_BUTTON: ".d-lightbox__previous-button",
+ NEXT_BUTTON: ".d-lightbox__next-button",
+ CLOSE_BUTTON: ".d-lightbox__close-button",
+ FULL_SCREEN_BUTTON: ".d-lightbox__full-screen-button",
+ TAB_BUTTON: ".d-lightbox__new-tab-button",
+ ROTATE_BUTTON: ".d-lightbox__rotate-button",
+ ZOOM_BUTTON: ".d-lightbox__zoom-button",
+ DOWNLOAD_BUTTON: ".d-lightbox__download-button",
+ COUNTERS: ".d-lightbox__counters",
+ COUNTER_CURRENT: ".d-lightbox__counter-current",
+ COUNTER_TOTAL: ".d-lightbox__counter-total",
+ IMAGE_TITLE: ".d-lightbox__image-title",
+ ACTIVE_ITEM_TITLE: ".d-lightbox__item-title",
+ ACTIVE_ITEM_FILE_DETAILS: ".d-lightbox__item-file-details",
+ CAROUSEL: ".d-lightbox__carousel",
+ CAROUSEL_ITEM: ".d-lightbox__carousel-item",
+ CAROUSEL_PREV_BUTTON: ".d-lightbox__carousel-previous-button",
+ CAROUSEL_NEXT_BUTTON: ".d-lightbox__carousel-next-button",
+};
+
+export const LIGHTBOX_APP_EVENT_NAMES = {
+ // this cannot use dom:clean else #cleanupLightboxes will be called after #setupLighboxes
+ CLEAN: "lightbox:clean",
+ CLOSE: "lightbox:close",
+ CLOSED: "lightbox:closed",
+ ITEM_DID_CHANGE: "lightbox:item-did-change",
+ ITEM_WILL_CHANGE: "lightbox:item-will-change",
+ OPEN: "lightbox:open",
+ OPENED: "lightbox:opened",
+};
+
+export const LAYOUT_TYPES = {
+ HORIZONTAL: "horizontal",
+ VERTICAL: "vertical",
+};
+
+export const KEYBOARD_SHORTCUTS = {
+ CAROUSEL: "a",
+ CLOSE: "Escape",
+ DOWNLOAD: "d",
+ FULLSCREEN: "m",
+ NEWTAB: "n",
+ NEXT: ["ArrowRight", "ArrowDown"],
+ PREVIOUS: ["ArrowLeft", "ArrowUp"],
+ ROTATE: "r",
+ TITLE: "t",
+ ZOOM: "z",
+};
diff --git a/app/assets/javascripts/discourse/app/lib/lightbox/helpers.js b/app/assets/javascripts/discourse/app/lib/lightbox/helpers.js
new file mode 100644
index 00000000000..5354296a6ef
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/lib/lightbox/helpers.js
@@ -0,0 +1,18 @@
+export {
+ setSiteThemeColor,
+ getSiteThemeColor,
+} from "./helpers/site-theme-color";
+
+export { getSwipeDirection } from "./helpers/get-swipe-direction";
+
+export { preloadItemImages } from "./helpers/preload-item-images";
+
+export { createDownloadLink } from "./helpers/create-download-link";
+
+export { scrollParentToElementCenter } from "./helpers/scroll-parent-to-element-center";
+
+export { openImageInNewTab } from "./helpers/open-image-in-new-tab";
+
+export { setCarouselScrollPosition } from "./helpers/set-carousel-scroll-position";
+
+export { findNearestSharedParent } from "./helpers/find-nearest-shared-parent";
diff --git a/app/assets/javascripts/discourse/app/lib/lightbox/helpers/create-download-link.js b/app/assets/javascripts/discourse/app/lib/lightbox/helpers/create-download-link.js
new file mode 100644
index 00000000000..66b1398cf2f
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/lib/lightbox/helpers/create-download-link.js
@@ -0,0 +1,11 @@
+export async function createDownloadLink(lightboxItem) {
+ try {
+ const link = document.createElement("a");
+ link.href = lightboxItem.downloadURL;
+ link.download = lightboxItem.title;
+ link.click();
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error(error);
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/lib/lightbox/helpers/find-nearest-shared-parent.js b/app/assets/javascripts/discourse/app/lib/lightbox/helpers/find-nearest-shared-parent.js
new file mode 100644
index 00000000000..673f1028c9c
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/lib/lightbox/helpers/find-nearest-shared-parent.js
@@ -0,0 +1,14 @@
+export function findNearestSharedParent(items) {
+ const ancestors = [];
+ for (const item of items) {
+ let ancestor = item;
+ while (ancestor) {
+ ancestors.push(ancestor);
+ ancestor = ancestor.parentElement;
+ }
+ }
+ return ancestors.filter(
+ (ancestor) =>
+ ancestors.indexOf(ancestor) !== ancestors.lastIndexOf(ancestor)
+ )[0];
+}
diff --git a/app/assets/javascripts/discourse/app/lib/lightbox/helpers/get-swipe-direction.js b/app/assets/javascripts/discourse/app/lib/lightbox/helpers/get-swipe-direction.js
new file mode 100644
index 00000000000..256f0249f9c
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/lib/lightbox/helpers/get-swipe-direction.js
@@ -0,0 +1,26 @@
+import { SWIPE_DIRECTIONS, SWIPE_THRESHOLD } from "../constants";
+
+export function getSwipeDirection({
+ touchstartX,
+ touchstartY,
+ touchendX,
+ touchendY,
+}) {
+ const diffX = touchstartX - touchendX;
+ const absDiffX = Math.abs(diffX);
+
+ const diffY = touchstartY - touchendY;
+ const absDiffY = Math.abs(diffY);
+
+ if (absDiffX > SWIPE_THRESHOLD) {
+ return Math.sign(diffX) > 0
+ ? SWIPE_DIRECTIONS.RIGHT
+ : SWIPE_DIRECTIONS.LEFT;
+ }
+
+ if (absDiffY > SWIPE_THRESHOLD) {
+ return Math.sign(diffY) > 0 ? SWIPE_DIRECTIONS.UP : SWIPE_DIRECTIONS.DOWN;
+ }
+
+ return false;
+}
diff --git a/app/assets/javascripts/discourse/app/lib/lightbox/helpers/open-image-in-new-tab.js b/app/assets/javascripts/discourse/app/lib/lightbox/helpers/open-image-in-new-tab.js
new file mode 100644
index 00000000000..25b81a78403
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/lib/lightbox/helpers/open-image-in-new-tab.js
@@ -0,0 +1,8 @@
+export async function openImageInNewTab(lightboxItem) {
+ try {
+ window.open(lightboxItem.fullsizeURL, "_blank");
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error(error);
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/lib/lightbox/helpers/preload-item-images.js b/app/assets/javascripts/discourse/app/lib/lightbox/helpers/preload-item-images.js
new file mode 100644
index 00000000000..24a79ce943c
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/lib/lightbox/helpers/preload-item-images.js
@@ -0,0 +1,50 @@
+export async function preloadItemImages(lightboxItem) {
+ if (!lightboxItem) {
+ return;
+ }
+
+ if (lightboxItem.isLoaded && !lightboxItem.hasLoadingError) {
+ return lightboxItem;
+ }
+
+ const fullsizeImage = new Image();
+ const smallImage = new Image();
+
+ const fullsizeImagePromise = new Promise((resolve, reject) => {
+ fullsizeImage.onload = resolve;
+ fullsizeImage.onerror = reject;
+ fullsizeImage.src = lightboxItem.fullsizeURL;
+ });
+
+ const smallImagePromise = new Promise((resolve, reject) => {
+ smallImage.onload = resolve;
+ smallImage.onerror = reject;
+ smallImage.src = lightboxItem.smallURL;
+ });
+
+ try {
+ await Promise.all([fullsizeImagePromise, smallImagePromise]);
+
+ lightboxItem = {
+ ...lightboxItem,
+ isLoaded: true,
+ hasLoadingError: false,
+ width: fullsizeImage.naturalWidth,
+ height: fullsizeImage.naturalHeight,
+ aspectRatio:
+ lightboxItem.aspectRatio ||
+ `${smallImage.naturalWidth} / ${smallImage.naturalHeight}`,
+ canZoom:
+ fullsizeImage.naturalWidth > window.innerWidth ||
+ fullsizeImage.naturalHeight > window.innerHeight,
+ };
+ } catch (error) {
+ lightboxItem.hasLoadingError = true;
+ // eslint-disable-next-line no-console
+ console.error(
+ `Failed to load lightbox image ${lightboxItem.index}: ${lightboxItem.fullsizeURL}`
+ );
+ }
+
+ return lightboxItem;
+}
diff --git a/app/assets/javascripts/discourse/app/lib/lightbox/helpers/scroll-parent-to-element-center.js b/app/assets/javascripts/discourse/app/lib/lightbox/helpers/scroll-parent-to-element-center.js
new file mode 100644
index 00000000000..ba774bcd931
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/lib/lightbox/helpers/scroll-parent-to-element-center.js
@@ -0,0 +1,16 @@
+export async function scrollParentToElementCenter({ element, isRTL }) {
+ const {
+ offsetWidth: width,
+ offsetHeight: height,
+ parentElement: parent,
+ } = element;
+
+ // if isRTL, make it relative to the right side of the viewport
+ const modifier = isRTL ? -1 : 1;
+
+ const x = ((width - parent.offsetWidth) / 2) * modifier;
+ const y = (height - parent.offsetHeight) / 2;
+
+ parent.scrollLeft = parseInt(x, 10);
+ parent.scrollTop = parseInt(y, 10);
+}
diff --git a/app/assets/javascripts/discourse/app/lib/lightbox/helpers/set-carousel-scroll-position.js b/app/assets/javascripts/discourse/app/lib/lightbox/helpers/set-carousel-scroll-position.js
new file mode 100644
index 00000000000..d50c12d5677
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/lib/lightbox/helpers/set-carousel-scroll-position.js
@@ -0,0 +1,25 @@
+import { SELECTORS } from "../constants";
+
+export async function setCarouselScrollPosition({ behavior = "instant" } = {}) {
+ const carouselItem = document.querySelector(SELECTORS.ACTIVE_CAROUSEL_ITEM);
+
+ if (!carouselItem) {
+ return;
+ }
+
+ const left =
+ carouselItem.offsetLeft -
+ carouselItem.offsetWidth -
+ carouselItem.offsetWidth / 2;
+
+ const top =
+ carouselItem.offsetTop -
+ carouselItem.offsetHeight -
+ carouselItem.offsetHeight / 2;
+
+ carouselItem.parentElement.scrollTo({
+ behavior,
+ left,
+ top,
+ });
+}
diff --git a/app/assets/javascripts/discourse/app/lib/lightbox/helpers/site-theme-color.js b/app/assets/javascripts/discourse/app/lib/lightbox/helpers/site-theme-color.js
new file mode 100644
index 00000000000..9aab8e0ee14
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/lib/lightbox/helpers/site-theme-color.js
@@ -0,0 +1,18 @@
+import { postRNWebviewMessage } from "discourse/lib/utilities";
+
+export async function getSiteThemeColor() {
+ const siteThemeColor = document.querySelector('meta[name="theme-color"]');
+ return siteThemeColor?.content;
+}
+
+export async function setSiteThemeColor(color = "000000") {
+ const _color = `#${color}`;
+
+ const siteThemeColor = document.querySelector('meta[name="theme-color"]');
+
+ if (siteThemeColor) {
+ siteThemeColor.content = _color;
+ }
+
+ postRNWebviewMessage?.("headerBg", _color);
+}
diff --git a/app/assets/javascripts/discourse/app/lib/lightbox/process-html.js b/app/assets/javascripts/discourse/app/lib/lightbox/process-html.js
new file mode 100644
index 00000000000..b8bd24bd889
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/lib/lightbox/process-html.js
@@ -0,0 +1,91 @@
+import { SELECTORS } from "./constants";
+import { escapeExpression } from "discourse/lib/utilities";
+import { htmlSafe } from "@ember/template";
+
+export async function processHTML({ container, selector }) {
+ selector ??= SELECTORS.DEFAULT_ITEM_SELECTOR;
+
+ const items = [...container.querySelectorAll(selector)];
+
+ let _startingIndex = items.findIndex(
+ (item) => item === document.activeElement
+ );
+
+ if (_startingIndex === -1) {
+ _startingIndex = 0;
+ }
+
+ const backgroundImageRegex = /url\((['"])?(.*?)\1\)/gi;
+
+ const _processedItems = items.map((item, index) => {
+ try {
+ const innerImage = item.querySelector("img") || {};
+
+ const _backgroundImage =
+ item.style?.backgroundImage ||
+ item.parentElement?.style?.backgroundImage ||
+ null;
+
+ const _fullsizeURL = item.href || item.src || innerImage.src || null;
+
+ const _smallURL =
+ innerImage.currentSrc ||
+ item.src ||
+ innerImage.src ||
+ _backgroundImage?.replace(backgroundImageRegex, "$2") ||
+ null;
+
+ const _downloadURL =
+ item.dataset?.downloadHref ||
+ item.href ||
+ item.src ||
+ innerImage.src ||
+ null;
+
+ const _title =
+ item.title || item.alt || innerImage.title || innerImage.alt || null;
+
+ const _aspectRatio =
+ item.dataset?.aspectRatio ||
+ innerImage.dataset?.aspectRatio ||
+ item.style?.aspectRatio ||
+ innerImage.style?.aspectRatio ||
+ null;
+
+ const _fileDetails =
+ item
+ .querySelector(SELECTORS.FILE_DETAILS_CONTAINER)
+ ?.innerText.trim() || null;
+
+ const _dominantColor = innerImage.dataset?.dominantColor || null;
+
+ const _cssVars = [
+ _dominantColor && `--dominant-color: #${_dominantColor};`,
+ _aspectRatio && `--aspect-ratio: ${_aspectRatio};`,
+ _smallURL && `--small-url: url(${encodeURI(_smallURL)});`,
+ ].join("");
+
+ return {
+ fullsizeURL: encodeURI(_fullsizeURL),
+ smallURL: encodeURI(_smallURL),
+ downloadURL: encodeURI(_downloadURL),
+ title: escapeExpression(_title),
+ fileDetails: _fileDetails,
+ dominantColor: _dominantColor,
+ aspectRatio: _aspectRatio,
+ index,
+ cssVars: htmlSafe(_cssVars),
+ };
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error(`Error processing lightbox item ${index}`);
+ // eslint-disable-next-line no-console
+ console.error(error);
+ }
+ });
+
+ return {
+ items: _processedItems,
+ startingIndex: _startingIndex,
+ };
+}
diff --git a/app/assets/javascripts/discourse/app/services/lightbox.js b/app/assets/javascripts/discourse/app/services/lightbox.js
new file mode 100644
index 00000000000..864de2a7e69
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/services/lightbox.js
@@ -0,0 +1,257 @@
+import {
+ DOCUMENT_ELEMENT_LIGHTBOX_OPEN_CLASS,
+ LIGHTBOX_APP_EVENT_NAMES,
+ MIN_CAROUSEL_ARROW_ITEM_COUNT,
+ SELECTORS,
+} from "discourse/lib/lightbox/constants";
+import Service, { inject as service } from "@ember/service";
+import {
+ getSiteThemeColor,
+ setSiteThemeColor,
+} from "discourse/lib/lightbox/helpers";
+
+import { bind } from "discourse-common/utils/decorators";
+import { isDocumentRTL } from "discourse/lib/text-direction";
+import { processHTML } from "discourse/lib/lightbox/process-html";
+
+export default class LightboxService extends Service {
+ @service appEvents;
+ @service site;
+
+ lightboxIsOpen = false;
+ lightboxClickElements = [];
+ lastFocusedElement = null;
+ originalSiteThemeColor = null;
+ onFocus = null;
+ selector = null;
+
+ callbacks = {};
+ options = {};
+
+ async init() {
+ super.init(...arguments);
+
+ this.callbacks = {
+ onOpen: this.onLightboxOpened,
+ onClose: this.onLightboxClosed,
+ onWillChange: this.onLightboxItemWillChange,
+ onItemDidChange: this.onLightboxItemDidChange,
+ onCleanUp: this.onLightboxCleanedUp,
+ };
+
+ this.options = {
+ isMobile: this.site.mobileView,
+ isRTL: isDocumentRTL(),
+ minCarosuelArrowItemCount: MIN_CAROUSEL_ARROW_ITEM_COUNT,
+ zoomOnOpen: false,
+ canDownload:
+ this.currentUser ||
+ !this.siteSettings.prevent_anons_from_downloading_files,
+ };
+
+ this.appEvents.on(
+ LIGHTBOX_APP_EVENT_NAMES.CLEAN,
+ this,
+ this.cleanupLightboxes
+ );
+ }
+
+ @bind
+ async onLightboxOpened({ items, currentItem }) {
+ this.originalSiteThemeColor = await getSiteThemeColor();
+
+ document.documentElement.classList.add(
+ DOCUMENT_ELEMENT_LIGHTBOX_OPEN_CLASS
+ );
+
+ this.#setupDocumentFocus();
+
+ this.appEvents.trigger(LIGHTBOX_APP_EVENT_NAMES.OPENED, {
+ items,
+ currentItem,
+ });
+ }
+
+ @bind
+ async onLightboxItemWillChange({ currentItem }) {
+ this.appEvents.trigger(LIGHTBOX_APP_EVENT_NAMES.ITEM_WILL_CHANGE, {
+ currentItem,
+ });
+ }
+
+ @bind
+ async onLightboxItemDidChange({ currentItem }) {
+ this.appEvents.trigger(LIGHTBOX_APP_EVENT_NAMES.ITEM_DID_CHANGE, {
+ currentItem,
+ });
+ }
+
+ @bind
+ async onLightboxClosed() {
+ document.documentElement.classList.remove(
+ DOCUMENT_ELEMENT_LIGHTBOX_OPEN_CLASS
+ );
+
+ setSiteThemeColor(this.originalSiteThemeColor);
+ this.#restoreDocumentFocus();
+
+ this.originalSiteThemeColor = "";
+ this.lightboxIsOpen = false;
+
+ this.appEvents.trigger(LIGHTBOX_APP_EVENT_NAMES.CLOSED);
+ }
+
+ @bind
+ onLightboxCleanedUp() {
+ return true;
+ }
+
+ @bind
+ handleEvent(event) {
+ const isLightboxClick = event
+ .composedPath()
+ .find(
+ (element) =>
+ element.matches &&
+ (element.matches(this.selector) ||
+ element.matches("[data-lightbox-trigger]"))
+ );
+
+ if (!isLightboxClick) {
+ return;
+ }
+
+ event.preventDefault();
+
+ this.openLightbox({
+ container: event.currentTarget,
+ selector: this.selector,
+ });
+
+ event.target.toggleAttribute(SELECTORS.DOCUMENT_LAST_FOCUSED_ELEMENT);
+ }
+
+ @bind
+ async openLightbox({ container, selector }) {
+ const { items, startingIndex } = await processHTML({ container, selector });
+
+ if (!items.length) {
+ return;
+ }
+
+ this.appEvents.trigger(LIGHTBOX_APP_EVENT_NAMES.OPEN, {
+ items,
+ startingIndex,
+ callbacks: { ...this.callbacks },
+ options: { ...this.options },
+ });
+
+ this.lightboxIsOpen = true;
+ }
+
+ @bind
+ async closeLightbox() {
+ if (this.lightboxIsOpen) {
+ this.appEvents.trigger(LIGHTBOX_APP_EVENT_NAMES.CLOSE);
+ this.lightboxIsOpen = false;
+ }
+ }
+
+ async #setupLightboxes({ container, selector }) {
+ if (!container) {
+ throw new Error("Lightboxes require a container to be passed in");
+ }
+
+ this.selector = selector;
+ const hasLightboxes = container.querySelector(selector);
+
+ if (!hasLightboxes) {
+ return;
+ }
+
+ const handlerOptions = { capture: true };
+
+ container.addEventListener("click", this, handlerOptions);
+
+ this.lightboxClickElements.push({ container, handlerOptions });
+ }
+
+ @bind
+ async setupLightboxes({ container, selector }) {
+ this.#setupLightboxes({ container, selector });
+ }
+
+ async #cleanupLightboxes() {
+ this.closeLightbox();
+
+ this.lightboxClickElements.forEach(({ container, handlerOptions }) => {
+ container.removeEventListener("click", this, handlerOptions);
+ });
+
+ this.lightboxClickElements = [];
+ }
+
+ @bind
+ async cleanupLightboxes() {
+ this.#cleanupLightboxes();
+ }
+
+ async #setupDocumentFocus() {
+ if (!this.lightboxIsOpen) {
+ return;
+ }
+
+ this.lastFocusedElement = document.activeElement;
+ document.activeElement.blur();
+ document.querySelector(SELECTORS.CLOSE_BUTTON)?.focus();
+
+ const focusableElements = document.querySelectorAll(
+ SELECTORS.LIGHTBOX_CONTAINER + " button"
+ );
+
+ const firstFocusableElement = focusableElements[0];
+ const lastFocusableElement =
+ focusableElements[focusableElements.length - 1];
+
+ const focusTraps = document.querySelectorAll(SELECTORS.FOCUS_TRAP);
+
+ const firstfocusTrap = focusTraps[0];
+ const lastfocusTrap = focusTraps[focusTraps.length - 1];
+
+ this.onFocus = ({ target }) => {
+ if (target === firstfocusTrap) {
+ lastFocusableElement.focus();
+ } else if (target === lastfocusTrap) {
+ firstFocusableElement.focus();
+ }
+ };
+
+ document.addEventListener("focus", this.onFocus, {
+ passive: true,
+ capture: true,
+ });
+ }
+
+ async #restoreDocumentFocus() {
+ document.removeEventListener("focus", this.onFocus, {
+ passive: true,
+ capture: true,
+ });
+
+ document.activeElement.blur();
+
+ this.lastFocusedElement?.focus();
+ }
+
+ async #reset() {
+ this.appEvents.off(
+ LIGHTBOX_APP_EVENT_NAMES.CLEAN,
+ this,
+ this.cleanupLightboxes
+ );
+ }
+
+ willDestroy() {
+ this.#reset();
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/templates/application.hbs b/app/assets/javascripts/discourse/app/templates/application.hbs
index 1a7df932747..0a604ff204d 100644
--- a/app/assets/javascripts/discourse/app/templates/application.hbs
+++ b/app/assets/javascripts/discourse/app/templates/application.hbs
@@ -96,6 +96,7 @@
@outletArgs={{hash showFooter=this.showFooter}}
/>
+
diff --git a/app/assets/javascripts/discourse/tests/acceptance/d-lightbox-test.js b/app/assets/javascripts/discourse/tests/acceptance/d-lightbox-test.js
new file mode 100644
index 00000000000..0da932577ef
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/acceptance/d-lightbox-test.js
@@ -0,0 +1,950 @@
+import {
+ LIGHTBOX_IMAGE_FIXTURES,
+ generateLightboxMarkup,
+} from "discourse/tests/helpers/lightbox-helpers";
+import {
+ acceptance,
+ chromeTest,
+ queryAll,
+} from "discourse/tests/helpers/qunit-helpers";
+import {
+ click,
+ triggerEvent,
+ triggerKeyEvent,
+ visit,
+ waitUntil,
+} from "@ember/test-helpers";
+
+import { cloneJSON } from "discourse-common/lib/object";
+import i18n from "I18n";
+import sinon from "sinon";
+import { test } from "qunit";
+import topicFixtures from "discourse/tests/fixtures/topic";
+import { SELECTORS } from "discourse/lib/lightbox/constants";
+
+async function waitForLoad() {
+ return await waitUntil(
+ () => document.querySelector(".d-lightbox.is-finished-loading"),
+ {
+ timeout: 5000,
+ }
+ );
+}
+
+const singleLargeImageMarkup = `
+${generateLightboxMarkup(LIGHTBOX_IMAGE_FIXTURES.first)}`;
+
+const singleSmallImageMarkup = `
+${generateLightboxMarkup(LIGHTBOX_IMAGE_FIXTURES.smallerThanViewPort)}
+`;
+
+const multipleLargeImagesMarkup = `
+${generateLightboxMarkup(LIGHTBOX_IMAGE_FIXTURES.first)}
+${generateLightboxMarkup(LIGHTBOX_IMAGE_FIXTURES.second)}
+${generateLightboxMarkup(LIGHTBOX_IMAGE_FIXTURES.third)}
+`;
+
+const markupWithInvalidImage = `
+${generateLightboxMarkup(LIGHTBOX_IMAGE_FIXTURES.first)}
+${generateLightboxMarkup(LIGHTBOX_IMAGE_FIXTURES.invalidImage)}
+${generateLightboxMarkup(LIGHTBOX_IMAGE_FIXTURES.second)}`;
+
+function setupPretender(server, helper, markup) {
+ const topicResponse = cloneJSON(topicFixtures["/t/280/1.json"]);
+ topicResponse.post_stream.posts[0].cooked += markup;
+
+ server.get("/t/280.json", () => helper.response(topicResponse));
+ server.get("/t/280/:post_number.json", () => helper.response(topicResponse));
+}
+
+acceptance("Experimental Lightbox - site setting", function (needs) {
+ needs.pretender((server, helper) =>
+ setupPretender(server, helper, singleLargeImageMarkup)
+ );
+
+ test("it does not interfere with Magnific when enable_experimental_lightbox is disabled", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+ await click(SELECTORS.DEFAULT_ITEM_SELECTOR);
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+
+ await click(".mfp-close");
+ });
+});
+
+acceptance("Experimental Lightbox - layout single image", function (needs) {
+ needs.settings({ enable_experimental_lightbox: true });
+
+ needs.pretender((server, helper) =>
+ setupPretender(server, helper, singleLargeImageMarkup)
+ );
+
+ test("it shows the correct elements for a single-image lightbox", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+ await click(SELECTORS.DEFAULT_ITEM_SELECTOR);
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).exists();
+
+ await waitForLoad();
+
+ assert.dom(".d-lightbox__main-title").exists();
+
+ assert
+ .dom(SELECTORS.ACTIVE_ITEM_TITLE)
+ .hasText(LIGHTBOX_IMAGE_FIXTURES.first.title);
+
+ assert
+ .dom(SELECTORS.ACTIVE_ITEM_FILE_DETAILS)
+ .hasText(LIGHTBOX_IMAGE_FIXTURES.first.fileDetails);
+
+ assert.dom(SELECTORS.CLOSE_BUTTON).exists();
+ assert.dom(SELECTORS.CLOSE_BUTTON).isFocused();
+ assert.dom(SELECTORS.TAB_BUTTON).exists();
+ assert.dom(SELECTORS.FULL_SCREEN_BUTTON).exists();
+ assert.dom(SELECTORS.ROTATE_BUTTON).exists();
+ assert.dom(SELECTORS.ZOOM_BUTTON).exists();
+ assert.dom(SELECTORS.DOWNLOAD_BUTTON).exists();
+ assert.dom(SELECTORS.PREV_BUTTON).doesNotExist();
+ assert.dom(SELECTORS.NEXT_BUTTON).doesNotExist();
+ assert.dom(SELECTORS.MULTI_BUTTONS).doesNotExist();
+ assert.dom(SELECTORS.CAROUSEL).doesNotExist();
+ assert.dom(SELECTORS.MAIN_IMAGE).exists();
+ assert.dom(SELECTORS.MAIN_IMAGE).hasAttribute("src");
+
+ assert.dom(".d-lightbox__error-message").doesNotExist();
+
+ assert
+ .dom(SELECTORS.MAIN_IMAGE)
+ .hasAttribute("src", LIGHTBOX_IMAGE_FIXTURES.first.fullsizeURL);
+
+ assert.dom(".d-lightbox.is-fullscreen").doesNotExist();
+ assert.dom(".d-lightbox.is-rotated").doesNotExist();
+ assert.dom(".d-lightbox.is-zoomed").doesNotExist();
+ assert.dom(".d-lightbox__backdrop").exists();
+
+ await click(SELECTORS.CLOSE_BUTTON);
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+ });
+});
+
+acceptance("Experimental Lightbox - layout multiple images", function (needs) {
+ needs.settings({ enable_experimental_lightbox: true });
+
+ needs.pretender((server, helper) =>
+ setupPretender(server, helper, multipleLargeImagesMarkup)
+ );
+
+ test("it shows multiple image controls when there's more than one item", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+
+ await click(SELECTORS.DEFAULT_ITEM_SELECTOR);
+ await waitForLoad();
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).exists();
+ assert.dom(SELECTORS.PREV_BUTTON).exists();
+ assert.dom(SELECTORS.NEXT_BUTTON).exists();
+ assert.dom(SELECTORS.MULTI_BUTTONS).exists();
+ assert.dom(SELECTORS.COUNTERS).exists();
+ assert.dom(SELECTORS.COUNTER_CURRENT).hasText("1");
+ assert.dom(SELECTORS.COUNTER_TOTAL).hasText("3");
+ assert.dom(SELECTORS.CAROUSEL).doesNotExist();
+
+ await click(SELECTORS.CLOSE_BUTTON);
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+ });
+});
+
+acceptance("Experimental Lightbox - interaction", function (needs) {
+ needs.settings({ enable_experimental_lightbox: true });
+
+ needs.pretender((server, helper) =>
+ setupPretender(server, helper, multipleLargeImagesMarkup)
+ );
+
+ test("handles zoom", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+ await click(SELECTORS.DEFAULT_ITEM_SELECTOR);
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).exists();
+
+ await waitForLoad();
+ assert.dom(".d-lightbox.is-zoomed").doesNotExist();
+
+ await click(SELECTORS.ZOOM_BUTTON);
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).hasClass("is-zoomed");
+ assert.dom(SELECTORS.ACTIVE_ITEM_TITLE).doesNotExist();
+
+ await click(SELECTORS.ZOOM_BUTTON);
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).doesNotHaveClass("is-zoomed");
+ assert.dom(SELECTORS.ACTIVE_ITEM_TITLE).exists();
+
+ await click(SELECTORS.MAIN_IMAGE);
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).hasClass("is-zoomed");
+
+ await click(".d-lightbox__zoomed-image-container");
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).doesNotHaveClass("is-zoomed");
+
+ await click(SELECTORS.CLOSE_BUTTON);
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+ });
+
+ test("handles rotation", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+ await click(SELECTORS.DEFAULT_ITEM_SELECTOR);
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).exists();
+
+ await waitForLoad();
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).doesNotHaveClass("is-rotated");
+
+ await click(SELECTORS.ROTATE_BUTTON);
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).hasClass("is-rotated");
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).hasClass("is-rotated-90");
+
+ await click(SELECTORS.ROTATE_BUTTON);
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).hasClass("is-rotated-180");
+
+ await click(SELECTORS.ROTATE_BUTTON);
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).hasClass("is-rotated-270");
+
+ await click(SELECTORS.ROTATE_BUTTON);
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).doesNotHaveClass("is-rotated");
+
+ await click(SELECTORS.CLOSE_BUTTON);
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+ });
+
+ test("handles navigation - next", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+ await click(SELECTORS.DEFAULT_ITEM_SELECTOR);
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).exists();
+ assert.dom(SELECTORS.COUNTER_CURRENT).hasText("1");
+ assert.dom(SELECTORS.COUNTER_TOTAL).hasText("3");
+
+ await waitForLoad();
+
+ assert
+ .dom(SELECTORS.MAIN_IMAGE)
+ .hasAttribute("src", LIGHTBOX_IMAGE_FIXTURES.first.fullsizeURL);
+
+ await click(SELECTORS.NEXT_BUTTON);
+ await waitForLoad();
+
+ assert.dom(SELECTORS.COUNTER_CURRENT).hasText("2");
+
+ assert
+ .dom(SELECTORS.MAIN_IMAGE)
+ .hasAttribute("src", LIGHTBOX_IMAGE_FIXTURES.second.fullsizeURL);
+
+ await click(SELECTORS.NEXT_BUTTON);
+ await waitForLoad();
+
+ assert.dom(SELECTORS.COUNTER_CURRENT).hasText("3");
+
+ assert
+ .dom(SELECTORS.MAIN_IMAGE)
+ .hasAttribute("src", LIGHTBOX_IMAGE_FIXTURES.third.fullsizeURL);
+
+ await click(SELECTORS.NEXT_BUTTON);
+ await waitForLoad();
+
+ assert.dom(SELECTORS.COUNTER_CURRENT).hasText("1");
+
+ assert
+ .dom(SELECTORS.MAIN_IMAGE)
+ .hasAttribute("src", LIGHTBOX_IMAGE_FIXTURES.first.fullsizeURL);
+
+ await click(SELECTORS.CLOSE_BUTTON);
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+ });
+
+ test("handles navigation - previous", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+ await click(SELECTORS.DEFAULT_ITEM_SELECTOR);
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).exists();
+ assert.dom(SELECTORS.COUNTER_CURRENT).hasText("1");
+ assert.dom(SELECTORS.COUNTER_TOTAL).hasText("3");
+
+ await waitForLoad();
+ assert
+ .dom(SELECTORS.MAIN_IMAGE)
+ .hasAttribute("src", LIGHTBOX_IMAGE_FIXTURES.first.fullsizeURL);
+
+ await click(SELECTORS.PREV_BUTTON);
+ assert.dom(SELECTORS.COUNTER_CURRENT).hasText("3");
+
+ await waitForLoad();
+ assert
+ .dom(SELECTORS.MAIN_IMAGE)
+ .hasAttribute("src", LIGHTBOX_IMAGE_FIXTURES.third.fullsizeURL);
+
+ await click(SELECTORS.PREV_BUTTON);
+ assert.dom(SELECTORS.COUNTER_CURRENT).hasText("2");
+
+ await waitForLoad();
+ assert
+ .dom(SELECTORS.MAIN_IMAGE)
+ .hasAttribute("src", LIGHTBOX_IMAGE_FIXTURES.second.fullsizeURL);
+
+ await click(SELECTORS.PREV_BUTTON);
+ assert.dom(SELECTORS.COUNTER_CURRENT).hasText("1");
+
+ await waitForLoad();
+ assert
+ .dom(SELECTORS.MAIN_IMAGE)
+ .hasAttribute("src", LIGHTBOX_IMAGE_FIXTURES.first.fullsizeURL);
+
+ await click(SELECTORS.CLOSE_BUTTON);
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+ });
+
+ test("handles navigation - opens at the correct index", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+ const lightboxes = queryAll(SELECTORS.DEFAULT_ITEM_SELECTOR);
+
+ await click(lightboxes[1]);
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).exists();
+ assert.dom(SELECTORS.COUNTER_CURRENT).hasText("2");
+ assert.dom(SELECTORS.COUNTER_TOTAL).hasText("3");
+
+ await waitForLoad();
+ assert
+ .dom(SELECTORS.MAIN_IMAGE)
+ .hasAttribute("src", LIGHTBOX_IMAGE_FIXTURES.second.fullsizeURL);
+
+ await click(SELECTORS.CLOSE_BUTTON);
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+
+ await click(lightboxes[2]);
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).exists();
+ assert.dom(SELECTORS.COUNTER_CURRENT).hasText("3");
+ assert.dom(SELECTORS.COUNTER_TOTAL).hasText("3");
+
+ await waitForLoad();
+ assert
+ .dom(SELECTORS.MAIN_IMAGE)
+ .hasAttribute("src", LIGHTBOX_IMAGE_FIXTURES.third.fullsizeURL);
+
+ await click(SELECTORS.CLOSE_BUTTON);
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+ });
+
+ test(`handles navigation - prevents document scroll while the lightbox is open`, async function (assert) {
+ await visit("/t/internationalization-localization/280");
+
+ const classListAddStub = sinon.stub(
+ document.documentElement.classList,
+ "add"
+ );
+ const classListRemoveStub = sinon.stub(
+ document.documentElement.classList,
+ "remove"
+ );
+
+ await click(SELECTORS.DEFAULT_ITEM_SELECTOR);
+ await waitForLoad();
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).exists();
+
+ assert.ok(
+ classListAddStub.calledWith("has-lightbox"),
+ "adds has-lightbox class to document element"
+ );
+
+ await click(SELECTORS.CLOSE_BUTTON);
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+
+ assert.ok(
+ classListRemoveStub.calledWith("has-lightbox"),
+ "removes has-lightbox class from document element"
+ );
+
+ classListAddStub.restore();
+ classListRemoveStub.restore();
+ });
+
+ test("handles fullscreen", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+
+ await click(SELECTORS.DEFAULT_ITEM_SELECTOR);
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).exists();
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).doesNotHaveClass("is-fullscreen");
+
+ const requestFullscreenStub = sinon.stub(
+ document.documentElement,
+ "requestFullscreen"
+ );
+
+ const exitFullscreenStub = sinon.stub(document, "exitFullscreen");
+
+ await click(SELECTORS.FULL_SCREEN_BUTTON);
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).hasClass("is-fullscreen");
+ assert.ok(requestFullscreenStub.calledOnce, "it calls requestFullscreen");
+
+ await click(SELECTORS.FULL_SCREEN_BUTTON);
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotHaveClass("is-fullscreen");
+
+ assert.ok(exitFullscreenStub.calledOnce, "it calls exitFullscreen");
+
+ await click(SELECTORS.CLOSE_BUTTON);
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+
+ requestFullscreenStub.restore();
+ exitFullscreenStub.restore();
+ });
+
+ test("handles download", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+
+ await click(SELECTORS.DEFAULT_ITEM_SELECTOR);
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).exists();
+
+ const clickStub = sinon.stub(HTMLAnchorElement.prototype, "click");
+
+ // appends and clicks
+ await click(SELECTORS.DOWNLOAD_BUTTON);
+
+ assert.ok(clickStub.called, "The click method was called");
+
+ await click(SELECTORS.CLOSE_BUTTON);
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+
+ clickStub.restore();
+ });
+
+ test("handles newtab", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+
+ await click(SELECTORS.DEFAULT_ITEM_SELECTOR);
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).exists();
+
+ const openStub = sinon.stub(window, "open");
+
+ await click(SELECTORS.TAB_BUTTON);
+
+ assert.ok(openStub.called, "The open method was called");
+
+ await click(SELECTORS.CLOSE_BUTTON);
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+
+ openStub.restore();
+ });
+
+ test("handles close", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+
+ await click(SELECTORS.DEFAULT_ITEM_SELECTOR);
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).exists();
+
+ await click(SELECTORS.CLOSE_BUTTON);
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+ });
+
+ test("handles focus", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+
+ await click(SELECTORS.DEFAULT_ITEM_SELECTOR);
+ await waitForLoad();
+
+ assert.dom(SELECTORS.CLOSE_BUTTON).isFocused();
+
+ // tab forward
+ Array(50)
+ .fill()
+ .forEach(async () => {
+ await triggerKeyEvent(SELECTORS.LIGHTBOX_CONTENT, "keyup", 9);
+ });
+
+ // it keeps focus inside the lightbox when tabbing forward
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).exists();
+
+ // tab backward
+ Array(50)
+ .fill()
+ .forEach(async () => {
+ await triggerKeyEvent(SELECTORS.LIGHTBOX_CONTENT, "keyup", 9, {
+ shiftKey: true,
+ });
+ });
+
+ // it keeps focus inside the lightbox when tabbing backward
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).exists();
+
+ await click(SELECTORS.CLOSE_BUTTON);
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+
+ // it restores focus in the main document when closed
+ assert.dom(SELECTORS.DEFAULT_ITEM_SELECTOR).isFocused();
+ });
+
+ test("navigation - screen reader announcer", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+
+ const firstExpectedTitle = i18n.t(
+ "experimental_lightbox.screen_reader_image_title",
+ {
+ current: 1,
+ total: 3,
+ title: LIGHTBOX_IMAGE_FIXTURES.first.title,
+ }
+ );
+
+ const secondExpectedTitle = i18n.t(
+ "experimental_lightbox.screen_reader_image_title",
+ {
+ current: 2,
+ total: 3,
+ title: LIGHTBOX_IMAGE_FIXTURES.second.title,
+ }
+ );
+
+ await click(SELECTORS.DEFAULT_ITEM_SELECTOR);
+ await waitForLoad();
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).exists();
+
+ assert.dom(".d-lightbox__screen-reader-announcer").exists();
+
+ assert
+ .dom(".d-lightbox__screen-reader-announcer")
+ .hasText(firstExpectedTitle);
+
+ await click(SELECTORS.NEXT_BUTTON);
+ await waitForLoad();
+
+ assert
+ .dom(".d-lightbox__screen-reader-announcer")
+ .hasText(secondExpectedTitle);
+
+ await click(SELECTORS.CLOSE_BUTTON);
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+ });
+
+ // TODO: this test is flaky on firefox. It runs fine locally and the functionality works in a real session, but fails on CI.
+ chromeTest("handles keyboard shortcuts", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+ await click(SELECTORS.DEFAULT_ITEM_SELECTOR);
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).exists();
+
+ await waitForLoad();
+
+ await triggerKeyEvent(SELECTORS.LIGHTBOX_CONTENT, "keyup", "ArrowRight");
+
+ assert.dom(SELECTORS.COUNTER_CURRENT).hasText("2");
+
+ await triggerKeyEvent(SELECTORS.LIGHTBOX_CONTENT, "keyup", "ArrowLeft");
+
+ assert.dom(SELECTORS.COUNTER_CURRENT).hasText("1");
+
+ await triggerKeyEvent(SELECTORS.LIGHTBOX_CONTENT, "keyup", "ArrowDown");
+
+ assert.dom(SELECTORS.COUNTER_CURRENT).hasText("2");
+
+ await triggerKeyEvent(SELECTORS.LIGHTBOX_CONTENT, "keyup", "ArrowUp");
+
+ assert.dom(SELECTORS.COUNTER_CURRENT).hasText("1");
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).doesNotHaveClass("is-zoomed");
+
+ await triggerKeyEvent(SELECTORS.LIGHTBOX_CONTENT, "keyup", 90); // 'z' key
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).hasClass("is-zoomed");
+
+ await triggerKeyEvent(SELECTORS.LIGHTBOX_CONTENT, "keyup", 90);
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).doesNotHaveClass("is-zoomed");
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).doesNotHaveClass("is-rotated");
+
+ await triggerKeyEvent(SELECTORS.LIGHTBOX_CONTENT, "keyup", 82); // r key
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).hasClass("is-rotated");
+
+ await triggerKeyEvent(SELECTORS.LIGHTBOX_CONTENT, "keyup", 82);
+ await triggerKeyEvent(SELECTORS.LIGHTBOX_CONTENT, "keyup", 82);
+ await triggerKeyEvent(SELECTORS.LIGHTBOX_CONTENT, "keyup", 82);
+
+ // back to original rotation
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).doesNotHaveClass("is-rotated");
+
+ assert.dom(SELECTORS.CAROUSEL).doesNotExist();
+
+ await triggerKeyEvent(SELECTORS.LIGHTBOX_CONTENT, "keyup", 65); // 'a' key
+
+ assert.dom(SELECTORS.CAROUSEL).exists();
+
+ await triggerKeyEvent(SELECTORS.LIGHTBOX_CONTENT, "keyup", 65);
+
+ assert.dom(SELECTORS.CAROUSEL).doesNotExist();
+
+ assert
+ .dom(SELECTORS.LIGHTBOX_CONTAINER)
+ .doesNotHaveClass("has-expanded-title");
+
+ await triggerKeyEvent(SELECTORS.LIGHTBOX_CONTENT, "keyup", 84); // 't' key
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).hasClass("has-expanded-title");
+
+ await triggerKeyEvent(SELECTORS.LIGHTBOX_CONTENT, "keyup", 84);
+
+ assert
+ .dom(SELECTORS.LIGHTBOX_CONTAINER)
+ .doesNotHaveClass("has-expanded-title");
+
+ const requestFullscreenStub = sinon.stub(
+ document.documentElement,
+ "requestFullscreen"
+ );
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).doesNotHaveClass("is-fullscreen");
+
+ const exitFullscreenStub = sinon.stub(document, "exitFullscreen");
+
+ await triggerKeyEvent(SELECTORS.LIGHTBOX_CONTENT, "keyup", 77); // 'm' key
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).hasClass("is-fullscreen");
+
+ await triggerKeyEvent(SELECTORS.LIGHTBOX_CONTENT, "keyup", 77);
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).doesNotHaveClass("is-fullscreen");
+
+ requestFullscreenStub.restore();
+ exitFullscreenStub.restore();
+
+ await triggerKeyEvent(SELECTORS.LIGHTBOX_CONTENT, "keyup", "Escape");
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+ });
+});
+
+acceptance("Experimental Lightbox - carousel", function (needs) {
+ needs.settings({ enable_experimental_lightbox: true });
+
+ needs.pretender((server, helper) =>
+ setupPretender(
+ server,
+ helper,
+ multipleLargeImagesMarkup + multipleLargeImagesMarkup
+ )
+ );
+
+ test("navigation", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+ await click(SELECTORS.DEFAULT_ITEM_SELECTOR);
+
+ // lightbox opens with the first image
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).exists();
+ assert.dom(SELECTORS.COUNTER_CURRENT).hasText("1");
+
+ // carousel is not visible by default
+ assert.dom(SELECTORS.CAROUSEL).doesNotExist();
+
+ await click(SELECTORS.CAROUSEL_BUTTON);
+
+ // carousel opens after clicking the button, and has prev/next buttons
+ assert.dom(SELECTORS.CAROUSEL).exists();
+ assert.dom(SELECTORS.CAROUSEL_PREV_BUTTON).exists();
+ assert.dom(SELECTORS.CAROUSEL_NEXT_BUTTON).exists();
+
+ // carousel has 5 items and an active item
+ assert.dom(SELECTORS.CAROUSEL_ITEM).exists({ count: 6 });
+ assert.dom(SELECTORS.CAROUSEL_ITEM + ".is-current").exists();
+
+ await waitForLoad();
+
+ // carousel current item is the first image
+ assert
+ .dom(SELECTORS.CAROUSEL_ITEM + ".is-current")
+ .hasAttribute("src", LIGHTBOX_IMAGE_FIXTURES.first.smallURL);
+
+ await click(SELECTORS.CAROUSEL_NEXT_BUTTON);
+ await waitForLoad();
+
+ // carousel next button works and current item is the second image
+ assert
+ .dom(SELECTORS.CAROUSEL_ITEM + ".is-current")
+ .hasAttribute("src", LIGHTBOX_IMAGE_FIXTURES.second.smallURL);
+
+ await click(SELECTORS.CAROUSEL_PREV_BUTTON);
+ await waitForLoad();
+
+ // carousel previous button works and current item is the first image again
+ assert
+ .dom(SELECTORS.CAROUSEL_ITEM + ".is-current")
+ .hasAttribute("src", LIGHTBOX_IMAGE_FIXTURES.first.smallURL);
+
+ await click(SELECTORS.CAROUSEL_ITEM + ":nth-child(3)");
+ await waitForLoad();
+
+ // carousel manual item selection works and current item is the third image
+ assert
+ .dom(SELECTORS.CAROUSEL_ITEM + ".is-current")
+ .hasAttribute("src", LIGHTBOX_IMAGE_FIXTURES.third.smallURL);
+
+ // carousel closes after clicking the carousel button again
+ await click(SELECTORS.CAROUSEL_BUTTON);
+ assert.dom(SELECTORS.CAROUSEL).doesNotExist();
+
+ await click(SELECTORS.CLOSE_BUTTON);
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+ });
+
+ test("arrows are not shown when there are only a few images", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+
+ const lightboxes = [...queryAll(SELECTORS.DEFAULT_ITEM_SELECTOR)];
+
+ const lastThreeLightboxes = lightboxes.slice(-3);
+
+ lastThreeLightboxes.forEach((lightbox) => {
+ lightbox.remove();
+ });
+
+ await click(SELECTORS.DEFAULT_ITEM_SELECTOR);
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).exists();
+ assert.dom(SELECTORS.CAROUSEL).doesNotExist();
+
+ // carousel opens after clicking the button
+ await click(SELECTORS.CAROUSEL_BUTTON);
+ assert.dom(SELECTORS.CAROUSEL).exists();
+ assert.dom(SELECTORS.CAROUSEL_ITEM).exists({ count: 3 });
+
+ // no prev/next buttons when carousel only has a few images
+ assert.dom(SELECTORS.CAROUSEL_PREV_BUTTON).doesNotExist();
+ assert.dom(SELECTORS.CAROUSEL_NEXT_BUTTON).doesNotExist();
+
+ // carousel closes after clicking the button again
+ await click(SELECTORS.CAROUSEL_BUTTON);
+ assert.dom(SELECTORS.CAROUSEL).doesNotExist();
+
+ await click(SELECTORS.CLOSE_BUTTON);
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+ });
+});
+
+acceptance("Experimental Lightbox - mobile", function (needs) {
+ needs.settings({ enable_experimental_lightbox: true });
+
+ needs.pretender((server, helper) =>
+ setupPretender(server, helper, multipleLargeImagesMarkup)
+ );
+
+ test("navigation - swipe navigation LTR", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+ await click(SELECTORS.DEFAULT_ITEM_SELECTOR);
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).exists();
+ assert.dom(SELECTORS.COUNTER_CURRENT).hasText("1");
+
+ await triggerEvent(SELECTORS.LIGHTBOX_BODY, "touchstart", {
+ changedTouches: [{ screenX: 0, screenY: 0 }],
+ touches: [{ screenX: 0, screenY: 0 }],
+ });
+
+ await triggerEvent(SELECTORS.LIGHTBOX_BODY, "touchend", {
+ changedTouches: [{ screenX: 150, screenY: 0 }],
+ touches: [{ pageX: 150, pageY: 0 }],
+ });
+
+ // swiping left goes to the previous image
+ assert.dom(SELECTORS.COUNTER_CURRENT).hasText("3");
+
+ await triggerEvent(SELECTORS.LIGHTBOX_BODY, "touchstart", {
+ changedTouches: [{ screenX: 0, screenY: 0 }],
+ touches: [{ screenX: 0, screenY: 0 }],
+ });
+
+ await triggerEvent(SELECTORS.LIGHTBOX_BODY, "touchend", {
+ changedTouches: [{ screenX: -150, screenY: 0 }],
+ touches: [{ pageX: 150, pageY: 0 }],
+ });
+
+ // swiping right goes to the next image
+ assert.dom(SELECTORS.COUNTER_CURRENT).hasText("1");
+
+ await click(SELECTORS.CLOSE_BUTTON);
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+ });
+
+ test("navigation - swipe navigation RTL", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+ await click(SELECTORS.DEFAULT_ITEM_SELECTOR);
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).exists();
+ assert.dom(SELECTORS.COUNTER_CURRENT).hasText("1");
+
+ const containsStub = sinon.stub(
+ document.documentElement.classList,
+ "contains"
+ );
+
+ containsStub.withArgs("rtl").returns(true);
+
+ await triggerEvent(SELECTORS.LIGHTBOX_BODY, "touchstart", {
+ changedTouches: [{ screenX: 0, screenY: 0 }],
+ touches: [{ screenX: 0, screenY: 0 }],
+ });
+
+ await triggerEvent(SELECTORS.LIGHTBOX_BODY, "touchend", {
+ changedTouches: [{ screenX: -150, screenY: 0 }],
+ touches: [{ pageX: 150, pageY: 0 }],
+ });
+
+ // swiping left goes to the next image in RTL
+ assert.dom(SELECTORS.COUNTER_CURRENT).hasText("2");
+
+ await triggerEvent(SELECTORS.LIGHTBOX_BODY, "touchstart", {
+ changedTouches: [{ screenX: 0, screenY: 0 }],
+ touches: [{ screenX: 0, screenY: 0 }],
+ });
+
+ await triggerEvent(SELECTORS.LIGHTBOX_BODY, "touchend", {
+ changedTouches: [{ screenX: 150, screenY: 0 }],
+ touches: [{ pageX: 150, pageY: 0 }],
+ });
+
+ // swiping right goes to the previous image in RTL
+ assert.dom(SELECTORS.COUNTER_CURRENT).hasText("1");
+
+ containsStub.restore();
+
+ await click(SELECTORS.CLOSE_BUTTON);
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+ });
+
+ test("navigation - swipe close", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+ await click(SELECTORS.DEFAULT_ITEM_SELECTOR);
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).exists();
+
+ await triggerEvent(SELECTORS.LIGHTBOX_BODY, "touchstart", {
+ changedTouches: [{ screenX: 0, screenY: 0 }],
+ touches: [{ screenX: 0, screenY: 0 }],
+ });
+
+ await triggerEvent(SELECTORS.LIGHTBOX_BODY, "touchend", {
+ changedTouches: [{ screenX: 0, screenY: -150 }],
+ touches: [{ pageX: 0, pageY: 150 }],
+ });
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+ });
+
+ test("navigation - swipe carousel", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+
+ await click(SELECTORS.DEFAULT_ITEM_SELECTOR);
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).exists();
+ assert.dom(SELECTORS.CAROUSEL).doesNotExist();
+
+ await triggerEvent(SELECTORS.LIGHTBOX_BODY, "touchstart", {
+ changedTouches: [{ screenX: 0, screenY: 0 }],
+ touches: [{ screenX: 0, screenY: 0 }],
+ });
+
+ await triggerEvent(SELECTORS.LIGHTBOX_BODY, "touchend", {
+ changedTouches: [{ screenX: 0, screenY: 150 }],
+ touches: [{ pageX: 0, pageY: 150 }],
+ });
+
+ assert.dom(SELECTORS.CAROUSEL).exists(); // opens after swiping down
+
+ await triggerEvent(SELECTORS.LIGHTBOX_BODY, "touchstart", {
+ changedTouches: [{ screenX: 0, screenY: 0 }],
+ touches: [{ screenX: 0, screenY: 0 }],
+ });
+
+ await triggerEvent(SELECTORS.LIGHTBOX_BODY, "touchend", {
+ changedTouches: [{ screenX: 0, screenY: 150 }],
+ touches: [{ pageX: 0, pageY: 150 }],
+ });
+
+ assert.dom(SELECTORS.CAROUSEL).doesNotExist(); // closes after swiping down again
+
+ await click(SELECTORS.CLOSE_BUTTON);
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+ });
+});
+
+acceptance("Experimental Lightbox - loading state", function (needs) {
+ needs.settings({ enable_experimental_lightbox: true });
+
+ needs.pretender((server, helper) =>
+ setupPretender(server, helper, markupWithInvalidImage)
+ );
+
+ test("handles loading errors", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+
+ await click(SELECTORS.DEFAULT_ITEM_SELECTOR);
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).exists();
+
+ await waitForLoad();
+
+ // the image has the correct src
+ assert
+ .dom(SELECTORS.MAIN_IMAGE)
+ .hasAttribute("src", LIGHTBOX_IMAGE_FIXTURES.first.fullsizeURL);
+
+ await click(SELECTORS.NEXT_BUTTON);
+
+ // does not show an image if it can't be loaded
+ assert.dom(SELECTORS.MAIN_IMAGE).doesNotExist();
+
+ await click(SELECTORS.NEXT_BUTTON);
+
+ // it shows the correct image when navigating after an error
+ assert.dom(SELECTORS.COUNTER_CURRENT).hasText("3");
+
+ await waitForLoad();
+
+ assert
+ .dom(SELECTORS.MAIN_IMAGE)
+ .hasAttribute("src", LIGHTBOX_IMAGE_FIXTURES.second.fullsizeURL);
+
+ await click(SELECTORS.CLOSE_BUTTON);
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+ });
+});
+
+acceptance("Experimental Lightbox - conditional buttons", function (needs) {
+ needs.settings({
+ enable_experimental_lightbox: true,
+ prevent_anons_from_downloading_files: true,
+ });
+
+ needs.pretender((server, helper) =>
+ setupPretender(server, helper, singleSmallImageMarkup)
+ );
+
+ test("it doesn't show the newtab and download buttons to anons if prevent_anons_from_downloading_files is enabled", async function (assert) {
+ this.siteSettings.prevent_anons_from_downloading_files = true;
+ await visit("/t/internationalization-localization/280");
+ await click(SELECTORS.DEFAULT_ITEM_SELECTOR);
+
+ // it doesn not show the newtab or download button
+ assert.dom(SELECTORS.TAB_BUTTON).doesNotExist();
+ assert.dom(SELECTORS.DOWNLOAD_BUTTON).doesNotExist();
+ });
+
+ test("it doesn't show the zoom button if the image is smaller than the viewport", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+
+ await click(SELECTORS.DEFAULT_ITEM_SELECTOR);
+ assert.dom(SELECTORS.ZOOM_BUTTON).doesNotExist();
+
+ await click(SELECTORS.CLOSE_BUTTON);
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+ });
+});
diff --git a/app/assets/javascripts/discourse/tests/helpers/lightbox-helpers.js b/app/assets/javascripts/discourse/tests/helpers/lightbox-helpers.js
new file mode 100644
index 00000000000..e3b1c976673
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/helpers/lightbox-helpers.js
@@ -0,0 +1,176 @@
+import { htmlSafe } from "@ember/template";
+
+// we use transparent pngs here to avoid loading actual images in tests. We don't care so much about the content of the image
+// we only care that the correct loading state is set and the metadata is correct
+const PNGS = {
+ first: {
+ fullsizeURL:
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAC7gAAAAKCAYAAAAkEBP9AAAAqElEQVR42u3aQQEAIAwAIdfN/pVmDO8BOZizdw8AAAAAAAAAAAAAAHw2gjsAAAAAAAAAAAAAAAWCOwAAAAAAAAAAAAAACYI7AAAAAAAAAAAAAAAJgjsAAAAAAAAAAAAAAAmCOwAAAAAAAAAAAAAACYI7AAAAAAAAAAAAAAAJgjsAAAAAAAAAAAAAAAmCOwAAAAAAAAAAAAAACYI7AAAAAAAAAAAAAAAJD2GpFp8NV4+AAAAAAElFTkSuQmCC",
+ smallURL:
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAfQAAAABCAYAAAAo/lyUAAAAG0lEQVR42mP8z8AARKNgFIyCUTAKRsEoGMoAAJ3mAgDVocSsAAAAAElFTkSuQmCC",
+ },
+ second: {
+ fullsizeURL:
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA+gAAAACCAYAAADLlPadAAAAJ0lEQVR42u3XMQEAAAgDoNk/pzk0xh5owdzmAgAAAFSNoAMAAEDfA6HNBcm32R2bAAAAAElFTkSuQmCC",
+ smallURL:
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAfQAAAABCAYAAAAo/lyUAAAAHElEQVR42mP8/5ThP8MoGAWjYBSMglEwCoY0AACaegLl/taPAQAAAABJRU5ErkJggg==",
+ },
+ third: {
+ fullsizeURL:
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA+gAAAACCAYAAADLlPadAAAAJklEQVR42u3XMQEAAAgDoPnZv7DG2AMtmNxeAAAAgKoRdAAAAOh7JuQED1zV49EAAAAASUVORK5CYII=",
+ smallURL:
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAfQAAAABCAYAAAAo/lyUAAAAHElEQVR42mNk+M/xn2EUjIJRMApGwSgYBUMaAADbVwIINvIVWgAAAABJRU5ErkJggg==",
+ },
+ smallerThanViewPort: {
+ fullsizeURL:
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAASwAAAACCAYAAADirOGHAAAAIUlEQVR42u3UAQ0AAAgDoNvE/iU1xzcIwWTvAlBghAW0eNbwBD9majEtAAAAAElFTkSuQmCC",
+ smallURL:
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJYAAAABCAYAAAA8YlcZAAAAE0lEQVR42mNkUPj/n2EUjAIqAwD2IwIg6SI42wAAAABJRU5ErkJggg==",
+ },
+};
+
+const cssVars1 = htmlSafe(
+ `--dominant-color: #F0F1F3;--aspect-ratio: 3000 / 10;--small-url: url(${PNGS.first.smallURL});`
+);
+const cssVars2 = htmlSafe(
+ `--dominant-color: #F0F1F3;--aspect-ratio: 3000 / 10;--small-url: url(${PNGS.second.smallURL});`
+);
+const cssVars3 = htmlSafe(
+ `--dominant-color: #F0F1F3;--aspect-ratio: 3000 / 10;--small-url: url(${PNGS.third.smallURL});`
+);
+const cssVars4 = htmlSafe(
+ `--dominant-color: #F0F1F3;--aspect-ratio: 3000 / 10;--small-url: url(${PNGS.smallerThanViewPort.smallURL});`
+);
+
+export const LIGHTBOX_IMAGE_FIXTURES = {
+ first: {
+ fullsizeURL: PNGS.first.fullsizeURL,
+ smallURL: PNGS.first.smallURL,
+ downloadURL: PNGS.first.fullsizeURL,
+ fileDetails: "3000×10 221 KB",
+ width: 3000,
+ height: 10,
+ aspectRatio: "3000 / 10",
+ dominantColor: "F0F1F3",
+ index: 0,
+ title: "first image title",
+ alt: "first image alt",
+ cssVars: cssVars1,
+ },
+ second: {
+ fullsizeURL: PNGS.second.fullsizeURL,
+ smallURL: PNGS.second.smallURL,
+ downloadURL: PNGS.second.fullsizeURL,
+ fileDetails: "1000×2 166 KB",
+ width: 1000,
+ height: 2,
+ aspectRatio: "1000 / 2",
+ dominantColor: "F9F5F6",
+ index: 1,
+ title: "second image title",
+ alt: "second image alt",
+ cssVars: cssVars2,
+ },
+ third: {
+ fullsizeURL: PNGS.third.fullsizeURL,
+ smallURL: PNGS.third.smallURL,
+ downloadURL: PNGS.third.fullsizeURL,
+ fileDetails: "1000×2 240 KB",
+ width: 1000,
+ height: 2,
+ aspectRatio: "1000 / 2",
+ dominantColor: "EEF0EE",
+ index: 2,
+ title: "third image title",
+ alt: "third image alt",
+ cssVars: cssVars3,
+ },
+ smallerThanViewPort: {
+ fullsizeURL: PNGS.smallerThanViewPort.fullsizeURL,
+ smallURL: PNGS.smallerThanViewPort.smallURL,
+ downloadURL: PNGS.smallerThanViewPort.fullsizeURL,
+ fileDetails: "300×2 92.3 KB",
+ width: 300,
+ height: 2,
+ aspectRatio: "300 / 2",
+ dominantColor: "F0F0F1",
+ index: 3,
+ title: "fourth image title",
+ alt: "fourth image alt",
+ cssVars: cssVars4,
+ },
+ invalidImage: {
+ fullsizeURL: `https:expected-lightbox-invalid/.image/404.png`,
+ },
+};
+
+export function generateLightboxObject() {
+ const trimmedLighboxItem = Object.keys(LIGHTBOX_IMAGE_FIXTURES.first).reduce(
+ (acc, key) => {
+ if (key !== "height" && key !== "width" && key !== "alt") {
+ acc[key] = LIGHTBOX_IMAGE_FIXTURES.first[key];
+ }
+ return acc;
+ },
+ {}
+ );
+
+ return {
+ items: [{ ...trimmedLighboxItem }],
+ startingIndex: 0,
+ callbacks: {},
+ options: {},
+ };
+}
+
+export function generateLightboxMarkup(
+ {
+ fullsizeURL,
+ smallURL,
+ downloadURL,
+ title,
+ fileDetails,
+ dominantColor,
+ aspectRatio,
+ alt,
+ height,
+ width,
+ } = { ...LIGHTBOX_IMAGE_FIXTURES.first }
+) {
+ return `
+
+`;
+}
+
+export function generateImageUploaderMarkup(
+ fullsizeURL = LIGHTBOX_IMAGE_FIXTURES.first.fullsizeURL
+) {
+ return `
+
+`;
+}
diff --git a/app/assets/javascripts/discourse/tests/index.html b/app/assets/javascripts/discourse/tests/index.html
index 4732c5df3a3..c68bf4e7a7e 100644
--- a/app/assets/javascripts/discourse/tests/index.html
+++ b/app/assets/javascripts/discourse/tests/index.html
@@ -9,6 +9,7 @@
+
diff --git a/app/assets/javascripts/discourse/tests/integration/components/d-lightbox-test.js b/app/assets/javascripts/discourse/tests/integration/components/d-lightbox-test.js
new file mode 100644
index 00000000000..874c75731e4
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/integration/components/d-lightbox-test.js
@@ -0,0 +1,85 @@
+import { click, render, settled } from "@ember/test-helpers";
+import { query } from "discourse/tests/helpers/qunit-helpers";
+import { module, test } from "qunit";
+
+import domFromString from "discourse-common/lib/dom-from-string";
+import { generateLightboxMarkup } from "discourse/tests/helpers/lightbox-helpers";
+import { hbs } from "ember-cli-htmlbars";
+import { setupLightboxes } from "discourse/lib/lightbox";
+import { setupRenderingTest } from "discourse/tests/helpers/component-test";
+import { SELECTORS } from "discourse/lib/lightbox/constants";
+
+module("Integration | Component | d-lightbox", function (hooks) {
+ setupRenderingTest(hooks);
+
+ test("it renders according to state", async function (assert) {
+ await render(hbs` `);
+
+ // lightbox container exists but is not visible
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).exists();
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).doesNotHaveClass("is-visible");
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).hasAttribute("tabindex", "-1");
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+
+ // it is hidden from screen readers
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).hasAttribute("aria-hidden");
+
+ const container = domFromString(generateLightboxMarkup())[0];
+ await setupLightboxes({
+ container,
+ selector: SELECTORS.DEFAULT_ITEM_SELECTOR,
+ });
+
+ const lightboxedElement = container.querySelector(
+ SELECTORS.DEFAULT_ITEM_SELECTOR
+ );
+ await click(lightboxedElement);
+
+ await settled();
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).hasClass("is-visible");
+
+ assert
+ .dom(SELECTORS.LIGHTBOX_CONTAINER)
+ .hasClass(/^(is-vertical|is-horizontal)$/);
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).doesNotHaveClass("is-zoomed");
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).doesNotHaveClass("is-rotated");
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).doesNotHaveClass("is-fullscreen");
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).exists();
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).doesNotHaveAria("hidden");
+
+ // the content is tabbable
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).hasAttribute("tabindex", "0");
+
+ // the content has a document role
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).hasAttribute("role", "document");
+
+ // the content has an aria-labelledby attribute
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).hasAttribute("aria-labelledby");
+
+ assert.strictEqual(
+ query(SELECTORS.LIGHTBOX_CONTENT)
+ .getAttribute("style")
+ .match(/--d-lightbox/g).length > 0,
+ true,
+ "the content has the correct css variables added"
+ );
+
+ // it has focus traps for keyboard navigation
+ assert.dom(SELECTORS.FOCUS_TRAP).exists();
+
+ await click(SELECTORS.CLOSE_BUTTON);
+ await settled();
+
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).doesNotHaveClass("is-visible");
+ assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist();
+
+ // it is not tabbable
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).hasAttribute("tabindex", "-1");
+
+ // it is hidden from screen readers
+ assert.dom(SELECTORS.LIGHTBOX_CONTAINER).hasAttribute("aria-hidden");
+ });
+});
diff --git a/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/create-download-link-test.js b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/create-download-link-test.js
new file mode 100644
index 00000000000..60bad9d3034
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/create-download-link-test.js
@@ -0,0 +1,43 @@
+import { module, test } from "qunit";
+
+import { createDownloadLink } from "discourse/lib/lightbox/helpers";
+import sinon from "sinon";
+
+module(
+ "Unit | lib | Experimental Lightbox | Helpers | createDownloadLink()",
+ function () {
+ test("creates a download link with the correct href and download attributes", async function (assert) {
+ const lightboxItem = {
+ downloadURL: "http://example.com/download.jpg",
+ title: "image.jpg",
+ };
+ const createElementSpy = sinon.spy(document, "createElement");
+ const clickStub = sinon.stub(HTMLAnchorElement.prototype, "click");
+
+ createDownloadLink(lightboxItem);
+
+ assert.strictEqual(
+ createElementSpy.calledWith("a"),
+ true,
+ "creates an anchor element"
+ );
+
+ assert.strictEqual(
+ createElementSpy.returnValues[0].href,
+ "http://example.com/download.jpg",
+ "sets the correct href attribute"
+ );
+
+ assert.strictEqual(
+ createElementSpy.returnValues[0].download,
+ "image.jpg",
+ "sets the correct download attribute"
+ );
+
+ assert.strictEqual(clickStub.called, true, "clicks the link element");
+
+ createElementSpy.restore();
+ clickStub.restore();
+ });
+ }
+);
diff --git a/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/find-nearest-shared-parent-test.js b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/find-nearest-shared-parent-test.js
new file mode 100644
index 00000000000..d3f1d3e5f8e
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/find-nearest-shared-parent-test.js
@@ -0,0 +1,28 @@
+import { module, test } from "qunit";
+
+import { findNearestSharedParent } from "discourse/lib/lightbox/helpers";
+
+module(
+ "Unit | lib | Experimental Lightbox | Helpers | findNearestSharedParent()",
+ function () {
+ test("it returns the nearest shared parent for the elements passed in", async function (assert) {
+ const element0 = document.createElement("div");
+ const element1 = document.createElement("div");
+ const element2 = document.createElement("div");
+ const element3 = document.createElement("div");
+ const element4 = document.createElement("div");
+
+ element1.appendChild(element2);
+ element3.appendChild(element4);
+
+ element0.appendChild(element1);
+ element0.appendChild(element3);
+
+ assert.strictEqual(
+ findNearestSharedParent([element2, element4]),
+ element0,
+ "returns the correct nearest shared parent"
+ );
+ });
+ }
+);
diff --git a/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/get-swipe-direction-test.js b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/get-swipe-direction-test.js
new file mode 100644
index 00000000000..75f507ccea3
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/get-swipe-direction-test.js
@@ -0,0 +1,66 @@
+import { module, test } from "qunit";
+
+import { SWIPE_DIRECTIONS } from "discourse/lib/lightbox/constants";
+import { getSwipeDirection } from "discourse/lib/lightbox/helpers";
+
+module(
+ "Unit | lib | Experimental Lightbox | Helpers | getSwipeDirection()",
+ function () {
+ test("returns the correct direction based on the difference between touchstart and touchend", function (assert) {
+ assert.strictEqual(
+ getSwipeDirection({
+ touchstartX: 200,
+ touchstartY: 0,
+ touchendX: 50,
+ touchendY: 0,
+ }),
+ SWIPE_DIRECTIONS.RIGHT,
+ "returns 'RIGHT' for swipes with a large negative x-axis difference"
+ );
+
+ assert.strictEqual(
+ getSwipeDirection({
+ touchstartX: 50,
+ touchstartY: 0,
+ touchendX: 200,
+ touchendY: 0,
+ }),
+ SWIPE_DIRECTIONS.LEFT,
+ "returns 'LEFT' for swipes with a large positive x-axis difference"
+ );
+
+ assert.strictEqual(
+ getSwipeDirection({
+ touchstartX: 0,
+ touchstartY: 200,
+ touchendX: 0,
+ touchendY: 50,
+ }),
+ SWIPE_DIRECTIONS.UP,
+ "returns 'UP' for swipes with a large negative y-axis difference"
+ );
+
+ assert.strictEqual(
+ getSwipeDirection({
+ touchstartX: 0,
+ touchstartY: 50,
+ touchendX: 0,
+ touchendY: 200,
+ }),
+ SWIPE_DIRECTIONS.DOWN,
+ "returns 'DOWN' for swipes with a large positive y-axis difference"
+ );
+
+ assert.strictEqual(
+ getSwipeDirection({
+ touchstartX: 50,
+ touchstartY: 50,
+ touchendX: 49,
+ touchendY: 49,
+ }),
+ false,
+ "returns 'false' for swipes with a small x-axis difference and a small y-axis difference"
+ );
+ });
+ }
+);
diff --git a/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/open-image-in-new-tab-test.js b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/open-image-in-new-tab-test.js
new file mode 100644
index 00000000000..ebfdd45e97c
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/open-image-in-new-tab-test.js
@@ -0,0 +1,47 @@
+import { module, test } from "qunit";
+
+import { openImageInNewTab } from "discourse/lib/lightbox/helpers";
+import sinon from "sinon";
+
+module(
+ "Unit | lib | Experimental Lightbox | Helpers | openImageinNewTab()",
+ function () {
+ test("opens the fullsize URL of the lightbox item in a new tab", async function (assert) {
+ const lightboxItem = {
+ fullsizeURL: "image.jpg",
+ };
+
+ const openStub = sinon.stub(window, "open");
+
+ await openImageInNewTab(lightboxItem);
+
+ assert.strictEqual(
+ openStub.calledWith("image.jpg", "_blank"),
+ true,
+ "calls window.open with the correct arguments"
+ );
+
+ openStub.restore();
+ });
+
+ test("handles errors when trying to open the new tab", async function (assert) {
+ const lightboxItem = {
+ fullsizeURL: "image.jpg",
+ };
+
+ const openStub = sinon.stub(window, "open").throws();
+ const consoleErrorStub = sinon.stub(console, "error");
+
+ await openImageInNewTab(lightboxItem);
+
+ assert.strictEqual(
+ consoleErrorStub.called,
+ true,
+ "logs an error to the console"
+ );
+
+ openStub.restore();
+ consoleErrorStub.restore();
+ });
+ }
+);
diff --git a/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/preload-item-images-test.js b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/preload-item-images-test.js
new file mode 100644
index 00000000000..78b40128486
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/preload-item-images-test.js
@@ -0,0 +1,78 @@
+import {
+ LIGHTBOX_IMAGE_FIXTURES,
+ generateLightboxObject,
+} from "discourse/tests/helpers/lightbox-helpers";
+import { module, test } from "qunit";
+
+import { cloneJSON } from "discourse-common/lib/object";
+import { preloadItemImages } from "discourse/lib/lightbox/helpers";
+
+module(
+ "Unit | lib | Experimental Lightbox | Helpers | preloadItemImages()",
+
+ function () {
+ const baseLightboxItem = generateLightboxObject().items[0];
+
+ test("returns the correct object", async function (assert) {
+ const lightboxItem = cloneJSON(baseLightboxItem);
+
+ const result = await preloadItemImages(lightboxItem);
+
+ assert.ok(result.isLoaded, "isLoaded should be true");
+
+ assert.ok(!result.hasLoadingError, "hasLoadingError should be false");
+
+ assert.strictEqual(
+ result.width,
+ LIGHTBOX_IMAGE_FIXTURES.first.width,
+ "width should be equal to fullsizeImage width"
+ );
+
+ assert.strictEqual(
+ result.height,
+ LIGHTBOX_IMAGE_FIXTURES.first.height,
+ "height should be equal to fullsizeImage height"
+ );
+
+ assert.strictEqual(
+ result.aspectRatio,
+ LIGHTBOX_IMAGE_FIXTURES.first.aspectRatio,
+ "aspectRatio should be equal to image width/height"
+ );
+
+ assert.ok(
+ result.canZoom,
+ "canZoom should be true if fullsizeImage width or height is greater than window inner width or height"
+ );
+ });
+
+ test("handles errors", async function (assert) {
+ const lightboxItem = cloneJSON(baseLightboxItem);
+
+ lightboxItem.fullsizeURL =
+ LIGHTBOX_IMAGE_FIXTURES.invalidImage.fullsizeURL;
+
+ const result = await preloadItemImages(lightboxItem);
+
+ assert.strictEqual(
+ result.hasLoadingError,
+ true,
+ "sets hasLoadingError to true if there is an error"
+ );
+ });
+
+ test("handles images smaller than the viewport", async function (assert) {
+ const lightboxItem = cloneJSON(baseLightboxItem);
+
+ lightboxItem.fullsizeURL =
+ LIGHTBOX_IMAGE_FIXTURES.smallerThanViewPort.fullsizeURL;
+
+ const result = await preloadItemImages(lightboxItem);
+
+ assert.notOk(
+ result.canZoom,
+ "canZoom should be false if fullsizeImage width or height is smaller than window inner width or height"
+ );
+ });
+ }
+);
diff --git a/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/scroll-parent-to-element-to-center-test.js b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/scroll-parent-to-element-to-center-test.js
new file mode 100644
index 00000000000..2078b3ecfeb
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/scroll-parent-to-element-to-center-test.js
@@ -0,0 +1,51 @@
+import { module, test } from "qunit";
+
+import { scrollParentToElementCenter } from "discourse/lib/lightbox/helpers";
+
+module(
+ "Unit | lib | Experimental Lightbox | Helpers | scrollParentToElementCenter()",
+ function () {
+ test("scrolls the parent element to the center of the element", async function (assert) {
+ const parent = document.createElement("div");
+ parent.style.width = "200px";
+ parent.style.height = "200px";
+ parent.style.overflow = "scroll";
+
+ const element = document.createElement("div");
+ element.style.width = "400px";
+ element.style.height = "400px";
+ parent.appendChild(element);
+
+ document.body.appendChild(parent);
+
+ const getExpectedX = (element.offsetWidth - parent.offsetWidth) / 2;
+ const expectedY = (element.offsetHeight - parent.offsetHeight) / 2;
+
+ scrollParentToElementCenter({ element, isRTL: false });
+
+ assert.strictEqual(
+ parent.scrollLeft,
+ getExpectedX,
+ "scrolls parent to center of element (LTR - horizontal)"
+ );
+
+ assert.strictEqual(
+ parent.scrollTop,
+ expectedY,
+ "scrolls parent to center of viewport (LTR and RTL - vertical)"
+ );
+
+ parent.style.direction = "rtl";
+
+ scrollParentToElementCenter({ element, isRTL: true });
+
+ assert.strictEqual(
+ parent.scrollLeft,
+ getExpectedX * -1,
+ "scrolls parent to center of viewport (RTL - horizontal)"
+ );
+
+ document.body.removeChild(parent);
+ });
+ }
+);
diff --git a/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/set-carousel-scroll-position-test.js b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/set-carousel-scroll-position-test.js
new file mode 100644
index 00000000000..bdc05bcdcb5
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/set-carousel-scroll-position-test.js
@@ -0,0 +1,76 @@
+import { module, test } from "qunit";
+
+import { setCarouselScrollPosition } from "discourse/lib/lightbox/helpers";
+
+module(
+ "Unit | lib | Experimental Lightbox | Helpers | setCarouselScrollPosition()",
+ function () {
+ const carouselItemSize = 100;
+ const target = 9;
+
+ const carousel = document.createElement("div");
+ carousel.style.cssText = `
+ display: grid;
+ height: 400px;
+ width: 400px;
+ overflow: auto;
+ position: relative;
+ `;
+
+ const carouselItem = document.createElement("div");
+ carouselItem.style.cssText = `
+ width: ${carouselItemSize}px;
+ height: ${carouselItemSize}px;
+ `;
+
+ Array(20)
+ .fill(null)
+ .map((_, index) => {
+ const item = carouselItem.cloneNode(true);
+ if (index === target) {
+ item.dataset.lightboxCarouselItem = "current";
+ }
+ carousel.appendChild(item);
+ });
+
+ const expected =
+ target * carouselItemSize - carouselItemSize - carouselItemSize / 2;
+
+ test("scrolls the carousel to the center of the active item ", async function (assert) {
+ const container = carousel.cloneNode(true);
+
+ container.style.cssText += `
+ grid-auto-flow: column;
+ grid-template-columns: repeat(auto, ${carouselItemSize}px);
+ `;
+
+ const fixtureDiv = document.getElementById("qunit-fixture");
+ fixtureDiv.appendChild(container);
+
+ await setCarouselScrollPosition("instant");
+
+ assert.strictEqual(
+ container.scrollLeft,
+ expected,
+ "scrolls carousel to center of active item (horizontal)"
+ );
+
+ container.style.cssText += `
+ grid-auto-flow: row;
+ grid-template-rows: repeat(auto, ${carouselItemSize}px);
+ `;
+
+ await setCarouselScrollPosition("instant");
+
+ assert.strictEqual(
+ container.scrollTop,
+ expected,
+ "scrolls carousel to center of active item (vertical)"
+ );
+ });
+
+ test("test scroll animation", async function (assert) {
+ assert.ok(true);
+ });
+ }
+);
diff --git a/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/site-theme-color-test.js b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/site-theme-color-test.js
new file mode 100644
index 00000000000..7905c8216a0
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/site-theme-color-test.js
@@ -0,0 +1,59 @@
+import {
+ getSiteThemeColor,
+ setSiteThemeColor,
+} from "discourse/lib/lightbox/helpers";
+import { module, test } from "qunit";
+
+import sinon from "sinon";
+
+module(
+ "Unit | lib | Experimental Lightbox | Helpers | getSiteThemeColor()",
+ function () {
+ test("gets the correct site theme color", async function (assert) {
+ const querySelectorSpy = sinon.spy(document, "querySelector");
+ const MetaSiteColorStub = sinon.stub(
+ HTMLMetaElement.prototype,
+ "content"
+ );
+
+ MetaSiteColorStub.value("#ff0000");
+
+ const themeColor = await getSiteThemeColor();
+
+ assert.strictEqual(
+ querySelectorSpy.calledWith('meta[name="theme-color"]'),
+ true,
+ "Queries the correct element"
+ );
+
+ assert.strictEqual(
+ themeColor,
+ "#ff0000",
+ "returns the correct theme color"
+ );
+
+ querySelectorSpy.restore();
+ MetaSiteColorStub.restore();
+ });
+
+ test("sets the site theme color correctly", async function (assert) {
+ const querySelectorSpy = sinon.spy(document, "querySelector");
+
+ await setSiteThemeColor("0000ff");
+
+ assert.strictEqual(
+ querySelectorSpy.calledWith('meta[name="theme-color"]'),
+ true,
+ "queries the correct element"
+ );
+
+ assert.strictEqual(
+ querySelectorSpy.returnValues[0].content,
+ "#0000ff",
+ "sets the correct theme color"
+ );
+
+ querySelectorSpy.restore();
+ });
+ }
+);
diff --git a/app/assets/javascripts/discourse/tests/unit/lib/lightbox/process-html-test.js b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/process-html-test.js
new file mode 100644
index 00000000000..d0eb3ddce2c
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/process-html-test.js
@@ -0,0 +1,279 @@
+import {
+ LIGHTBOX_IMAGE_FIXTURES,
+ generateImageUploaderMarkup,
+ generateLightboxMarkup,
+} from "discourse/tests/helpers/lightbox-helpers";
+import { module, test } from "qunit";
+
+import { SELECTORS } from "discourse/lib/lightbox/constants";
+import domFromString from "discourse-common/lib/dom-from-string";
+import { processHTML } from "discourse/lib/lightbox/process-html";
+
+module("Unit | lib | Experimental lightbox | processHTML()", function () {
+ const wrap = domFromString(generateLightboxMarkup())[0];
+ const imageUploaderWrap = domFromString(generateImageUploaderMarkup())[0];
+ const selector = SELECTORS.DEFAULT_ITEM_SELECTOR;
+
+ test("returns the correct object from the proccessed element", async function (assert) {
+ const container = wrap.cloneNode(true);
+
+ const { items, startingIndex } = await processHTML({
+ container,
+ selector,
+ });
+
+ assert.strictEqual(items.length, 1);
+
+ const item = items[0];
+
+ assert.strictEqual(
+ item.fullsizeURL,
+ LIGHTBOX_IMAGE_FIXTURES.first.fullsizeURL
+ );
+
+ assert.strictEqual(item.smallURL, LIGHTBOX_IMAGE_FIXTURES.first.smallURL);
+
+ assert.strictEqual(
+ item.downloadURL,
+ LIGHTBOX_IMAGE_FIXTURES.first.downloadURL
+ );
+
+ assert.strictEqual(items[0].title, LIGHTBOX_IMAGE_FIXTURES.first.title);
+
+ assert.strictEqual(
+ item.fileDetails,
+ LIGHTBOX_IMAGE_FIXTURES.first.fileDetails
+ );
+
+ assert.strictEqual(
+ item.dominantColor,
+ LIGHTBOX_IMAGE_FIXTURES.first.dominantColor
+ );
+
+ assert.strictEqual(
+ item.aspectRatio,
+ LIGHTBOX_IMAGE_FIXTURES.first.aspectRatio
+ );
+
+ assert.strictEqual(item.index, LIGHTBOX_IMAGE_FIXTURES.first.index);
+
+ assert.strictEqual(
+ item.cssVars.string,
+ LIGHTBOX_IMAGE_FIXTURES.first.cssVars.string
+ );
+
+ assert.strictEqual(startingIndex, 0);
+ });
+
+ test("returns the correct number of items", async function (assert) {
+ const htmlString = generateLightboxMarkup().repeat(3);
+ const container = domFromString(htmlString);
+
+ const outer = document.createElement("div");
+ outer.append(...container);
+
+ const { items } = await processHTML({
+ container: outer,
+ selector,
+ });
+
+ assert.strictEqual(items.length, 3);
+ });
+
+ test("fallsback to src when no href is defined for fullsizeURL", async function (assert) {
+ const container = wrap.cloneNode(true);
+
+ container.querySelector("a").removeAttribute("href");
+
+ const { items } = await processHTML({
+ container,
+ selector,
+ });
+
+ assert.strictEqual(
+ items[0].fullsizeURL,
+ LIGHTBOX_IMAGE_FIXTURES.first.smallURL
+ );
+ });
+
+ test("handles title fallbacks", async function (assert) {
+ const container = wrap.cloneNode(true);
+
+ container.querySelector("a").removeAttribute("title");
+
+ let { items } = await processHTML({
+ container,
+ selector,
+ });
+
+ assert.strictEqual(items[0].title, LIGHTBOX_IMAGE_FIXTURES.first.title);
+
+ container.querySelector("img").removeAttribute("title");
+
+ ({ items } = await processHTML({
+ container,
+ selector,
+ }));
+
+ assert.strictEqual(items[0].title, LIGHTBOX_IMAGE_FIXTURES.first.alt);
+
+ container.querySelector("img").removeAttribute("alt");
+
+ ({ items } = await processHTML({
+ container,
+ selector,
+ }));
+
+ assert.strictEqual(items[0].title, "");
+ });
+
+ test("correctly escapes the title", async function (assert) {
+ const container = wrap.cloneNode(true);
+
+ container
+ .querySelector("a")
+ .setAttribute("title", `"><\x00script>javascript:alert(1)`);
+
+ const { items } = await processHTML({
+ container,
+ selector,
+ });
+
+ assert.strictEqual(
+ items[0].title,
+ `"><\x00script>javascript:alert(1)</script>`
+ );
+ });
+
+ test("handles missing aspect ratio", async function (assert) {
+ const container = wrap.cloneNode(true);
+
+ container.querySelector("img").style.removeProperty("aspect-ratio");
+
+ const { items } = await processHTML({
+ container,
+ selector,
+ });
+
+ assert.strictEqual(items[0].aspectRatio, null);
+
+ assert.strictEqual(
+ items[0].cssVars.string,
+ `--dominant-color: #${LIGHTBOX_IMAGE_FIXTURES.first.dominantColor};--small-url: url(${LIGHTBOX_IMAGE_FIXTURES.first.smallURL});`
+ );
+ });
+
+ test("handles missing file details", async function (assert) {
+ const container = wrap.cloneNode(true);
+
+ container.querySelector(SELECTORS.FILE_DETAILS_CONTAINER).remove();
+
+ const { items } = await processHTML({
+ container,
+ selector,
+ });
+
+ assert.strictEqual(items[0].fileDetails, null);
+ });
+
+ test("handles missing dominant color", async function (assert) {
+ const container = wrap.cloneNode(true);
+
+ container.querySelector("img").removeAttribute("data-dominant-color");
+
+ const { items } = await processHTML({
+ container,
+ selector,
+ });
+
+ assert.strictEqual(items[0].dominantColor, null);
+
+ assert.strictEqual(
+ items[0].cssVars.string,
+ `--aspect-ratio: ${LIGHTBOX_IMAGE_FIXTURES.first.aspectRatio};--small-url: url(${LIGHTBOX_IMAGE_FIXTURES.first.smallURL});`
+ );
+ });
+
+ test("falls back to href when data-download is not defined", async function (assert) {
+ const container = wrap.cloneNode(true);
+
+ container.querySelector("a").removeAttribute("data-download-href");
+
+ const { items } = await processHTML({
+ container,
+ selector,
+ });
+
+ assert.strictEqual(
+ items[0].downloadURL,
+ LIGHTBOX_IMAGE_FIXTURES.first.fullsizeURL
+ );
+ });
+
+ test("handles missing selector", async function (assert) {
+ const container = wrap.cloneNode(true);
+
+ const { items } = await processHTML({
+ container,
+ });
+
+ assert.strictEqual(items.length, 1);
+ });
+
+ test("handles custom selector", async function (assert) {
+ const container = wrap.cloneNode(true);
+ container.querySelector("a").classList.add("custom-selector");
+
+ const { items } = await processHTML({
+ container,
+ selector: ".custom-selector",
+ });
+
+ assert.strictEqual(items.length, 1);
+ });
+
+ test("returns the correct object for image uploader components", async function (assert) {
+ const container = imageUploaderWrap.cloneNode(true);
+
+ const { items } = await processHTML({
+ container,
+ selector,
+ });
+
+ const item = items[0];
+
+ assert.strictEqual(items.length, 1);
+
+ assert.strictEqual(item.title, "");
+
+ assert.strictEqual(item.aspectRatio, null);
+
+ assert.strictEqual(item.dominantColor, null);
+
+ assert.strictEqual(item.fileDetails, "x");
+
+ assert.strictEqual(
+ item.downloadURL,
+ LIGHTBOX_IMAGE_FIXTURES.first.fullsizeURL
+ );
+
+ assert.strictEqual(
+ item.smallURL,
+ LIGHTBOX_IMAGE_FIXTURES.first.fullsizeURL
+ );
+
+ assert.strictEqual(
+ item.fullsizeURL,
+ LIGHTBOX_IMAGE_FIXTURES.first.fullsizeURL
+ );
+
+ assert.strictEqual(
+ item.cssVars.string,
+ `--small-url: url(${LIGHTBOX_IMAGE_FIXTURES.first.fullsizeURL});`
+ );
+ });
+
+ test("throws missing container error when no container / nodelist is passed", async function (assert) {
+ assert.rejects(processHTML({ selector }));
+ });
+});
diff --git a/app/assets/javascripts/discourse/tests/unit/services/lightbox-test.js b/app/assets/javascripts/discourse/tests/unit/services/lightbox-test.js
new file mode 100644
index 00000000000..b96831b5dda
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/unit/services/lightbox-test.js
@@ -0,0 +1,187 @@
+import {
+ generateLightboxMarkup,
+ generateLightboxObject,
+} from "discourse/tests/helpers/lightbox-helpers";
+import { module, test } from "qunit";
+import { click } from "@ember/test-helpers";
+import { LIGHTBOX_APP_EVENT_NAMES } from "discourse/lib/lightbox/constants";
+import domFromString from "discourse-common/lib/dom-from-string";
+import { getOwner } from "discourse-common/lib/get-owner";
+import { setupTest } from "ember-qunit";
+import sinon from "sinon";
+
+module("Unit | Service | Experimental Lightbox", function (hooks) {
+ setupTest(hooks);
+
+ const wrap = domFromString(generateLightboxMarkup())[0];
+ const selector = ".lightbox";
+
+ hooks.beforeEach(function () {
+ this.lightbox = getOwner(this).lookup("service:lightbox");
+ this.appEvents = getOwner(this).lookup("service:app-events");
+ });
+
+ test("Lightbox Service has appEvents", async function (assert) {
+ assert.ok(this.lightbox.appEvents);
+ });
+
+ test("Does not add event listener if no lightboxes are found", async function (assert) {
+ const container = document.createElement("div");
+ const addEventListenerSpy = sinon.spy(container, "addEventListener");
+
+ await this.lightbox.setupLightboxes({ container, selector });
+
+ assert.strictEqual(
+ addEventListenerSpy.called,
+ false,
+ "does not add event listener"
+ );
+
+ addEventListenerSpy.restore();
+ });
+
+ test("Adds event listener if lightboxes are found", async function (assert) {
+ const container = wrap.cloneNode(true);
+ const addEventListenerSpy = sinon.spy(container, "addEventListener");
+
+ await this.lightbox.setupLightboxes({ container, selector });
+
+ assert.strictEqual(
+ addEventListenerSpy.calledOnce,
+ true,
+ "adds event listener"
+ );
+
+ addEventListenerSpy.restore();
+ });
+
+ test("Correctly sets event listeners", async function (assert) {
+ const container = wrap.cloneNode(true);
+
+ const openLightboxSpy = sinon.spy(this.lightbox, "openLightbox");
+ const removeEventListenerSpy = sinon.spy(container, "removeEventListener");
+
+ await this.lightbox.setupLightboxes({ container, selector });
+
+ await click(container.querySelector(selector));
+
+ container.appendChild(document.createElement("p"));
+
+ await click(container.querySelector("p"));
+
+ assert.strictEqual(
+ openLightboxSpy.calledWith({ container, selector }),
+ true,
+ "calls openLightbox on lightboxed element click"
+ );
+
+ assert.strictEqual(
+ openLightboxSpy.calledOnce,
+ true,
+ "only calls open lightbox when lightboxed element is clicked"
+ );
+
+ assert.strictEqual(
+ this.lightbox.lightboxClickElements.length,
+ 1,
+ "correctly stores lightbox click elements for cleanup"
+ );
+
+ await this.lightbox.cleanupLightboxes();
+
+ assert.strictEqual(
+ removeEventListenerSpy.calledOnce,
+ true,
+ "removes event listener from element on cleanup"
+ );
+
+ removeEventListenerSpy.restore();
+
+ assert.strictEqual(
+ this.lightbox.lightboxClickElements.length,
+ 0,
+ "correctly removes stored entry from lightboxClickElements on cleanup"
+ );
+
+ openLightboxSpy.restore();
+ removeEventListenerSpy.restore();
+ });
+
+ test(`correctly calls the lightbox:open event`, async function (assert) {
+ const done = assert.async();
+ const container = wrap.cloneNode(true);
+
+ await this.lightbox.setupLightboxes({ container, selector });
+
+ const appEventsTriggerSpy = sinon.spy(this.appEvents, "trigger");
+
+ const expectedObject = {
+ ...generateLightboxObject(),
+ options: this.lightbox.options,
+ callbacks: this.lightbox.callbacks,
+ };
+
+ const expectedEvent = LIGHTBOX_APP_EVENT_NAMES.OPEN;
+
+ this.appEvents.on(LIGHTBOX_APP_EVENT_NAMES.OPEN, (args) => {
+ assert.deepEqual(args, expectedObject);
+ done();
+ });
+
+ await click(container.querySelector(selector));
+
+ assert.ok(appEventsTriggerSpy.calledWith(expectedEvent));
+
+ appEventsTriggerSpy.restore();
+ });
+
+ test(`correctly calls the lightbox:close event`, async function (assert) {
+ const done = assert.async();
+ const container = wrap.cloneNode(true);
+
+ await this.lightbox.setupLightboxes({ container, selector });
+
+ this.appEvents.on(LIGHTBOX_APP_EVENT_NAMES.CLOSE, () => {
+ assert.ok(true);
+ done();
+ });
+
+ await click(container.querySelector(selector));
+
+ await this.lightbox.closeLightbox();
+ });
+
+ test(`correctly responds to the lightbox:clean event`, async function (assert) {
+ const container = wrap.cloneNode(true);
+
+ await this.lightbox.setupLightboxes({ container, selector });
+
+ await click(container.querySelector(".lightbox"));
+
+ assert.strictEqual(
+ this.lightbox.lightboxClickElements.length,
+ 1,
+ "correctly stores lightbox click elements for cleanup"
+ );
+
+ assert.strictEqual(
+ this.lightbox.lightboxIsOpen,
+ true,
+ "sets lightboxIsOpen to true"
+ );
+
+ this.appEvents.trigger(LIGHTBOX_APP_EVENT_NAMES.CLEAN);
+
+ assert.strictEqual(
+ this.lightbox.lightboxClickElements.length,
+ 0,
+ "correctly removes stored entry from lightboxClickElements on cleanup"
+ );
+
+ assert.strictEqual(
+ this.lightbox.lightboxIsOpen,
+ false,
+ "sets lightboxIsOpen to false"
+ );
+ });
+});
diff --git a/app/assets/stylesheets/common/components/_index.scss b/app/assets/stylesheets/common/components/_index.scss
index bd7806630f1..c33d212486c 100644
--- a/app/assets/stylesheets/common/components/_index.scss
+++ b/app/assets/stylesheets/common/components/_index.scss
@@ -7,6 +7,7 @@
@import "char-counter";
@import "conditional-loading-section";
@import "convert-to-public-topic-modal";
+@import "d-lightbox";
@import "d-tooltip";
@import "d-toggle-switch";
@import "date-input";
diff --git a/app/assets/stylesheets/common/components/d-lightbox.scss b/app/assets/stylesheets/common/components/d-lightbox.scss
new file mode 100644
index 00000000000..852b15bad5a
--- /dev/null
+++ b/app/assets/stylesheets/common/components/d-lightbox.scss
@@ -0,0 +1,795 @@
+/* Main document */
+
+html.has-lightbox {
+ overflow-y: hidden;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+
+ .profiler-results {
+ display: none;
+ }
+
+ #main {
+ padding-right: var(--document-scrollbar-width);
+ }
+
+ --carousel-item-size: clamp(6.5rem, 12vh, 15rem);
+ --d-lightbox-primary: #ffffff;
+ --d-lightbox-secondary: #000000;
+ --d-lightbox-secondary-translucent: rgba(0, 0, 0, 0.25);
+}
+
+/* Lightbox element*/
+
+// Grid
+
+.d-lightbox {
+ overscroll-behavior: contain;
+
+ * {
+ box-sizing: border-box;
+ }
+
+ &.is-visible {
+ display: grid;
+ place-items: stretch;
+ }
+
+ &__content {
+ display: grid;
+ place-items: stretch;
+ }
+
+ &.is-vertical &__content {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto 1fr auto auto;
+
+ grid-template-areas:
+ "lightbox-header"
+ "lightbox-body"
+ "lightbox-carousel"
+ "lightbox-footer";
+ }
+
+ &.is-horizontal &__content {
+ grid-template-columns: auto 1fr;
+ grid-template-rows: auto 1fr auto;
+ grid-template-areas:
+ "lightbox-carousel lightbox-header"
+ "lightbox-carousel lightbox-body"
+ "lightbox-carousel lightbox-footer";
+ }
+
+ &__header {
+ grid-area: lightbox-header;
+ justify-self: stretch;
+ display: grid;
+ place-items: center start;
+ grid-template-areas: "preview-controls lightbox-header-buttons";
+ }
+
+ &__multi-item-controls {
+ grid-area: preview-controls;
+ place-self: center start;
+ display: grid;
+ gap: 0.5em;
+ place-items: center start;
+ grid-auto-flow: column;
+ grid-template-areas: "lightbox-carousel-toggle lightbox-counter";
+
+ .d-lightbox__carousel-button {
+ grid-area: lightbox-carousel-toggle;
+ }
+ }
+
+ &__counters {
+ grid-area: lightbox-counter;
+ }
+
+ &__header-buttons {
+ grid-area: lightbox-header-buttons;
+ place-self: center end;
+ display: grid;
+ gap: 0 0.25em;
+ place-items: center end;
+ grid-auto-flow: column;
+ }
+
+ &__body {
+ grid-area: lightbox-body;
+ display: grid;
+ place-items: center stretch;
+
+ grid-auto-flow: column;
+ grid-template-columns: auto 1fr auto;
+ grid-template-areas: "lightbox-previous-button lightbox-main-image lightbox-next-button";
+ }
+
+ &__main-image,
+ &__error-message,
+ &__loading-spinner {
+ grid-area: lightbox-main-image;
+ place-self: center;
+ display: grid;
+ place-items: center;
+ grid-auto-flow: column;
+ }
+
+ &__previous-button {
+ grid-area: lightbox-previous-button;
+ place-self: center start;
+ }
+
+ &__next-button {
+ grid-area: lightbox-next-button;
+ place-self: center end;
+ }
+
+ &.has-carousel &,
+ &.is-rotated &,
+ &.is-zoomed & {
+ &__body {
+ grid-auto-flow: row;
+ grid-template-columns: 1fr;
+ grid-template-areas: "lightbox-main-image";
+ }
+ }
+
+ &__footer {
+ grid-area: lightbox-footer;
+ place-self: stretch;
+ display: grid;
+ grid-auto-flow: column;
+ gap: 1.5em;
+ grid-template-columns: 1fr auto;
+ grid-template-areas: "lightbox-title lightbox-footer-buttons";
+ }
+
+ &__main-title {
+ grid-area: lightbox-title;
+ place-self: center start;
+ }
+
+ &__footer-buttons {
+ grid-area: lightbox-footer-buttons;
+ place-self: center end;
+ display: grid;
+ grid-auto-flow: column;
+ gap: 0 0.25em;
+ place-items: center;
+ }
+
+ &__carousel {
+ grid-area: lightbox-carousel;
+ display: grid;
+ place-items: center;
+ }
+
+ &__carousel-items {
+ grid-area: lightbox-preview-images;
+ display: grid;
+ gap: 1em;
+ place-items: center;
+ }
+
+ &__carousel-previous-button {
+ grid-area: lightbox-previous;
+ }
+
+ &__carousel-next-button {
+ grid-area: lightbox-next;
+ }
+
+ &.is-vertical &__carousel {
+ grid-auto-flow: column;
+ grid-template-columns: auto 1fr auto;
+ grid-template-areas: "lightbox-previous lightbox-preview-images lightbox-next";
+ }
+
+ &__carousel-items {
+ grid-auto-flow: column;
+ }
+ &__carousel-item,
+ &__carousel-item.is-current {
+ height: var(--carousel-item-size);
+ }
+
+ &.is-horizontal &__carousel {
+ max-height: 100vh;
+ grid-template-rows: 1fr auto 1fr;
+ place-items: center;
+ grid-template-areas:
+ "lightbox-previous"
+ "lightbox-preview-images"
+ "lightbox-next";
+ }
+
+ &.is-horizontal &__carousel-items {
+ grid-auto-flow: row;
+ }
+
+ &__carousel-item,
+ &__carousel-item.is-current {
+ width: var(--carousel-item-size);
+ }
+
+ &__carousel-previous-button {
+ place-self: self-end center;
+ }
+
+ &__carousel-next-button {
+ place-self: self-start center;
+ }
+}
+
+/* Base styles */
+
+.d-lightbox {
+ &.is-visible {
+ position: fixed;
+ top: 0;
+ left: 0;
+ height: 100%;
+ width: 100%;
+ z-index: z("header");
+ }
+
+ &__content {
+ background-color: var(--d-lightbox-secondary);
+
+ &:focus-visible {
+ outline: none;
+ }
+ }
+}
+
+.d-lightbox {
+ &__header {
+ z-index: 1;
+ padding: 0.25em 1px; // 1px for button outlines
+ color: var(--d-lightbox-primary);
+ background: linear-gradient(
+ to right,
+ var(--d-lightbox-secondary),
+ transparent
+ );
+
+ position: relative;
+ }
+}
+
+.d-lightbox {
+ &__body {
+ @include user-select(none);
+ position: relative;
+ outline: none;
+ }
+
+ &__loading-spinner,
+ &__error-message,
+ &__main-image {
+ position: absolute;
+ outline: none;
+ }
+
+ &__backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ height: 100%;
+ width: 100%;
+ background-size: cover;
+ background-position: center;
+ filter: blur(15px) brightness(0.25);
+ background-color: var(--d-lightbox-secondary);
+ background-image: var(--d-lightbox-image-small-url);
+ }
+
+ &__main-image {
+ aspect-ratio: var(--d-lightbox-image-aspect-ratio);
+ background-color: var(--d-lightbox-image-dominant-color);
+ box-sizing: border-box;
+ max-height: 100%;
+ max-width: 100%;
+ box-shadow: shadow("card");
+ }
+
+ &__error-message {
+ color: var(--d-lightbox-primary);
+ }
+
+ &__previous-button,
+ &__next-button {
+ z-index: 1;
+ position: absolute;
+
+ .d-icon {
+ font-size: var(--font-up-2);
+ }
+ }
+}
+
+.d-lightbox {
+ &__footer {
+ z-index: 1;
+ padding: 0.25em 1px;
+ box-sizing: border-box;
+ color: var(--d-lightbox-primary);
+ background: linear-gradient(
+ to left,
+ var(--d-lightbox-secondary),
+ transparent
+ );
+
+ position: relative;
+
+ &__main-title {
+ word-break: break-word;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ // right/left padding to align with buttons
+ padding: 0 0.65em;
+ border: 1px solid transparent;
+
+ &:focus-visible {
+ outline: none;
+ border: 1px solid var(--tertiary);
+ border-radius: 1px;
+ }
+ }
+ }
+
+ &__item-file-details {
+ font-size: var(--font-down-2);
+ opacity: 0.5;
+ padding: 0.25em 0;
+ white-space: nowrap;
+ }
+}
+
+.d-lightbox {
+ &__carousel {
+ @include user-select(none);
+ box-sizing: border-box;
+ z-index: 1;
+ position: relative;
+ color: var(--d-lightbox-primary);
+ }
+ &__carousel-previous-button,
+ &__carousel-next-button {
+ z-index: 2;
+
+ .d-icon {
+ font-size: var(--font-up-2);
+ }
+ }
+}
+
+.d-lightbox {
+ &__carousel-items {
+ @extend %lightbox-scrollable;
+ max-height: 100%;
+ max-width: 100%;
+ z-index: 0;
+ box-sizing: border-box;
+ }
+
+ &__carousel-item,
+ &__carousel-item.is-current {
+ border: 1px solid transparent;
+ box-shadow: 0 0 2px -1px var(--d-lightbox-primary);
+ border-radius: 5px;
+ cursor: pointer;
+ user-select: none;
+ transition: all var(--d-lightbox-image-animation-duration) ease-in-out;
+ background-color: var(--dominant-color);
+ aspect-ratio: var(--aspect-ratio);
+ filter: saturate(0.5);
+
+ &:focus {
+ outline: none;
+ }
+ }
+
+ &__carousel-item.is-current {
+ border-color: var(--tertiary);
+ pointer-events: none;
+ filter: initial;
+ }
+}
+
+.d-lightbox {
+ &.is-vertical &__carousel {
+ position: relative;
+ background-color: var(--d-lightbox-secondary-translucent);
+ &:before,
+ &:after {
+ content: "";
+ position: absolute;
+ display: block;
+ z-index: 1;
+ pointer-events: none;
+ height: 100%;
+ width: 20%;
+ }
+
+ &:before {
+ left: 0;
+ top: 0;
+ background: var(--d-lightbox-secondary);
+ -webkit-mask-image: linear-gradient(
+ 90deg,
+ var(--d-lightbox-secondary),
+ transparent
+ );
+ mask-image: linear-gradient(
+ 90deg,
+ var(--d-lightbox-secondary),
+ transparent
+ );
+ }
+
+ &:after {
+ right: 0;
+ bottom: 0;
+ background: var(--d-lightbox-secondary);
+ -webkit-mask-image: linear-gradient(
+ -90deg,
+ var(--d-lightbox-secondary),
+ transparent
+ );
+ mask-image: linear-gradient(
+ -90deg,
+ var(--d-lightbox-secondary),
+ transparent
+ );
+ }
+
+ .rtl & {
+ &:before {
+ left: auto;
+ right: 0;
+ }
+
+ &:after {
+ left: 0;
+ right: auto;
+ }
+ }
+ }
+
+ &.is-vertical &__carousel-items {
+ padding: 1em 2em;
+ }
+
+ &__carousel-previous-button,
+ &__carousel-next-button {
+ position: absolute;
+ }
+}
+
+.d-lightbox {
+ &.is-horizontal &__carousel {
+ position: relative;
+ &:before,
+ &:after {
+ content: "";
+ position: absolute;
+ display: block;
+ z-index: 1;
+ pointer-events: none;
+ height: 20%;
+ width: 100%;
+ }
+
+ &:before {
+ left: 0;
+ top: 0;
+ background: var(--d-lightbox-secondary);
+ -webkit-mask-image: linear-gradient(
+ 180deg,
+ var(--d-lightbox-secondary),
+ transparent
+ );
+ mask-image: linear-gradient(
+ 180deg,
+ var(--d-lightbox-secondary) 20%,
+ transparent 100%
+ );
+ }
+
+ &:after {
+ right: 0;
+ bottom: 0;
+ background: var(--d-lightbox-secondary);
+ -webkit-mask-image: linear-gradient(
+ 0,
+ var(--d-lightbox-secondary),
+ transparent
+ );
+ mask-image: linear-gradient(
+ 0,
+ var(--d-lightbox-secondary) 20%,
+ transparent 100%
+ );
+ }
+ }
+
+ &.is-horizontal &__carousel-items {
+ padding: 50% 1em;
+ }
+
+ &__carousel-previous-button,
+ &__carousel-next-button {
+ transform: rotate(90deg);
+
+ .rtl & {
+ transform: rotate(-90deg);
+ }
+ }
+}
+
+.d-lightbox {
+ .btn-flat {
+ .d-icon {
+ margin: 0;
+ }
+
+ &:focus {
+ background: transparent;
+ outline: none;
+
+ .d-icon {
+ color: var(--d-lightbox-primary);
+ }
+ }
+
+ &:focus-visible {
+ outline: 1px solid var(--tertiary);
+ border-radius: 1px;
+ .d-icon {
+ color: var(--tertiary);
+ }
+ }
+
+ @include hover {
+ .d-icon {
+ color: var(--tertiary);
+ }
+ }
+ }
+}
+
+.d-lightbox {
+ &__focus-trap,
+ &__screen-reader-announcer {
+ position: absolute;
+ left: -100%;
+ top: -100%;
+ }
+}
+
+/* State styles */
+
+// Carousel
+.d-lightbox {
+ &.has-carousel {
+ .d-lightbox__content {
+ gap: 2em 0;
+ }
+
+ .btn-flat.d-lightbox__carousel-button .d-icon {
+ color: var(--tertiary);
+
+ @include hover {
+ color: var(--tertiary);
+ }
+ }
+ }
+}
+
+// expanded title
+.d-lightbox {
+ &.has-expanded-title {
+ .d-lightbox__content {
+ gap: 2em;
+ }
+
+ .d-lightbox__main-title {
+ overflow: visible;
+ white-space: normal;
+ text-overflow: unset;
+ }
+ }
+}
+
+// Zoom
+.d-lightbox {
+ &.can-zoom {
+ .d-lightbox__body {
+ @extend %lightbox-scrollable;
+
+ overflow: auto;
+ height: 100%;
+ width: 100%;
+ position: absolute;
+ }
+
+ .d-lightbox__main-image {
+ cursor: zoom-in;
+ }
+ }
+
+ &.is-zoomed {
+ .d-lightbox__content {
+ gap: 0;
+ }
+
+ .d-lightbox {
+ &__body {
+ @extend %lightbox-scrollable;
+ overflow: auto;
+ height: 100%;
+ width: 100%;
+ position: absolute;
+ }
+
+ &__zoomed-image-container {
+ cursor: zoom-out;
+ background-image: var(--d-lightbox-image-full-size-url);
+ background-repeat: no-repeat;
+ backface-visibility: hidden;
+ background-position: center;
+
+ .mobile-view & {
+ width: var(--d-lightbox-image-width);
+ height: var(--d-lightbox-image-height);
+ }
+
+ .desktop-view & {
+ width: 100%;
+ height: 100%;
+ }
+ }
+ }
+
+ .btn-flat.d-lightbox__zoom-button .d-icon {
+ color: var(--tertiary);
+
+ @include hover {
+ color: var(--tertiary);
+ }
+ }
+ }
+}
+
+// Rotate
+.d-lightbox {
+ &.is-rotated {
+ .d-lightbox__body {
+ gap: 0;
+ }
+ .d-lightbox__main-image,
+ .d-lightbox__zoomed-image-container {
+ transform: rotate(var(--d-lightbox-image-rotation));
+ }
+
+ .btn-flat.d-lightbox__rotate-button .d-icon {
+ color: var(--tertiary);
+ transform: rotate(var(--d-lightbox-image-rotation));
+
+ @include hover {
+ color: var(--tertiary);
+ }
+ }
+ }
+}
+
+// Full screen
+.d-lightbox {
+ &.is-fullscreen {
+ .btn-flat.d-lightbox__full-screen-button .d-icon {
+ color: var(--tertiary);
+
+ @include hover {
+ color: var(--tertiary);
+ }
+ }
+ }
+}
+
+/* Animations */
+
+.d-lightbox {
+ &.is-visible &__content {
+ @extend %lightbox-animation-base;
+ animation-name: lightbox-fade-in-scale;
+ }
+
+ &__backdrop,
+ &__main-image,
+ &__loading-spinner,
+ &__zoomed-image-container,
+ &__carousel,
+ &__main-title {
+ @extend %lightbox-animation-base;
+ animation-name: lightbox-fade-in;
+ }
+
+ &__loading-spinner {
+ animation-delay: var(--d-lightbox-image-animation-duration);
+ opacity: 0;
+ }
+
+ &.will-close &__content {
+ animation-name: lightbox-fade-out;
+ }
+}
+
+%lightbox-animation-base {
+ animation-duration: 150ms;
+ animation-fill-mode: forwards;
+ backface-visibility: hidden;
+ will-change: opacity;
+ animation-timing-function: linear;
+}
+
+%lightbox-scrollable {
+ backface-visibility: hidden;
+
+ // MOZ
+ scrollbar-width: none;
+ overflow: auto;
+ // Webkit
+ overflow: overlay;
+ &::-webkit-scrollbar {
+ display: none;
+ }
+}
+
+@keyframes lightbox-fade-out {
+ 0% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ }
+}
+
+@keyframes lightbox-fade-in {
+ 0% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+}
+
+@keyframes lightbox-fade-in-scale {
+ 0% {
+ opacity: 0;
+ transform: scale(0.85);
+ }
+
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@keyframes lightbox-fade-out-scale {
+ 0% {
+ opacity: 1;
+ transform: scale(1);
+ }
+
+ 100% {
+ opacity: 0;
+ transform: scale(0.85);
+ }
+}
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index a2095c38e0d..a33e4f4b881 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -4040,6 +4040,21 @@ en:
content_load_error: 'The content could not be loaded.'
image_load_error: 'The image could not be loaded.'
+ experimental_lightbox:
+ image_load_error: "The image could not be loaded."
+ screen_reader_image_title: "Image %{current} of %{total}: %{title}"
+ buttons:
+ next: "Next (Right or down arrow key)"
+ previous: "Previous (Left or up arrow key)"
+ close: "Close (Esc)"
+ download: "Download image"
+ newtab: "Open image in a new tab"
+ zoom: "Zoom image in/out (Z key)"
+ rotate: "Rotate image (R key)"
+ fullscreen: "Toggle browser full screen mode (M key)"
+ carousel: "Display all images in a carousel (A key)"
+ retry: "Retry loading the image"
+
cannot_render_video: This video cannot be rendered because your browser does not support the codec.
keyboard_shortcuts_help:
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index a6a532b7e8c..4be34337816 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -2439,6 +2439,7 @@ en:
enable_custom_sidebar_sections: "EXPERIMENTAL: Enable custom sidebar sections"
experimental_topics_filter: "EXPERIMENTAL: Enables the experimental topics filter route at /filter"
experimental_search_menu_groups: "EXPERIMENTAL: Enables the new search menu that has been upgraded to use glimmer"
+ enable_experimental_lightbox: "EXPERIMENTAL: Replace the default image lightbox with the revamped design."
page_loading_indicator: "Configure the loading indicator which appears during page navigations within Discourse. 'Spinner' is a full page indicator. 'Slider' shows a narrow bar at the top of the screen."
diff --git a/config/site_settings.yml b/config/site_settings.yml
index 31508533189..17bcc019276 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -2130,6 +2130,9 @@ developer:
default: ""
allow_any: false
refresh: true
+ enable_experimental_lightbox:
+ default: false
+ client: true
experimental_topics_filter:
client: true
default: false
diff --git a/lib/svg_sprite.rb b/lib/svg_sprite.rb
index c43a23735e6..0fae05743d4 100644
--- a/lib/svg_sprite.rb
+++ b/lib/svg_sprite.rb
@@ -149,6 +149,7 @@ module SvgSprite
hourglass-start
id-card
image
+ images
inbox
info-circle
italic
@@ -187,6 +188,8 @@ module SvgSprite
reply
rocket
search
+ search-plus
+ search-minus
share
shield-alt
sign-in-alt
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-upload.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-upload.hbs
index e44fd4023df..5af7e46ddaa 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-upload.hbs
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-upload.hbs
@@ -7,6 +7,7 @@
src={{@upload.url}}
style={{this.imageStyle}}
loading="lazy"
+ tabindex="0"
{{on "load" this.imageLoaded}}
/>
{{else if (eq this.type this.VIDEO_TYPE)}}
diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-decorators.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-decorators.js
index 1e9fc651220..597f07e72ae 100644
--- a/plugins/chat/assets/javascripts/discourse/initializers/chat-decorators.js
+++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-decorators.js
@@ -13,7 +13,9 @@ export default {
initializeWithPluginApi(api, container) {
const siteSettings = container.lookup("service:site-settings");
+ const lightboxService = container.lookup("service:lightbox");
const site = container.lookup("service:site");
+
api.decorateChatMessage((element) => decorateGithubOneboxBody(element), {
id: "onebox-github-body",
});
@@ -65,14 +67,27 @@ export default {
id: "linksNewTab",
});
- api.decorateChatMessage(
- (element) =>
- this.lightbox(element.querySelectorAll("img:not(.emoji, .avatar)")),
- {
- id: "lightbox",
- }
- );
-
+ if (siteSettings.enable_experimental_lightbox) {
+ api.decorateChatMessage(
+ (element) => {
+ lightboxService.setupLightboxes({
+ container: element,
+ selector: "img:not(.emoji, .avatar)",
+ });
+ },
+ {
+ id: "experimental-chat-lightbox",
+ }
+ );
+ } else {
+ api.decorateChatMessage(
+ (element) =>
+ this.lightbox(element.querySelectorAll("img:not(.emoji, .avatar)")),
+ {
+ id: "lightbox",
+ }
+ );
+ }
api.decorateChatMessage((element) => decorateHashtags(element, site), {
id: "hashtagIcons",
});