FEATURE: New Discourse Lightbox using Glimmer (#19798)

Introduces new lightbox as a step to migrate away from Magnific Popup.

Please see https://meta.discourse.org/t/migrating-away-from-magnific-popup/251505 for more details

Co-authored-by: Nat <natalie.tay@discourse.org>
Co-authored-by: David Battersby <info@davidbattersby.com>
This commit is contained in:
Joe 2023-07-13 15:06:17 +08:00 committed by GitHub
parent f933c9fcd9
commit 82c03127df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 4488 additions and 31 deletions

View File

@ -27,7 +27,13 @@
{{~d-icon "spinner" class="loading-icon"~}}
{{else}}
{{#if @icon}}
{{~d-icon @icon~}}
{{#if @ariaHidden}}
<span aria-hidden="true">
{{~d-icon @icon~}}
</span>
{{else}}
{{~d-icon @icon~}}
{{/if}}
{{/if}}
{{/if}}

View File

@ -0,0 +1,83 @@
<DSection
tabIndex="-1"
id={{this.elementId}}
aria-hidden={{not this.isVisible}}
class={{this.HTMLClassList}}
@scrollTop={{false}}
{{did-insert this.registerAppEventListeners}}
{{will-destroy this.deregisterAppEventListners}}
>
{{#if this.isVisible}}
<div
aria-hidden="false"
tabindex="0"
role="document"
class="d-lightbox__content"
style={{this.CSSVars}}
aria-labelledby={{this.titleElementId}}
{{on "keyup" this.onKeyup passive=true capture=true}}
>
<div class="d-lightbox__focus-trap" tabindex="0"></div>
<DLightbox::Header
@canDownload={{this.canDownload}}
@canFullscreen={{this.canFullscreen}}
@canNavigate={{this.canNavigate}}
@close={{this.close}}
@openInNewTab={{this.openInNewTab}}
@toggleCarousel={{this.toggleCarousel}}
@toggleFullScreen={{this.toggleFullScreen}}
@totalItemCount={{this.totalItemCount}}
@counterIndex={{this.counterIndex}}
/>
<DLightbox::Body
@close={{this.close}}
@centerZoomedBackgroundPosition={{this.centerZoomedBackgroundPosition}}
@currentItem={{this.currentItem}}
@hasLoadingError={{this.hasLoadingError}}
@isLoading={{this.isLoading}}
@isZoomed={{this.isZoomed}}
@nextButtonIcon={{this.nextButtonIcon}}
@onTouchend={{this.onTouchend}}
@onTouchstart={{this.onTouchstart}}
@previousButtonIcon={{this.previousButtonIcon}}
@reloadImage={{this.reloadImage}}
@shouldDisplayMainImageArrows={{this.shouldDisplayMainImageArrows}}
@showNextItem={{this.showNextItem}}
@showPreviousItem={{this.showPreviousItem}}
@toggleZoom={{this.toggleZoom}}
@zoomOnMouseover={{this.zoomOnMouseover}}
/>
{{#if this.shouldDisplayCarousel}}
<DLightbox::Carousel
@currentIndex={{this.currentIndex}}
@items={{this.items}}
@nextButtonIcon={{this.nextButtonIcon}}
@previousButtonIcon={{this.previousButtonIcon}}
@showNextItem={{this.showNextItem}}
@showPreviousItem={{this.showPreviousItem}}
@showSelectedImage={{this.showSelectedImage}}
@shouldDisplayCarouselArrows={{this.shouldDisplayCarouselArrows}}
/>
{{/if}}
<DLightbox::Footer
@canDownload={{this.canDownload}}
@canRotate={{this.canRotate}}
@canZoom={{this.canZoom}}
@currentItem={{this.currentItem}}
@downloadImage={{this.downloadImage}}
@rotateImage={{this.rotateImage}}
@shouldDisplayTitle={{this.shouldDisplayTitle}}
@toggleExpandTitle={{this.toggleExpandTitle}}
@toggleZoom={{this.toggleZoom}}
@zoomButtonIcon={{this.zoomButtonIcon}}
/>
<DLightbox::ScreenReaderAnnouncer
@currentItem={{this.currentItem}}
@counterIndex={{this.counterIndex}}
@totalItemCount={{this.totalItemCount}}
@titleElementId={{this.titleElementId}}
/>
<div class="d-lightbox__focus-trap" tabindex="0"></div>
</div>
{{/if}}
</DSection>

View File

@ -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();
}
}

View File

@ -0,0 +1,6 @@
<div
aria-hidden="true"
class="d-lightbox__backdrop"
tabindex="-1"
{{on "click" @close passive=true capture=true}}
></div>

View File

@ -0,0 +1,59 @@
<div
class="d-lightbox__body"
tabindex="-1"
{{on "touchstart" @onTouchstart passive=true capture=true}}
{{on "touchend" @onTouchend passive=true capture=true}}
{{on "click" @toggleZoom passive=true}}
>
{{#if @shouldDisplayMainImageArrows}}
<DButton
@class="d-lightbox__previous-button btn-flat"
@title="experimental_lightbox.buttons.previous"
@icon={{@previousButtonIcon}}
@ariaHidden="true"
{{on "click" @showPreviousItem passive=true capture=true}}
/>
{{/if}}
{{#if @isLoading}}
<span class="d-lightbox__loading-spinner">
{{loading-spinner size="large"}}
</span>
{{else if @hasLoadingError}}
<span class="d-lightbox__error-message">
<DButton
@class="d-lightbox__retry-button btn-flat"
@title="experimental_lightbox.buttons.redo"
@icon="redo"
{{on "click" @reloadImage passive=true capture=true}}
/>
<span>{{i18n "experimental_lightbox.image_load_error"}}</span>
</span>
{{else if @isZoomed}}
<div
class="d-lightbox__zoomed-image-container"
tabindex="-1"
{{did-insert @centerZoomedBackgroundPosition}}
{{on "mousemove" @zoomOnMouseover passive=true capture=true}}
></div>
{{else}}
<DLightbox::Backdrop @close={{@close}} />
<img
aria-hidden="true"
draggable="false"
fetchPriority="high"
decoding="async"
tabindex="-1"
class="d-lightbox__main-image"
src={{@currentItem.fullsizeURL}}
/>
{{/if}}
{{#if @shouldDisplayMainImageArrows}}
<DButton
@class="d-lightbox__next-button btn-flat"
@title="experimental_lightbox.buttons.next"
@icon={{@nextButtonIcon}}
@ariaHidden="true"
{{on "click" @showNextItem passive=true capture=true}}
/>
{{/if}}
</div>

View File

@ -0,0 +1,49 @@
<div class="d-lightbox__carousel">
{{#if @shouldDisplayCarouselArrows}}
<DButton
@class="d-lightbox__carousel-previous-button btn-flat"
@title="experimental_lightbox.buttons.previous"
@icon={{@previousButtonIcon}}
@ariaHidden="true"
{{on "click" @showPreviousItem passive=true capture=true}}
/>
{{/if}}
<div
class="d-lightbox__carousel-items"
tabindex="-1"
role="list"
aria-hidden="true"
{{on "click" @showSelectedImage passive=true capture=true}}
{{on "focus" @showSelectedImage passive=true capture=true}}
>
{{#each @items as |item|}}
<img
data-lightbox-carousel-item={{if
(eq item.index @currentIndex)
"current"
""
}}
fetchPriority="low"
decoding="async"
loading="lazy"
tabindex="-1"
src={{item.smallURL}}
data-lightbox-item-index={{item.index}}
style={{item.cssVars}}
class={{concat
"d-lightbox__carousel-item"
(if (eq item.index @currentIndex) " is-current")
}}
/>
{{/each}}
</div>
{{#if @shouldDisplayCarouselArrows}}
<DButton
@class="d-lightbox__carousel-next-button btn-flat"
@title="experimental_lightbox.buttons.next"
@icon={{@nextButtonIcon}}
@ariaHidden="true"
{{on "click" @showNextItem passive=true capture=true}}
/>
{{/if}}
</div>

View File

@ -0,0 +1,48 @@
<div class="d-lightbox__footer">
{{#if @shouldDisplayTitle}}
<div
aria-hidden="true"
class="d-lightbox__main-title"
tabindex="0"
{{on "click" @toggleExpandTitle passive=true capture=true}}
>
<span class="d-lightbox__item-title">
{{~@currentItem.title~}}
</span>
<span class="d-lightbox__item-file-details">
{{~@currentItem.fileDetails~}}
</span>
</div>
{{/if}}
<div class="d-lightbox__footer-buttons">
{{#if @canZoom}}
<DButton
@class="d-lightbox__zoom-button btn-flat"
@title="experimental_lightbox.buttons.zoom"
@icon={{@zoomButtonIcon}}
@ariaHidden="true"
{{on "click" @toggleZoom passive=true capture=true}}
aria-hidden="true"
/>
{{/if}}
{{#if @canRotate}}
<DButton
@class="d-lightbox__rotate-button btn-flat"
@title="experimental_lightbox.buttons.rotate"
@icon="redo"
@ariaHidden="true"
{{on "click" @rotateImage passive=true capture=true}}
aria-hidden="true"
/>
{{/if}}
{{#if @canDownload}}
<DButton
@class="d-lightbox__download-button btn-flat"
@title="experimental_lightbox.buttons.download"
@icon="download"
@ariaHidden="true"
{{on "click" @downloadImage passive=true capture=true}}
/>
{{/if}}
</div>
</div>

View File

@ -0,0 +1,54 @@
<div class="d-lightbox__header">
{{#if @canNavigate}}
<div aria-hidden="true" class="d-lightbox__multi-item-controls">
<DButton
@class="d-lightbox__carousel-button btn-flat"
@title="experimental_lightbox.buttons.carousel"
@icon="images"
@ariaHidden="true"
{{on "click" @toggleCarousel passive=true capture=true}}
aria-hidden="true"
/>
<div class="d-lightbox__counters">
<span class="d-lightbox__counter-current">
{{~@counterIndex~}}
</span>
<span class="d-lightbox__counter-separator">
<span>/</span>
</span>
<span class="d-lightbox__counter-total">
{{~@totalItemCount~}}
</span>
</div>
</div>
{{/if}}
<div class="d-lightbox__header-buttons">
{{#if @canDownload}}
<DButton
@class="d-lightbox__new-tab-button btn-flat"
@title="experimental_lightbox.buttons.newtab"
@icon="external-link-alt"
@ariaHidden="true"
{{on "click" @openInNewTab passive=true capture=true}}
aria-hidden="true"
/>
{{/if}}
{{#if @canFullscreen}}
<DButton
@class="d-lightbox__full-screen-button btn-flat"
@title="experimental_lightbox.buttons.fullscreen"
@icon="discourse-expand"
@ariaHidden="true"
{{on "click" @toggleFullScreen passive=true capture=true}}
aria-hidden="true"
/>
{{/if}}
<DButton
@class="d-lightbox__close-button btn-flat"
@title="experimental_lightbox.buttons.close"
@icon="times"
@ariaHidden="true"
{{on "click" @close passive=true capture=true}}
/>
</div>
</div>

View File

@ -0,0 +1,16 @@
<div class="d-lightbox__screen-reader-announcer" tabindex="-1">
<h2
aria-live="polite"
aria-level="2"
aria-atomic="true"
class="d-lightbox__screen-reader-title"
id={{@titleElementId}}
>
{{i18n
"experimental_lightbox.screen_reader_image_title"
current=@counterIndex
total=@totalItemCount
title=@currentItem.title
}}
</h2>
</div>

View File

@ -25,14 +25,14 @@
@icon="far-trash-alt"
@type="button"
/>
<DButton
@icon="discourse-expand"
@title="expand"
@type="button"
@class="image-uploader-lightbox-btn no-text"
@action={{action "toggleLightbox"}}
@disabled={{this.loadingLightbox}}
@action={{unless this.experimentalLightboxEnabled this.toggleLightbox}}
data-lightbox-trigger={{if this.experimentalLightboxEnabled "true"}}
/>
{{/if}}

View File

@ -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();

View File

@ -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`
);
}
},
};

View File

@ -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) => {

View File

@ -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;

View File

@ -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",
};

View File

@ -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";

View File

@ -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);
}
}

View File

@ -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];
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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,
});
}

View File

@ -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);
}

View File

@ -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,
};
}

View File

@ -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();
}
}

View File

@ -96,6 +96,7 @@
@outletArgs={{hash showFooter=this.showFooter}}
/>
<DLightbox />
<ModalContainer />
<DialogHolder />
<TopicEntrance />

View File

@ -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 <a download="..." href="..."></a>
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();
});
});

View File

@ -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 `
<div class="lightbox-wrapper">
<a class="lightbox" href="${fullsizeURL}"
data-download-href="${downloadURL}"
title="${title}"><img src="${smallURL}" title="${title}" alt="${alt}"
width="${width}" height="${height}"
data-dominant-color="${dominantColor}" loading="lazy"
style="aspect-ratio: ${aspectRatio}" />
<div class="meta">
<span class="filename">${title}</span><span
class="informations">${fileDetails}</span>
</div>
</a>
</div>
`;
}
export function generateImageUploaderMarkup(
fullsizeURL = LIGHTBOX_IMAGE_FIXTURES.first.fullsizeURL
) {
return `
<div id="profile-background-uploader" class="image-uploader ember-view">
<div class="uploaded-image-preview input-xxlarge"
style="background-image: url(${fullsizeURL})">
<a class="lightbox"
href="${fullsizeURL}"
rel="nofollow ugc noopener">
<div class="meta">
<span class="informations">
x
</span>
</div>
</a>
</div>
</div>
`;
}

View File

@ -9,6 +9,7 @@
<meta property="og:url" content="{{rootURL}}" />
<meta name="twitter:title" content="Discourse Tests" />
<meta name="twitter:url" content="{{rootURL}}" />
<meta name="theme-color" content="#ffffff">
<link rel="canonical" href="{{rootURL}}" />

View File

@ -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`<DLightbox />`);
// 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");
});
});

View File

@ -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();
});
}
);

View File

@ -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"
);
});
}
);

View File

@ -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"
);
});
}
);

View File

@ -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();
});
}
);

View File

@ -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"
);
});
}
);

View File

@ -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);
});
}
);

View File

@ -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);
});
}
);

View File

@ -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();
});
}
);

View File

@ -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)</script>`);
const { items } = await processHTML({
container,
selector,
});
assert.strictEqual(
items[0].title,
`&quot;&gt;&lt;\x00script&gt;javascript:alert(1)&lt;/script&gt;`
);
});
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 }));
});
});

View File

@ -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"
);
});
});

View File

@ -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";

View File

@ -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);
}
}

View File

@ -4040,6 +4040,21 @@ en:
content_load_error: '<a href="%url%">The content</a> could not be loaded.'
image_load_error: '<a href="%url%">The image</a> 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:

View File

@ -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."

View File

@ -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

View File

@ -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

View File

@ -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)}}

View File

@ -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",
});