diff --git a/app/assets/javascripts/discourse/app/components/d-button.hbs b/app/assets/javascripts/discourse/app/components/d-button.hbs index 0b9fc386241..ab6dc82ca2e 100644 --- a/app/assets/javascripts/discourse/app/components/d-button.hbs +++ b/app/assets/javascripts/discourse/app/components/d-button.hbs @@ -27,7 +27,13 @@ {{~d-icon "spinner" class="loading-icon"~}} {{else}} {{#if @icon}} - {{~d-icon @icon~}} + {{#if @ariaHidden}} + + {{else}} + {{~d-icon @icon~}} + {{/if}} {{/if}} {{/if}} diff --git a/app/assets/javascripts/discourse/app/components/d-lightbox.hbs b/app/assets/javascripts/discourse/app/components/d-lightbox.hbs new file mode 100644 index 00000000000..fc7d3ca867d --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-lightbox.hbs @@ -0,0 +1,83 @@ + + {{#if this.isVisible}} +
+
+ + + {{#if this.shouldDisplayCarousel}} + + {{/if}} + + +
+
+ {{/if}} +
\ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/d-lightbox.js b/app/assets/javascripts/discourse/app/components/d-lightbox.js new file mode 100644 index 00000000000..87d3e6abe11 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-lightbox.js @@ -0,0 +1,470 @@ +import { + ANIMATION_DURATION, + KEYBOARD_SHORTCUTS, + LAYOUT_TYPES, + LIGHTBOX_APP_EVENT_NAMES, + LIGHTBOX_ELEMENT_ID, + SWIPE_DIRECTIONS, + TITLE_ELEMENT_ID, +} from "discourse/lib/lightbox/constants"; +import { + createDownloadLink, + getSwipeDirection, + openImageInNewTab, + preloadItemImages, + scrollParentToElementCenter, + setCarouselScrollPosition, + setSiteThemeColor, +} from "discourse/lib/lightbox/helpers"; + +import Component from "@glimmer/component"; +import { bind } from "discourse-common/utils/decorators"; +import discourseLater from "discourse-common/lib/later"; +import { htmlSafe } from "@ember/template"; +import { inject as service } from "@ember/service"; +import { tracked } from "@glimmer/tracking"; + +export default class DLightbox extends Component { + @service appEvents; + + @tracked isVisible = false; + @tracked isLoading = false; + @tracked currentIndex = 0; + @tracked currentItem = {}; + + @tracked isZoomed = false; + @tracked isRotated = false; + @tracked isFullScreen = false; + @tracked rotationAmount = 0; + + @tracked hasCarousel = false; + @tracked hasExpandedTitle = false; + + elementId = LIGHTBOX_ELEMENT_ID; + titleElementId = TITLE_ELEMENT_ID; + animationDuration = ANIMATION_DURATION; + + get layoutType() { + return window.innerWidth > window.innerHeight + ? LAYOUT_TYPES.HORIZONTAL + : LAYOUT_TYPES.VERTICAL; + } + + get CSSVars() { + const base = "--d-lightbox-image"; + + const variables = [ + `${base}-animation-duration: ${this.animationDuration}ms;`, + ]; + + if (!this.currentItem) { + return htmlSafe(variables.join("")); + } + + const { width, height, aspectRatio, dominantColor, fullsizeURL, smallURL } = + this.currentItem; + + variables.push( + `${base}-rotation: ${this.rotationAmount}deg`, + `${base}-width: ${width}px`, + `${base}-height: ${height}px`, + `${base}-aspect-ratio: ${aspectRatio}`, + `${base}-dominant-color: #${dominantColor}`, + `${base}-full-size-url: url(${encodeURI(fullsizeURL)})`, + `${base}-small-url: url(${encodeURI(smallURL)})` + ); + + return htmlSafe(variables.filter(Boolean).join(";")); + } + + get HTMLClassList() { + const base = "d-lightbox"; + + const classNames = [base]; + + if (!this.isVisible) { + return classNames.join(""); + } + + classNames.push( + this.layoutType && `is-${this.layoutType}`, + this.isVisible && `is-visible`, + this.isLoading ? `is-loading` : `is-finished-loading`, + this.isFullScreen && `is-fullscreen`, + this.isZoomed && `is-zoomed`, + this.isRotated && `is-rotated`, + this.canZoom && `can-zoom`, + this.hasExpandedTitle && `has-expanded-title`, + this.hasCarousel && `has-carousel`, + this.hasLoadingError && `has-loading-error`, + this.willClose && `will-close`, + this.isRotated && + this.rotationAmount && + `is-rotated-${this.rotationAmount}` + ); + + return classNames.filter(Boolean).join(" "); + } + + get shouldDisplayMainImageArrows() { + return ( + !this.options.isMobile && + this.canNavigate && + !this.hasCarousel && + !this.isZoomed && + !this.isRotated + ); + } + + get shouldDisplayCarousel() { + return this.hasCarousel && !this.isZoomed && !this.isRotated; + } + + get shouldDisplayCarouselArrows() { + return ( + !this.options.isMobile && + this.totalItemCount >= this.options.minCarosuelArrowItemCount + ); + } + + get shouldDisplayTitle() { + return !this.hasLoadingError && !this.isZoomed && !this.isRotated; + } + + get totalItemCount() { + return this.items?.length || 0; + } + + get counterIndex() { + return this.currentIndex ? this.currentIndex + 1 : 1; + } + + get canNavigate() { + return this.items?.length > 1; + } + + get canZoom() { + return !this.hasLoadingError && this.currentItem?.canZoom; + } + + get canRotate() { + return !this.hasLoadingError; + } + + get canDownload() { + return !this.hasLoadingError && this.options.canDownload; + } + + get canFullscreen() { + return !this.hasLoadingError; + } + + get hasLoadingError() { + return this.currentItem?.hasLoadingError; + } + + get nextButtonIcon() { + return this.options.isRTL ? "chevron-left" : "chevron-right"; + } + + get previousButtonIcon() { + return this.options.isRTL ? "chevron-right" : "chevron-left"; + } + + get zoomButtonIcon() { + return this.isZoomed ? "search-minus" : "search-plus"; + } + + @bind + registerAppEventListeners() { + this.appEvents.on(LIGHTBOX_APP_EVENT_NAMES.OPEN, this.open); + this.appEvents.on(LIGHTBOX_APP_EVENT_NAMES.CLOSE, this.close); + } + + @bind + deregisterAppEventListners() { + this.appEvents.off(LIGHTBOX_APP_EVENT_NAMES.OPEN, this.open); + this.appEvents.off(LIGHTBOX_APP_EVENT_NAMES.CLOSE, this.close); + } + + @bind + async open({ items, startingIndex, callbacks, options }) { + this.options = options; + + this.items = items; + this.currentIndex = startingIndex; + this.callbacks = callbacks; + + this.isLoading = true; + this.isVisible = true; + + await this.#setCurrentItem(this.currentIndex); + + if ( + this.options.zoomOnOpen && + this.currentItem?.canZoom && + !this.currentItem?.isZoomed + ) { + this.toggleZoom(); + } + + this.callbacks.onOpen?.({ + items: this.items, + currentItem: this.currentItem, + }); + } + + @bind + close() { + this.willClose = true; + + discourseLater(this.cleanup, this.animationDuration); + + this.callbacks.onClose?.(); + } + + async #setCurrentItem(index) { + this.#onBeforeItemChange(); + + this.currentIndex = (index + this.totalItemCount) % this.totalItemCount; + this.currentItem = await preloadItemImages(this.items[this.currentIndex]); + + this.#onAfterItemChange(); + } + + #onBeforeItemChange() { + this.callbacks.onItemWillChange?.({ + currentItem: this.currentItem, + }); + + this.isLoading = true; + this.isZoomed = false; + this.isRotated = false; + } + + #onAfterItemChange() { + this.isLoading = false; + + setSiteThemeColor(this.currentItem.dominantColor); + + setCarouselScrollPosition({ + behavior: "smooth", + }); + + this.callbacks.onItemDidChange?.({ + currentItem: this.currentItem, + }); + + const nextItem = this.items[this.currentIndex + 1]; + return nextItem ? preloadItemImages(nextItem) : false; + } + + @bind + centerZoomedBackgroundPosition(zoomedImageContainer) { + return this.options.isMobile + ? scrollParentToElementCenter({ + element: zoomedImageContainer, + isRTL: this.options.isRTL, + }) + : false; + } + + zoomOnMouseover(event) { + const zoomedImageContainer = event.target; + + const offsetX = event.offsetX; + const offsetY = event.offsetY; + + const x = (offsetX / zoomedImageContainer.offsetWidth) * 100; + const y = (offsetY / zoomedImageContainer.offsetHeight) * 100; + + zoomedImageContainer.style.backgroundPosition = x + "% " + y + "%"; + } + + @bind + toggleZoom() { + if (this.isLoading || !this.canZoom) { + return; + } + + this.isZoomed = !this.isZoomed; + document.querySelector(".d-lightbox__close-button")?.focus(); + } + + @bind + rotateImage() { + this.rotationAmount = (this.rotationAmount + 90) % 360; + this.isRotated = this.rotationAmount !== 0; + } + + @bind + toggleFullScreen() { + this.isFullScreen = !this.isFullScreen; + + return this.isFullScreen + ? document.documentElement.requestFullscreen() + : document.exitFullscreen(); + } + + @bind + downloadImage() { + return createDownloadLink(this.currentItem); + } + + @bind + openInNewTab() { + return openImageInNewTab(this.currentItem); + } + + @bind + reloadImage() { + this.#setCurrentItem(this.currentIndex); + } + + @bind + toggleCarousel() { + this.hasCarousel = !this.hasCarousel; + + requestAnimationFrame(setCarouselScrollPosition); + } + + @bind + showNextItem() { + this.#setCurrentItem(this.currentIndex + 1); + } + + @bind + showPreviousItem() { + this.#setCurrentItem(this.currentIndex - 1); + } + + @bind + showSelectedImage(event) { + const targetIndex = event.target.dataset?.lightboxItemIndex; + return targetIndex ? this.#setCurrentItem(Number(targetIndex)) : false; + } + + @bind + toggleExpandTitle() { + this.hasExpandedTitle = !this.hasExpandedTitle; + } + + @bind + onKeyup({ key }) { + if (KEYBOARD_SHORTCUTS.PREVIOUS.includes(key)) { + return this.showPreviousItem(); + } + + if (KEYBOARD_SHORTCUTS.NEXT.includes(key)) { + return this.showNextItem(); + } + + if (key === KEYBOARD_SHORTCUTS.CLOSE) { + return this.close(); + } + + if (key === KEYBOARD_SHORTCUTS.ZOOM) { + return this.toggleZoom(); + } + + if (key === KEYBOARD_SHORTCUTS.FULLSCREEN) { + return this.toggleFullScreen(); + } + + if (key === KEYBOARD_SHORTCUTS.ROTATE) { + return this.rotateImage(); + } + + if (key === KEYBOARD_SHORTCUTS.DOWNLOAD) { + return this.downloadImage(); + } + + if (key === KEYBOARD_SHORTCUTS.CAROUSEL) { + return this.toggleCarousel(); + } + + if (key === KEYBOARD_SHORTCUTS.TITLE) { + return this.toggleExpandTitle(); + } + + if (key === KEYBOARD_SHORTCUTS.NEWTAB) { + return this.openInNewTab(); + } + } + + @bind + onTouchstart(event = Event) { + if (this.isZoomed) { + return false; + } + + this.touchstartX = event.changedTouches[0].screenX; + this.touchstartY = event.changedTouches[0].screenY; + } + + @bind + async onTouchend(event) { + if (this.isZoomed) { + return false; + } + + event.stopPropagation(); + + const touchendY = event.changedTouches[0].screenY; + const touchendX = event.changedTouches[0].screenX; + + const swipeDirection = await getSwipeDirection({ + touchstartX: this.touchstartX, + touchstartY: this.touchstartY, + touchendX, + touchendY, + }); + + switch (swipeDirection) { + case SWIPE_DIRECTIONS.LEFT: + this.options.isRTL ? this.showNextItem() : this.showPreviousItem(); + break; + case SWIPE_DIRECTIONS.RIGHT: + this.options.isRTL ? this.showPreviousItem() : this.showNextItem(); + break; + case SWIPE_DIRECTIONS.UP: + this.close(); + break; + case SWIPE_DIRECTIONS.DOWN: + this.toggleCarousel(); + break; + } + } + + @bind + cleanup() { + if (this.isVisible) { + this.hasCarousel = !!document.querySelector(".d-lightbox.has-carousel"); + this.hasExpandedTitle = false; + this.isLoading = false; + this.items = []; + this.currentIndex = 0; + this.isZoomed = false; + this.isRotated = false; + this.rotationAmount = 0; + + if (this.isFullScreen) { + this.toggleFullScreen(); + this.isFullScreen = false; + } + + this.isVisible = false; + this.willClose = false; + + this.callbacks.onCleanup?.(); + + this.callbacks = {}; + this.options = {}; + } + } + + willDestroy() { + super.willDestroy(...arguments); + this.cleanup(); + } +} diff --git a/app/assets/javascripts/discourse/app/components/d-lightbox/backdrop.hbs b/app/assets/javascripts/discourse/app/components/d-lightbox/backdrop.hbs new file mode 100644 index 00000000000..5ef4361fd8f --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-lightbox/backdrop.hbs @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/d-lightbox/body.hbs b/app/assets/javascripts/discourse/app/components/d-lightbox/body.hbs new file mode 100644 index 00000000000..75348d76d23 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-lightbox/body.hbs @@ -0,0 +1,59 @@ +
+ {{#if @shouldDisplayMainImageArrows}} + + {{/if}} + {{#if @isLoading}} + + {{loading-spinner size="large"}} + + {{else if @hasLoadingError}} + + + {{i18n "experimental_lightbox.image_load_error"}} + + {{else if @isZoomed}} +
+ {{else}} + + + {{/if}} + {{#if @shouldDisplayMainImageArrows}} + + {{/if}} +
\ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/d-lightbox/carousel.hbs b/app/assets/javascripts/discourse/app/components/d-lightbox/carousel.hbs new file mode 100644 index 00000000000..c5867364eff --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-lightbox/carousel.hbs @@ -0,0 +1,49 @@ + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/d-lightbox/footer.hbs b/app/assets/javascripts/discourse/app/components/d-lightbox/footer.hbs new file mode 100644 index 00000000000..3427e2b7dc2 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-lightbox/footer.hbs @@ -0,0 +1,48 @@ + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/d-lightbox/header.hbs b/app/assets/javascripts/discourse/app/components/d-lightbox/header.hbs new file mode 100644 index 00000000000..e9068d69288 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-lightbox/header.hbs @@ -0,0 +1,54 @@ +
+ {{#if @canNavigate}} + + {{/if}} +
+ {{#if @canDownload}} +
+
\ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/d-lightbox/screen-reader-announcer.hbs b/app/assets/javascripts/discourse/app/components/d-lightbox/screen-reader-announcer.hbs new file mode 100644 index 00000000000..5220c12c72f --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-lightbox/screen-reader-announcer.hbs @@ -0,0 +1,16 @@ +
+

+ {{i18n + "experimental_lightbox.screen_reader_image_title" + current=@counterIndex + total=@totalItemCount + title=@currentItem.title + }} +

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