PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
← All changes | modules/carousel/jetpack-carousel.js +664 -209 12.1.3 → 16.3-a.7 View file →
@@ -1,5 +1,6 @@
1 1 /* global wpcom, jetpackCarouselStrings, DocumentTouch */
2 +/* eslint-disable no-shadow */
2 3
3 4 ( function () {
4 5 'use strict';
5 6 var swiper;
@@ -45,9 +46,9 @@
45 46
46 47 context.filter = 'blur(20px) ';
47 48 context.drawImage( imgEl, 0, 0 );
48 49 var url = canvas.toDataURL( 'image/png' );
49 - canvas = null;
50 + canvas = null; // eslint-disable-line no-useless-assignment -- Verify this isn't needed to free memory or something.
50 51
51 52 return url;
52 53 }
53 54
@@ -106,50 +107,59 @@
106 107 el.style.display = 'block';
107 108 }
108 109 }
109 110
111 + /**
112 + * CSS-transition fade (compositor). Duration + reduced-motion live in the
113 + * `.jp-carousel-fade` rule. A timer drives the finish, not `transitionend` -- that
114 + * event is skipped in background tabs / `transition: none` / zero duration, so the
115 + * timer is the reliable single source. `callback` fires once, after the fade.
116 + */
110 117 function fade( el, start, end, callback ) {
111 118 if ( ! el ) {
112 119 return callback();
113 120 }
114 121
115 - // Prepare for transition.
116 - // Ensure the item is in the render tree, in its initial state.
122 + // A fade already running on this element must not deliver its callback any more.
123 + if ( el.jpCarouselCancelFade ) {
124 + el.jpCarouselCancelFade();
125 + }
126 +
127 + // Set + commit the start state before attaching the transition, or the fade is swallowed.
128 + el.classList.remove( 'jp-carousel-fade' );
117 129 el.style.removeProperty( 'display' );
118 130 el.style.opacity = start;
119 - el.style.transition = 'opacity 0.2s';
120 131 el.style.pointerEvents = 'none';
121 132
122 - var finished = function ( e ) {
123 - if ( e.target === el && e.propertyName === 'opacity' ) {
124 - el.style.removeProperty( 'transition' );
125 - el.style.removeProperty( 'opacity' );
126 - el.style.removeProperty( 'pointer-events' );
127 - el.removeEventListener( 'transitionend', finished );
128 - el.removeEventListener( 'transitioncancel', finished );
129 - callback();
130 - }
133 + // Commit the starting opacity, otherwise the browser has nothing to animate from.
134 + void el.offsetWidth;
135 +
136 + el.classList.add( 'jp-carousel-fade' );
137 + el.style.opacity = end;
138 +
139 + // Read the duration back from the stylesheet so the timer always outlives the transition.
140 + var duration = parseFloat( getComputedStyle( el ).transitionDuration ) * 1000 || 0;
141 +
142 + var timer = setTimeout( function () {
143 + el.jpCarouselCancelFade = null;
144 + el.style.removeProperty( 'pointer-events' );
145 + callback();
146 + }, duration + 50 );
147 +
148 + el.jpCarouselCancelFade = function () {
149 + clearTimeout( timer );
150 + el.jpCarouselCancelFade = null;
131 151 };
132 -
133 - requestAnimationFrame( function () {
134 - // Double rAF for browser compatibility.
135 - requestAnimationFrame( function () {
136 - el.addEventListener( 'transitionend', finished );
137 - el.addEventListener( 'transitioncancel', finished );
138 - // Trigger transition.
139 - el.style.opacity = end;
140 - } );
141 - } );
142 152 }
143 153
144 154 function fadeIn( el, callback ) {
145 155 callback = callback || util.noop;
146 - fade( el, '0', '1', callback );
156 + fade( el, 0, 1, callback );
147 157 }
148 158
149 159 function fadeOut( el, callback ) {
150 160 callback = callback || util.noop;
151 - fade( el, '1', '0', function () {
161 + fade( el, 1, 0, function () {
152 162 if ( el ) {
153 163 el.style.display = 'none';
154 164 }
155 165 callback();
@@ -163,9 +173,9 @@
163 173 bubbles: true,
164 174 cancelable: true,
165 175 detail: detail || null,
166 176 } );
167 - } catch ( err ) {
177 + } catch {
168 178 e = document.createEvent( 'CustomEvent' );
169 179 e.initCustomEvent( type, true, true, detail || null );
170 180 }
171 181 el.dispatchEvent( e );
@@ -195,8 +205,14 @@
195 205 'ontouchstart' in window || ( window.DocumentTouch && document instanceof DocumentTouch )
196 206 );
197 207 }
198 208
209 + function prefersReducedMotion() {
210 + return (
211 + !! window.matchMedia && window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches
212 + );
213 + }
214 +
199 215 function scrollToElement( el, container, callback ) {
200 216 if ( ! el || ! container ) {
201 217 if ( callback ) {
202 218 return callback();
@@ -258,9 +274,9 @@
258 274 }
259 275
260 276 try {
261 277 return JSON.parse( el.getAttribute( attr ) );
262 - } catch ( e ) {
278 + } catch {
263 279 return undefined;
264 280 }
265 281 }
266 282
@@ -270,9 +286,11 @@
270 286 return dummy.innerHTML;
271 287 }
272 288
273 289 function stripHTML( text ) {
274 - return text.replace( /<[^>]*>?/gm, '' );
290 + var tmp = document.createElement( 'div' );
291 + tmp.innerHTML = text.replace( /<[^>]*>?/gm, '' );
292 + return tmp.textContent;
275 293 }
276 294
277 295 return {
278 296 closest: closest,
@@ -286,8 +304,9 @@
286 304 convertToPlainText: convertToPlainText,
287 305 stripHTML: stripHTML,
288 306 emitEvent: emitEvent,
289 307 isTouch: isTouch,
308 + prefersReducedMotion: prefersReducedMotion,
290 309 };
291 310 } )();
292 311
293 312 /////////////////////////////////////
@@ -294,8 +313,14 @@
294 313 // Carousel implementation
295 314 /////////////////////////////////////
296 315 function init() {
297 316 var commentInterval;
317 + // Rendered comments per attachment, kept for the lifetime of one overlay session.
318 + var commentsCache = {};
319 + // Attachment IDs with a comments request in flight, to prevent overlapping fetches.
320 + var commentsFetching = {};
321 + // Comments the server returns per page; mirrors the `number` arg in get_attachment_comments().
322 + var COMMENTS_PER_PAGE = 10;
298 323 var screenPadding;
299 324 var originalOverflow;
300 325 var originalHOverflow;
301 326 var scrollPos;
@@ -376,8 +401,14 @@
376 401 }
377 402 }
378 403 }
379 404
405 + function makeGalleryImageAccessible( img ) {
406 + img.role = 'button';
407 + img.tabIndex = 0;
408 + img.ariaLabel = jetpackCarouselStrings.image_label;
409 + }
410 +
380 411 function initializeCarousel() {
381 412 if ( ! carousel.overlay ) {
382 413 carousel.overlay = document.querySelector( '.jp-carousel-overlay' );
383 414 carousel.container = carousel.overlay.querySelector( '.jp-carousel-wrap' );
@@ -413,11 +444,9 @@
413 444 var target = e.target;
414 445 var isTargetCloseHint = !! domUtil.closest( target, '.jp-carousel-close-hint' );
415 446 var isSmallScreen = !! window.matchMedia( '(max-device-width: 760px)' ).matches;
416 447 if ( target === carousel.overlay ) {
417 - if ( isSmallScreen ) {
418 - return;
419 - } else {
448 + if ( ! isSmallScreen ) {
420 449 closeCarousel();
421 450 }
422 451 } else if ( isTargetCloseHint ) {
423 452 closeCarousel();
@@ -431,10 +460,8 @@
431 460 domUtil.closest( target, '.jp-carousel-photo-icons-container' ) ||
432 461 target.classList.contains( 'jp-carousel-photo-title' )
433 462 ) {
434 463 handleFooterElementClick( e );
435 - } else if ( ! domUtil.closest( target, '.jp-carousel-info' ) ) {
436 - return;
437 464 }
438 465 } );
439 466
440 467 window.addEventListener( 'keydown', handleKeyboardEvent );
@@ -520,8 +547,9 @@
520 547 var attachmentId = carousel.currentSlide.attrs.attachmentId;
521 548
522 549 var wrapper = document.querySelector( '#jp-carousel-comment-form-submit-and-info-wrapper' );
523 550 var spinner = document.querySelector( '#jp-carousel-comment-form-spinner' );
551 + // eslint-disable-next-line @wordpress/no-unused-vars-before-return
524 552 var submit = document.querySelector( '#jp-carousel-comment-form-button-submit' );
525 553 var form = document.querySelector( '#jp-carousel-comment-form' );
526 554
527 555 if (
@@ -580,14 +608,26 @@
580 608 ) {
581 609 var response;
582 610 try {
583 611 response = JSON.parse( this.response );
584 - } catch ( error ) {
612 + } catch {
585 613 updatePostResults( jetpackCarouselStrings.comment_post_error, false );
586 614 return;
587 615 }
588 616 if ( response.comment_status === 'approved' ) {
589 617 updatePostResults( jetpackCarouselStrings.comment_approved, true );
618 + /*
619 + Reflect the new comment in the badge total straight away. The refetch
620 + below only returns the first page, so on attachments with a full page
621 + of comments it cannot see the increment on its own. Bump the slide the
622 + comment was posted to -- captured as `attachmentId` -- not whichever
623 + slide happens to be current when this async response lands.
624 + */
625 + var postedSlide = getSlideByAttachmentId( attachmentId );
626 + if ( postedSlide ) {
627 + postedSlide.attrs.commentsCount =
628 + ( parseInt( postedSlide.attrs.commentsCount, 10 ) || 0 ) + 1;
629 + }
590 630 } else if ( response.comment_status === 'unapproved' ) {
591 631 updatePostResults( jetpackCarouselStrings.comment_unapproved, true );
592 632 } else {
593 633 // 'deleted', 'spam', false
@@ -593,9 +633,13 @@
593 633 // 'deleted', 'spam', false
594 634 updatePostResults( jetpackCarouselStrings.comment_post_error, false );
595 635 }
596 636 clearCommentTextAreaValue();
597 - fetchComments( attachmentId );
637 + // The new comment invalidates whatever we cached for this attachment.
638 + delete commentsCache[ attachmentId ];
639 + // Force past the in-flight guard so a still-pending first page can't
640 + // suppress this refresh.
641 + fetchComments( attachmentId, undefined, true );
598 642 submit.value = jetpackCarouselStrings.post_comment;
599 643 domUtil.hide( spinner );
600 644 form.classList.remove( 'jp-carousel-is-disabled' );
601 645 } else {
@@ -664,8 +708,19 @@
664 708 if ( commentsContainer ) {
665 709 commentsContainer.classList.toggle( 'jp-carousel-show' );
666 710 if ( commentsContainer.classList.contains( 'jp-carousel-show' ) ) {
667 711 extraInfoContainer.classList.add( 'jp-carousel-show' );
712 + // The panel is only now on screen, so this is when the comments are worth fetching.
713 + var current = carousel.currentSlide;
714 + if ( current ) {
715 + var currentCache = commentsCache[ current.attrs.attachmentId ];
716 + if ( ! currentCache ) {
717 + fetchComments( current.attrs.attachmentId );
718 + } else if ( currentCache.hasMore ) {
719 + // Cached page is already shown; re-arm paging for the rest.
720 + scheduleNextCommentsPage( current.attrs.attachmentId, currentCache.nextOffset );
721 + }
722 + }
668 723 } else {
669 724 extraInfoContainer.classList.remove( 'jp-carousel-show' );
670 725 }
671 726 }
@@ -734,8 +789,10 @@
734 789 if ( ! valid ) {
735 790 return;
736 791 }
737 792
793 + makeGalleryImageAccessible( image );
794 +
738 795 // Make this node a gallery recognizable by event listener above.
739 796 link.classList.add( 'single-image-gallery' );
740 797 // blog_id is needed to allow posting comments to correct blog.
741 798 link.setAttribute(
@@ -761,8 +818,17 @@
761 818 swiper.slideTo( index + 1 );
762 819 }
763 820 }
764 821
822 + function getSlideByAttachmentId( attachmentId ) {
823 + for ( var i = 0; i < carousel.slides.length; i++ ) {
824 + if ( carousel.slides[ i ].attrs.attachmentId === attachmentId ) {
825 + return carousel.slides[ i ];
826 + }
827 + }
828 + return null;
829 + }
830 +
765 831 function selectSlideAtIndex( index ) {
766 832 if ( ! index || index < 0 || index > carousel.slides.length ) {
767 833 index = 0;
768 834 }
@@ -769,22 +835,14 @@
769 835 carousel.currentSlide = carousel.slides[ index ];
770 836
771 837 var current = carousel.currentSlide;
772 838 var attachmentId = current.attrs.attachmentId;
773 - var infoIcon = carousel.info.querySelector( '.jp-carousel-icon-info' );
774 - var commentsIcon = carousel.info.querySelector( '.jp-carousel-icon-comments' );
775 839
776 - // If the comment/info section is toggled open, it's kept open, but scroll to top of the next slide.
777 - if (
778 - ( infoIcon && infoIcon.classList.contains( 'jp-carousel-selected' ) ) ||
779 - ( commentsIcon && commentsIcon.classList.contains( 'jp-carousel-selected' ) )
780 - ) {
781 - if ( carousel.overlay.scrollTop !== 0 ) {
782 - domUtil.scrollToElement( carousel.overlay, carousel.overlay );
783 - }
784 - }
840 + // Load current image immediately
841 + loadFullImage( carousel.slides[ index ] );
785 842
786 - loadFullImage( carousel.slides[ index ] );
843 + // Preload adjacent images in background
844 + preloadAdjacentImages( index );
787 845
788 846 if (
789 847 Number( jetpackCarouselStrings.display_background_image ) === 1 &&
790 848 ! carousel.slides[ index ].backgroundImage
@@ -804,9 +862,9 @@
804 862 updateFullSizeLink( current );
805 863
806 864 if ( Number( jetpackCarouselStrings.display_comments ) === 1 ) {
807 865 testCommentsOpened( carousel.slides[ index ].attrs.commentsOpened );
808 - fetchComments( attachmentId );
866 + showCommentsForSlide( current );
809 867 domUtil.hide( carousel.info.querySelector( '#jp-carousel-comment-post-results' ) );
810 868 }
811 869
812 870 // Update pagination in footer.
@@ -816,9 +874,9 @@
816 874 pagination.innerHTML = '<span>' + currentPage + ' / ' + carousel.slides.length + '</span>';
817 875 }
818 876
819 877 // Record pageview in WP Stats, for each new image loaded full-screen.
820 - if ( jetpackCarouselStrings.stats ) {
878 + if ( jetpackCarouselStrings.stats && carousel.isOpen ) {
821 879 new Image().src =
822 880 document.location.protocol +
823 881 '//pixel.wp.com/g.gif?' +
824 882 jetpackCarouselStrings.stats +
@@ -827,11 +885,14 @@
827 885 '&rand=' +
828 886 Math.random();
829 887 }
830 888
831 - pageview( attachmentId );
889 + if ( carousel.isOpen ) {
890 + pageview( attachmentId );
891 + }
832 892
833 - window.location.hash = lastKnownLocationHash = '#jp-carousel-' + attachmentId;
893 + lastKnownLocationHash = '#jp-carousel-' + attachmentId;
894 + window.location.hash = lastKnownLocationHash;
834 895 }
835 896
836 897 function restoreScroll() {
837 898 window.scrollTo( window.scrollX || window.pageXOffset || 0, scrollPos || 0 );
@@ -846,12 +907,14 @@
846 907 disableKeyboardNavigation();
847 908
848 909 domUtil.emitEvent( carousel.overlay, 'jp_carousel.beforeClose' );
849 910 restoreScroll();
911 + carousel.isOpen = false;
850 912 swiper.destroy();
851 - carousel.isOpen = false;
852 913 // Clear slide data for DOM garbage collection.
853 914 carousel.slides = [];
915 + commentsCache = {};
916 + commentsFetching = {};
854 917 carousel.currentSlide = undefined;
855 918 carousel.gallery.innerHTML = '';
856 919
857 920 domUtil.fadeOut( carousel.overlay, function () {
@@ -865,8 +928,43 @@
865 928 height: window.innerHeight - 64, //subtract height of bottom info bar,
866 929 };
867 930 }
868 931
932 + function sanitizePhotonUrl( url ) {
933 + var urlObj;
934 + try {
935 + urlObj = new URL( url );
936 + // eslint-disable-next-line no-unused-vars
937 + } catch ( e ) {
938 + return url;
939 + }
940 +
941 + var whitelistedPhotonArgs = [
942 + 'quality',
943 + 'ssl',
944 + 'filter',
945 + 'brightness',
946 + 'contrast',
947 + 'colorize',
948 + 'smooth',
949 + ];
950 +
951 + // Get all search params
952 + var searchParams = Array.from( urlObj.searchParams.entries() );
953 +
954 + // Clear all existing params
955 + urlObj.search = '';
956 +
957 + // Only add back whitelisted params
958 + searchParams.forEach( ( [ key, value ] ) => {
959 + if ( whitelistedPhotonArgs.includes( key ) ) {
960 + urlObj.searchParams.append( key, value );
961 + }
962 + } );
963 +
964 + return urlObj;
965 + }
966 +
869 967 function selectBestImageUrl( args ) {
870 968 if ( typeof args !== 'object' ) {
871 969 args = {};
872 970 }
@@ -878,9 +976,14 @@
878 976 if ( typeof args.origWidth === 'undefined' || typeof args.maxWidth === 'undefined' ) {
879 977 return args.origFile;
880 978 }
881 979
882 - if ( typeof args.mediumFile === 'undefined' || typeof args.largeFile === 'undefined' ) {
980 + // When there's no large file to fall back on (e.g. images that weren't enriched with
981 + // Jetpack's data-large-file attribute), use the original file. A missing attribute is
982 + // read as an empty string, so we can't only guard against `undefined` here: otherwise a
983 + // narrow (portrait, mobile) viewport would return that empty string as the image source,
984 + // leaving the carousel with a blank slide.
985 + if ( ! args.largeFile ) {
883 986 return args.origFile;
884 987 }
885 988
886 989 // Check if the image is being served by Photon (using a regular expression on the hostname).
@@ -889,15 +992,12 @@
889 992 imageLinkParser.href = args.largeFile;
890 993
891 994 var isPhotonUrl = /^i[0-2]\.wp\.com$/i.test( imageLinkParser.hostname );
892 995
893 - var mediumSizeParts = getImageSizeParts( args.mediumFile, args.origWidth, isPhotonUrl );
894 996 var largeSizeParts = getImageSizeParts( args.largeFile, args.origWidth, isPhotonUrl );
895 997
896 998 var largeWidth = parseInt( largeSizeParts[ 0 ], 10 );
897 999 var largeHeight = parseInt( largeSizeParts[ 1 ], 10 );
898 - var mediumWidth = parseInt( mediumSizeParts[ 0 ], 10 );
899 - var mediumHeight = parseInt( mediumSizeParts[ 1 ], 10 );
900 1000
901 1001 args.origMaxWidth = args.maxWidth;
902 1002 args.origMaxHeight = args.maxHeight;
903 1003
@@ -906,33 +1006,46 @@
906 1006 args.maxWidth = args.maxWidth * window.devicePixelRatio;
907 1007 args.maxHeight = args.maxHeight * window.devicePixelRatio;
908 1008 }
909 1009
1010 + /*
1011 + `maxWidth`/`maxHeight` now describe the device resolution. Anything beyond that is
1012 + headroom for zooming, so only the zoom request asks for it. The multiplier is folded
1013 + in here -- ahead of the size checks below -- so the zoom rendition keeps its extra
1014 + resolution even when the unzoomed one is already covered by `data-large-file`.
1015 + Without it, every slide would fetch ~4x the pixels the screen can show.
1016 + */
1017 + var headroom = args.zoomHeadroom || 1;
1018 + if ( headroom > 1 ) {
1019 + args.maxWidth = args.maxWidth * headroom;
1020 + args.maxHeight = args.maxHeight * headroom;
1021 + }
1022 +
910 1023 if ( largeWidth >= args.maxWidth || largeHeight >= args.maxHeight ) {
911 1024 return args.largeFile;
912 1025 }
913 1026
914 - if ( mediumWidth >= args.maxWidth || mediumHeight >= args.maxHeight ) {
915 - return args.mediumFile;
916 - }
917 -
918 1027 if ( isPhotonUrl ) {
919 1028 // args.origFile doesn't point to a Photon url, so in this case we use args.largeFile
920 1029 // to return the photon url of the original image.
921 - var largeFileIndex = args.largeFile.lastIndexOf( '?' );
922 - var origPhotonUrl = args.largeFile;
923 - if ( largeFileIndex !== -1 ) {
924 - origPhotonUrl = args.largeFile.substring( 0, largeFileIndex );
925 - // If we have a really large image load a smaller version
926 - // that is closer to the viewable size
927 - if ( args.origWidth > args.maxWidth || args.origHeight > args.maxHeight ) {
928 - // @2x the max sizes so we get a high enough resolution for zooming.
929 - args.origMaxWidth = args.maxWidth * 2;
930 - args.origMaxHeight = args.maxHeight * 2;
931 - origPhotonUrl += '?fit=' + args.origMaxWidth + '%2C' + args.origMaxHeight;
932 - }
1030 + if ( args.largeFile.lastIndexOf( '?' ) === -1 ) {
1031 + return args.largeFile;
933 1032 }
934 - return origPhotonUrl;
1033 +
1034 + // Sanitize the URL to remove non-cosmetic changes like resize, fit, etc.
1035 + var sanitizedUrl = sanitizePhotonUrl( args.largeFile );
1036 +
1037 + // If we have a really large image load a smaller version
1038 + // that is closer to the viewable size
1039 + if ( args.origWidth > args.maxWidth || args.origHeight > args.maxHeight ) {
1040 + args.origMaxWidth = args.maxWidth;
1041 + args.origMaxHeight = args.maxHeight;
1042 + // Add the fit arg to the list of Photon args.
1043 + sanitizedUrl.searchParams.set( 'fit', args.origMaxWidth + ',' + args.origMaxHeight );
1044 + }
1045 +
1046 + // Return a Photon URL image that's better fitted for the viewport.
1047 + return sanitizedUrl.toString();
935 1048 }
936 1049
937 1050 return args.origFile;
938 1051 }
@@ -941,14 +1054,14 @@
941 1054 var size = isPhotonUrl
942 1055 ? file.replace( /.*=([\d]+%2C[\d]+).*$/, '$1' )
943 1056 : file.replace( /.*-([\d]+x[\d]+)\..+$/, '$1' );
944 1057
945 - var sizeParts =
946 - size !== file
947 - ? isPhotonUrl
948 - ? size.split( '%2C' )
949 - : size.split( 'x' )
950 - : [ origWidth, 0 ];
1058 + var sizeParts;
1059 + if ( size !== file ) {
1060 + sizeParts = isPhotonUrl ? size.split( '%2C' ) : size.split( 'x' );
1061 + } else {
1062 + sizeParts = [ origWidth, 0 ];
1063 + }
951 1064
952 1065 // If one of the dimensions is set to 9999, then the actual value of that dimension can't be retrieved from the url.
953 1066 // In that case, we set the value to 0.
954 1067 if ( sizeParts[ 0 ] === '9999' ) {
@@ -992,11 +1105,11 @@
992 1105 return value;
993 1106 }
994 1107
995 1108 function updateTitleCaptionAndDesc( data ) {
996 - var caption = '';
997 - var title = '';
998 - var desc = '';
1109 + var caption;
1110 + var title;
1111 + var desc;
999 1112 var captionMainElement;
1000 1113 var captionInfoExtraElement;
1001 1114 var titleElement;
1002 1115 var descriptionElement;
@@ -1041,9 +1154,9 @@
1041 1154 descriptionElement.innerHTML = desc;
1042 1155 domUtil.show( descriptionElement );
1043 1156
1044 1157 if ( ! title && ! caption ) {
1045 - captionMainElement.innerHTML = domUtil.stripHTML( desc );
1158 + captionMainElement.textContent = domUtil.stripHTML( desc );
1046 1159 domUtil.show( captionMainElement );
1047 1160 }
1048 1161 }
1049 1162
@@ -1048,13 +1161,13 @@
1048 1161 }
1049 1162
1050 1163 if ( title ) {
1051 1164 var plainTitle = domUtil.stripHTML( title );
1052 - titleElement.innerHTML = plainTitle;
1165 + titleElement.textContent = plainTitle;
1053 1166
1054 1167 if ( ! caption ) {
1055 - captionMainElement.innerHTML = plainTitle;
1056 - captionInfoExtraElement.innerHTML = plainTitle;
1168 + captionMainElement.textContent = plainTitle;
1169 + captionInfoExtraElement.textContent = plainTitle;
1057 1170
1058 1171 domUtil.show( captionMainElement );
1059 1172 }
1060 1173
@@ -1068,9 +1181,14 @@
1068 1181 if ( ! meta || Number( jetpackCarouselStrings.display_exif ) !== 1 ) {
1069 1182 return false;
1070 1183 }
1071 1184
1072 - var ul = carousel.info.querySelector( '.jp-carousel-image-meta ul.jp-carousel-image-exif' );
1185 + // Locate the parent container for the metadata.
1186 + var metaContainer = carousel.info.querySelector( '.jp-carousel-image-meta' );
1187 + if ( ! metaContainer ) {
1188 + return false;
1189 + }
1190 +
1073 1191 var html = '';
1074 1192
1075 1193 for ( var key in meta ) {
1076 1194 var val = meta[ key ];
@@ -1094,10 +1212,31 @@
1094 1212
1095 1213 html += '<li><h5>' + jetpackCarouselStrings[ key ] + '</h5>' + val + '</li>';
1096 1214 }
1097 1215
1098 - ul.innerHTML = html;
1099 - ul.style.removeProperty( 'display' );
1216 + // Handle the UL element dynamically to ensure valid markup.
1217 + var ul = metaContainer.querySelector( 'ul.jp-carousel-image-exif' );
1218 +
1219 + if ( html !== '' ) {
1220 + // If there is data to display and the UL doesn't exist, create it.
1221 + if ( ! ul ) {
1222 + ul = document.createElement( 'ul' );
1223 + ul.className = 'jp-carousel-image-exif';
1224 +
1225 + // Insert right after the title/caption container if it exists, otherwise prepend
1226 + var titleAndCaption = metaContainer.querySelector( '.jp-carousel-title-and-caption' );
1227 + if ( titleAndCaption && titleAndCaption.nextSibling ) {
1228 + metaContainer.insertBefore( ul, titleAndCaption.nextSibling );
1229 + } else {
1230 + metaContainer.insertBefore( ul, metaContainer.firstChild );
1231 + }
1232 + }
1233 + ul.innerHTML = html;
1234 + ul.style.removeProperty( 'display' );
1235 + } else if ( ul ) {
1236 + // If the data is empty but the UL exists in the DOM, remove it.
1237 + ul.parentNode.removeChild( ul );
1238 + }
1100 1239 }
1101 1240
1102 1241 // Update the contents of the jp-carousel-image-download link
1103 1242 function updateFullSizeLink( currentSlide ) {
@@ -1128,9 +1267,9 @@
1128 1267 permalink.style.removeProperty( 'display' );
1129 1268 }
1130 1269
1131 1270 function testCommentsOpened( opened ) {
1132 - var commentForm = carousel.container.querySelector( '.jp-carousel-comment-form-container' );
1271 + var commentForm = carousel.info.querySelector( '#jp-carousel-comment-form-container' );
1133 1272 var isOpened = parseInt( opened, 10 ) === 1;
1134 1273
1135 1274 if ( isOpened ) {
1136 1275 domUtil.fadeIn( commentForm );
@@ -1138,22 +1277,111 @@
1138 1277 domUtil.fadeOut( commentForm );
1139 1278 }
1140 1279 }
1141 1280
1142 - function fetchComments( attachmentId, offset ) {
1143 - var shouldClear = offset === undefined;
1144 - var commentsIndicator = carousel.info.querySelector(
1281 + function isCommentsPanelOpen() {
1282 + var wrapper = carousel.info.querySelector( '.jp-carousel-comments-wrapper' );
1283 + return !! wrapper && wrapper.classList.contains( 'jp-carousel-show' );
1284 + }
1285 +
1286 + function updateCommentsIndicator( count ) {
1287 + var indicator = carousel.info.querySelector(
1145 1288 '.jp-carousel-icon-comments .jp-carousel-has-comments-indicator'
1146 1289 );
1147 1290
1148 - commentsIndicator.classList.remove( 'jp-carousel-show' );
1291 + if ( ! indicator ) {
1292 + return;
1293 + }
1149 1294
1295 + if ( count > 0 ) {
1296 + indicator.innerText = count;
1297 + indicator.classList.add( 'jp-carousel-show' );
1298 + } else {
1299 + indicator.classList.remove( 'jp-carousel-show' );
1300 + }
1301 + }
1302 +
1303 + /**
1304 + * Show what we already know about a slide's comments, and fetch the rest only if the
1305 + * comments panel is actually on screen. The comment list used to be re-fetched from
1306 + * admin-ajax on every single slide change, even though it is hidden by default.
1307 + * @param {object} slide - The slide being shown.
1308 + */
1309 + function showCommentsForSlide( slide ) {
1310 + var attachmentId = slide.attrs.attachmentId;
1311 + var comments = carousel.info.querySelector( '.jp-carousel-comments' );
1312 + var cached = commentsCache[ attachmentId ];
1313 +
1150 1314 clearInterval( commentInterval );
1315 + domUtil.hide( carousel.info.querySelector( '#jp-carousel-comments-loading' ) );
1151 1316
1317 + if ( cached ) {
1318 + comments.innerHTML = cached.html;
1319 + updateCommentsIndicator( cached.count );
1320 + domUtil.show( comments );
1321 + /*
1322 + Resume paging if the cached run ended on a full page, so returning to a slide
1323 + does not strand the comments past the first page. Only while the panel is on
1324 + screen, mirroring the fresh-fetch path below.
1325 + */
1326 + if ( cached.hasMore && isCommentsPanelOpen() ) {
1327 + scheduleNextCommentsPage( attachmentId, cached.nextOffset );
1328 + }
1329 + return;
1330 + }
1331 +
1332 + comments.innerHTML = '';
1333 + domUtil.hide( comments );
1334 + // The server tells us the count up front, so the badge costs no request.
1335 + updateCommentsIndicator( parseInt( slide.attrs.commentsCount, 10 ) || 0 );
1336 +
1337 + if ( isCommentsPanelOpen() ) {
1338 + fetchComments( attachmentId );
1339 + }
1340 + }
1341 +
1342 + /**
1343 + * Fetch the next page of comments once the reader scrolls near the bottom.
1344 + * @param {string} attachmentId - The attachment whose comments are shown.
1345 + * @param {number} nextOffset - Offset of the page to load next.
1346 + */
1347 + function scheduleNextCommentsPage( attachmentId, nextOffset ) {
1348 + clearInterval( commentInterval );
1349 + commentInterval = setInterval( function () {
1350 + if ( carousel.container.scrollTop + 150 > window.innerHeight ) {
1351 + clearInterval( commentInterval );
1352 + fetchComments( attachmentId, nextOffset );
1353 + }
1354 + }, 300 );
1355 + }
1356 +
1357 + function fetchComments( attachmentId, offset, force ) {
1358 + var shouldClear = offset === undefined;
1359 +
1360 + clearInterval( commentInterval );
1361 +
1152 1362 if ( ! attachmentId ) {
1153 1363 return;
1154 1364 }
1155 1365
1366 + /*
1367 + The comments UI is shared and only ever shows the current slide, so a request for
1368 + any other slide (e.g. a post-submit refetch after the reader has navigated on) must
1369 + not touch it.
1370 + */
1371 + if ( ! carousel.currentSlide || carousel.currentSlide.attrs.attachmentId !== attachmentId ) {
1372 + return;
1373 + }
1374 +
1375 + /*
1376 + One request per attachment at a time, so a reopen mid-flight can't double-load.
1377 + `force` (a post-submit refresh) supersedes instead: the newest request is recorded
1378 + below and any older one's response bails on the identity check.
1379 + */
1380 + if ( commentsFetching[ attachmentId ] && ! force ) {
1381 + return;
1382 + }
1383 +
1156 1384 if ( ! offset || offset < 1 ) {
1157 1385 offset = 0;
1158 1386 }
1159 1387
@@ -1166,8 +1394,16 @@
1166 1394 comments.innerHTML = '';
1167 1395 }
1168 1396
1169 1397 var xhr = new XMLHttpRequest();
1398 + // Record this as the live request for the attachment; a later `force` fetch replaces it.
1399 + commentsFetching[ attachmentId ] = xhr;
1400 +
1401 + // True while `xhr` is still the attachment's live request (not superseded).
1402 + var isLiveRequest = function () {
1403 + return commentsFetching[ attachmentId ] === xhr;
1404 + };
1405 +
1170 1406 var url =
1171 1407 jetpackCarouselStrings.ajaxurl +
1172 1408 '?action=get_attachment_comments' +
1173 1409 '&nonce=' +
@@ -1178,14 +1414,40 @@
1178 1414 offset;
1179 1415 xhr.open( 'GET', url );
1180 1416 xhr.setRequestHeader( 'X-Requested-With', 'XMLHttpRequest' );
1181 1417
1182 - var onError = function () {
1418 + // Reveal the comments and clear the loading state; callers gate this themselves.
1419 + var revealComments = function () {
1183 1420 domUtil.fadeIn( comments );
1184 1421 domUtil.fadeOut( commentsLoading );
1185 1422 };
1186 1423
1424 + // Network-level failure (fires without onload); guard it like a late response.
1425 + var onError = function () {
1426 + // A newer (forced) request has superseded this one; leave the UI to it.
1427 + if ( ! isLiveRequest() ) {
1428 + return;
1429 + }
1430 + delete commentsFetching[ attachmentId ];
1431 +
1432 + // Only touch the shared UI if this attachment is still on screen.
1433 + if (
1434 + ! carousel.currentSlide ||
1435 + carousel.currentSlide.attrs.attachmentId !== attachmentId
1436 + ) {
1437 + return;
1438 + }
1439 + revealComments();
1440 + };
1441 +
1187 1442 xhr.onload = function () {
1443 + // A newer (forced) request has superseded this one; drop its result.
1444 + if ( ! isLiveRequest() ) {
1445 + return;
1446 + }
1447 + // The request is done, whatever the outcome below.
1448 + delete commentsFetching[ attachmentId ];
1449 +
1188 1450 // Ignore the results if they arrive late and we're now on a different slide.
1189 1451 if (
1190 1452 ! carousel.currentSlide ||
1191 1453 carousel.currentSlide.attrs.attachmentId !== attachmentId
@@ -1196,14 +1458,16 @@
1196 1458 var isSuccess = xhr.status >= 200 && xhr.status < 300;
1197 1459 var data;
1198 1460 try {
1199 1461 data = JSON.parse( xhr.responseText );
1200 - } catch ( e ) {
1462 + } catch {
1201 1463 // Do nothing.
1202 1464 }
1203 1465
1204 1466 if ( ! isSuccess || ! data || ! Array.isArray( data ) ) {
1205 - return onError();
1467 + // Already past the identity/current-slide guards above.
1468 + revealComments();
1469 + return;
1206 1470 }
1207 1471
1208 1472 if ( shouldClear ) {
1209 1473 comments.innerHTML = '';
@@ -1210,8 +1474,13 @@
1210 1474 }
1211 1475
1212 1476 for ( var i = 0; i < data.length; i++ ) {
1213 1477 var entry = data[ i ];
1478 + // Skip anything already on screen: a page can be requested twice if the
1479 + // reader re-opens a slide while its previous request is still in flight.
1480 + if ( comments.querySelector( '#jp-carousel-comment-' + entry.id ) ) {
1481 + continue;
1482 + }
1214 1483 var comment = document.createElement( 'div' );
1215 1484 comment.classList.add( 'jp-carousel-comment' );
1216 1485 comment.setAttribute( 'id', 'jp-carousel-comment-' + entry.id );
1217 1486 comment.innerHTML =
@@ -1227,25 +1496,46 @@
1227 1496 '</div>' +
1228 1497 entry.content +
1229 1498 '</div>';
1230 1499 comments.appendChild( comment );
1500 + }
1231 1501
1232 - // Set the interval to check for a new page of comments.
1233 - clearInterval( commentInterval );
1234 - commentInterval = setInterval( function () {
1235 - if ( carousel.container.scrollTop + 150 > window.innerHeight ) {
1236 - fetchComments( attachmentId, offset + 10 );
1237 - clearInterval( commentInterval );
1238 - }
1239 - }, 300 );
1502 + /*
1503 + A full page back means there may be more; watch for a scroll to the bottom to
1504 + load the next one. A short page means we have reached the end.
1505 + */
1506 + var hasMore = data.length >= COMMENTS_PER_PAGE;
1507 + var nextOffset = offset + COMMENTS_PER_PAGE;
1508 + if ( hasMore ) {
1509 + scheduleNextCommentsPage( attachmentId, nextOffset );
1240 1510 }
1241 1511
1512 + /*
1513 + The endpoint returns at most one page, so prefer the server's total where we
1514 + have it and only count what we rendered as a fallback.
1515 + */
1516 + var rendered = comments.querySelectorAll( '.jp-carousel-comment' ).length;
1517 + var count = Math.max(
1518 + parseInt( carousel.currentSlide.attrs.commentsCount, 10 ) || 0,
1519 + rendered
1520 + );
1521 +
1242 1522 if ( data.length > 0 ) {
1243 1523 domUtil.show( comments );
1244 - commentsIndicator.innerText = data.length;
1245 - commentsIndicator.classList.add( 'jp-carousel-show' );
1524 + updateCommentsIndicator( count );
1246 1525 }
1247 1526
1527 + /*
1528 + Keep what we rendered for as long as the overlay is open, so going back to a
1529 + slide costs nothing -- including where to resume paging from.
1530 + */
1531 + commentsCache[ attachmentId ] = {
1532 + html: comments.innerHTML,
1533 + count: count,
1534 + hasMore: hasMore,
1535 + nextOffset: nextOffset,
1536 + };
1537 +
1248 1538 domUtil.hide( commentsLoading );
1249 1539 };
1250 1540
1251 1541 xhr.onerror = onError;
@@ -1253,52 +1543,165 @@
1253 1543 xhr.send();
1254 1544 }
1255 1545
1256 1546 function loadFullImage( slide ) {
1257 - var el = slide.el;
1258 1547 var attrs = slide.attrs;
1259 - var image = el.querySelector( 'img' );
1548 + var image = slide.el.querySelector( 'img' );
1260 1549
1261 - if ( ! image.hasAttribute( 'data-loaded' ) ) {
1262 - var hasPreview = !! attrs.previewImage;
1263 - var thumbSize = attrs.thumbSize;
1550 + if ( image.hasAttribute( 'data-loaded' ) ) {
1551 + return;
1552 + }
1264 1553
1265 - if ( ! hasPreview || ( thumbSize && el.offsetWidth > thumbSize.width ) ) {
1266 - image.src = attrs.src;
1267 - } else {
1268 - image.src = attrs.previewImage;
1269 - }
1554 + image.setAttribute( 'itemprop', 'image' );
1555 + image.setAttribute( 'data-loaded', 1 );
1270 1556
1271 - image.setAttribute( 'itemprop', 'image' );
1272 - image.setAttribute( 'data-loaded', 1 );
1557 + var hasPreview = attrs.previewImage && attrs.previewImage !== attrs.src;
1558 +
1559 + if ( ! hasPreview ) {
1560 + // No usable in-page thumbnail (e.g. a lazy-loading plugin swapped the
1561 + // gallery src for a placeholder). Load the full-size image straight
1562 + // into the visible element so the slide is never left without a src.
1563 + image.src = attrs.src;
1564 + return;
1273 1565 }
1566 +
1567 + // Show the thumbnail the browser has already decoded for this image in the
1568 + // post itself. Without it the slide stays empty until the full-size image
1569 + // arrives, which reads as a black screen whenever the reader moves through
1570 + // the gallery faster than the images can download.
1571 + image.src = attrs.previewImage;
1572 + // The thumbnail is much smaller than the slide, so soften the upscale
1573 + // until the full-size image replaces it.
1574 + image.style.filter = 'blur(8px)';
1575 +
1576 + // Load the full-size image off-DOM, then swap it in over the preview. On
1577 + // error the (blurred) preview stays put rather than reverting to blank.
1578 + var fullImage = new window.Image();
1579 +
1580 + fullImage.addEventListener(
1581 + 'load',
1582 + function () {
1583 + /*
1584 + If the visitor zoomed while this was still downloading, a larger rendition
1585 + is already in place -- don't downgrade it back to the fit-to-screen src.
1586 + */
1587 + if ( ! image.hasAttribute( 'data-zoom-loaded' ) ) {
1588 + // Cached by this point, so swapping it in is effectively instant.
1589 + image.src = attrs.src;
1590 + }
1591 + image.style.filter = '';
1592 + },
1593 + { once: true }
1594 + );
1595 +
1596 + fullImage.addEventListener(
1597 + 'error',
1598 + function () {
1599 + image.style.filter = '';
1600 + },
1601 + { once: true }
1602 + );
1603 +
1604 + fullImage.src = attrs.src;
1274 1605 }
1275 1606
1276 - function loadBackgroundImage( slide ) {
1277 - var currentSlide = slide.el;
1607 + /**
1608 + * Swap in a higher-resolution rendition now that the visitor is zoomed in.
1609 + *
1610 + * The zoomed-out slide only ever loads what the screen can actually show. The browser
1611 + * keeps painting the image it already has until this one decodes, so the picture
1612 + * sharpens rather than blanking.
1613 + * @param {object} slide - The slide being zoomed.
1614 + */
1615 + function loadZoomImage( slide ) {
1616 + if ( ! slide || ! slide.attrs.zoomSrc || slide.attrs.zoomSrc === slide.attrs.src ) {
1617 + return;
1618 + }
1278 1619
1279 - if ( swiper && swiper.slides ) {
1280 - currentSlide = swiper.slides[ swiper.activeIndex ];
1620 + var image = slide.el.querySelector( 'img' );
1621 + if ( ! image || image.hasAttribute( 'data-zoom-loaded' ) ) {
1622 + return;
1281 1623 }
1282 1624
1625 + image.setAttribute( 'data-zoom-loaded', 1 );
1626 + image.src = slide.attrs.zoomSrc;
1627 + }
1628 +
1629 + function preloadAdjacentImages( currentIndex ) {
1630 + var indicesToPreload = [];
1631 + var totalSlides = carousel.slides.length;
1632 +
1633 + // Only preload adjacent images if we have more than one slide (matching loop condition)
1634 + if ( totalSlides > 1 ) {
1635 + // Previous image (with loop handling)
1636 + var prevIndex = currentIndex > 0 ? currentIndex - 1 : totalSlides - 1;
1637 + indicesToPreload.push( prevIndex );
1638 +
1639 + // Next image (with loop handling)
1640 + var nextIndex = currentIndex < totalSlides - 1 ? currentIndex + 1 : 0;
1641 + indicesToPreload.push( nextIndex );
1642 + }
1643 +
1644 + indicesToPreload.forEach( function ( index ) {
1645 + var slide = carousel.slides[ index ];
1646 + if ( slide ) {
1647 + // Load in background without showing
1648 + loadFullImage( slide );
1649 +
1650 + // Also load background image if enabled
1651 + if (
1652 + Number( jetpackCarouselStrings.display_background_image ) === 1 &&
1653 + ! slide.backgroundImage
1654 + ) {
1655 + loadBackgroundImage( slide );
1656 + }
1657 + }
1658 + } );
1659 + }
1660 +
1661 + function loadBackgroundImage( slide ) {
1283 1662 var image = slide.attrs.originalElement;
1284 - var isLoaded = image.complete && image.naturalHeight !== 0;
1285 1663
1286 - if ( isLoaded ) {
1287 - applyBackgroundImage( slide, currentSlide, image );
1664 + if ( ! image ) {
1288 1665 return;
1289 1666 }
1290 1667
1291 - image.onload = function () {
1292 - applyBackgroundImage( slide, currentSlide, image );
1668 + if ( image.complete && image.naturalHeight !== 0 ) {
1669 + applyBackgroundImage( slide, image );
1670 + return;
1671 + }
1672 +
1673 + // The thumbnail in the post may still be loading, or may be lazy-loaded.
1674 + // Use an event listener rather than `onload`, which would overwrite any
1675 + // handler the page has already attached to its own image. Pair the load
1676 + // handler with an error handler so a thumbnail that never loads doesn't
1677 + // leave a listener (and its reference to the slide) attached for good.
1678 + var onLoad = function () {
1679 + image.removeEventListener( 'error', onError );
1680 + applyBackgroundImage( slide, image );
1293 1681 };
1682 + var onError = function () {
1683 + image.removeEventListener( 'load', onLoad );
1684 + };
1685 + image.addEventListener( 'load', onLoad, { once: true } );
1686 + image.addEventListener( 'error', onError, { once: true } );
1294 1687 }
1295 1688
1296 - function applyBackgroundImage( slide, currentSlide, image ) {
1689 + function applyBackgroundImage( slide, image ) {
1297 1690 var url = util.getBackgroundImage( image );
1691 +
1692 + if ( ! url ) {
1693 + return;
1694 + }
1695 +
1696 + // Always paint onto the slide the image belongs to. Preloading runs this
1697 + // for the neighbouring slides too, so painting onto whichever slide happens
1698 + // to be active would put the wrong image behind it and leave the slide it
1699 + // was meant for with no placeholder at all.
1298 1700 slide.backgroundImage = url;
1299 - currentSlide.style.backgroundImage = 'url(' + url + ')';
1300 - currentSlide.style.backgroundSize = 'cover';
1701 + slide.el.style.backgroundImage = 'url(' + url + ')';
1702 + slide.el.style.backgroundSize = 'cover';
1703 + slide.el.style.backgroundPosition = 'center';
1301 1704 }
1302 1705
1303 1706 function clearCommentTextAreaValue() {
1304 1707 if ( carousel.commentField ) {
@@ -1311,16 +1714,14 @@
1311 1714
1312 1715 if ( size ) {
1313 1716 var parts = size.split( ',' );
1314 1717 return { width: parseInt( parts[ 0 ], 10 ), height: parseInt( parts[ 1 ], 10 ) };
1315 - } else {
1316 - return {
1317 - width:
1318 - el.getAttribute( 'data-original-width' ) || el.getAttribute( 'width' ) || undefined,
1319 - height:
1320 - el.getAttribute( 'data-original-height' ) || el.getAttribute( 'height' ) || undefined,
1321 - };
1322 1718 }
1719 + return {
1720 + width: el.getAttribute( 'data-original-width' ) || el.getAttribute( 'width' ) || undefined,
1721 + height:
1722 + el.getAttribute( 'data-original-height' ) || el.getAttribute( 'height' ) || undefined,
1723 + };
1323 1724 }
1324 1725
1325 1726 function initCarouselSlides( items, startIndex ) {
1326 1727 carousel.slides = [];
@@ -1327,15 +1728,13 @@
1327 1728
1328 1729 var max = calculateMaxSlideDimensions();
1329 1730
1330 1731 // If the startIndex is not 0 then preload the clicked image first.
1331 - if ( startIndex !== 0 ) {
1732 + if ( startIndex !== 0 && items[ startIndex ].getAttribute( 'data-gallery-src' ) !== null ) {
1332 1733 var img = new Image();
1333 1734 img.src = items[ startIndex ].getAttribute( 'data-gallery-src' );
1334 1735 }
1335 1736
1336 - var useInPageThumbnails = !! domUtil.closest( items[ 0 ], '.tiled-gallery.type-rectangular' );
1337 -
1338 1737 // create the 'slide'
1339 1738 Array.prototype.forEach.call( items, function ( item, i ) {
1340 1739 var permalinkEl = domUtil.closest( item, 'a' );
1341 1740 var origFile = item.getAttribute( 'data-orig-file' ) || item.getAttribute( 'src-orig' );
@@ -1354,12 +1753,12 @@
1354 1753 var attrs = {
1355 1754 originalElement: item,
1356 1755 attachmentId: attrID,
1357 1756 commentsOpened: item.getAttribute( 'data-comments-opened' ) || '0',
1757 + commentsCount: item.getAttribute( 'data-comments-count' ) || '0',
1358 1758 imageMeta: domUtil.getJSONAttribute( item, 'data-image-meta' ) || {},
1359 1759 title: item.getAttribute( 'data-image-title' ) || '',
1360 1760 desc: item.getAttribute( 'data-image-description' ) || '',
1361 - mediumFile: item.getAttribute( 'data-medium-file' ) || '',
1362 1761 largeFile: item.getAttribute( 'data-large-file' ) || '',
1363 1762 origFile: origFile || '',
1364 1763 thumbSize: { width: item.naturalWidth, height: item.naturalHeight },
1365 1764 caption: caption || '',
@@ -1382,17 +1781,28 @@
1382 1781
1383 1782 if ( typeof wpcom !== 'undefined' && wpcom.carousel && wpcom.carousel.generateImgSrc ) {
1384 1783 attrs.src = wpcom.carousel.generateImgSrc( item, max );
1385 1784 } else {
1386 - attrs.src = selectBestImageUrl( {
1387 - origFile: attrs.src,
1388 - origWidth: attrs.origWidth,
1389 - origHeight: attrs.origHeight,
1390 - maxWidth: max.width,
1391 - maxHeight: max.height,
1392 - mediumFile: attrs.mediumFile,
1393 - largeFile: attrs.largeFile,
1394 - } );
1785 + var urlArgs = function ( zoomHeadroom ) {
1786 + // A fresh object each time: selectBestImageUrl() mutates what it is given.
1787 + return {
1788 + origFile: attrs.src,
1789 + origWidth: attrs.origWidth,
1790 + origHeight: attrs.origHeight,
1791 + maxWidth: max.width,
1792 + maxHeight: max.height,
1793 + largeFile: attrs.largeFile,
1794 + zoomHeadroom: zoomHeadroom,
1795 + };
1796 + };
1797 +
1798 + /*
1799 + The zoom rendition is kept aside until the visitor actually zooms. On sources
1800 + that cannot serve a larger one it comes back identical to `src`, and nothing
1801 + ever happens.
1802 + */
1803 + attrs.zoomSrc = selectBestImageUrl( urlArgs( 2 ) );
1804 + attrs.src = selectBestImageUrl( urlArgs( 1 ) );
1395 1805 }
1396 1806
1397 1807 // Set the final src.
1398 1808 item.setAttribute( 'data-gallery-src', attrs.src );
@@ -1404,9 +1814,8 @@
1404 1814
1405 1815 // Initially, the image is a 1x1 transparent gif.
1406 1816 // The preview is shown as a background image on the slide itself.
1407 1817 var image = new Image();
1408 - image.src = attrs.src;
1409 1818
1410 1819 var slideEl = document.createElement( 'div' );
1411 1820 slideEl.classList.add( 'swiper-slide' );
1412 1821 slideEl.setAttribute( 'itemprop', 'associatedMedia' );
@@ -1422,12 +1831,12 @@
1422 1831 slideEl.setAttribute( 'data-attachment-id', attrs.attachmentId );
1423 1832 slideEl.setAttribute( 'data-permalink', attrs.permalink );
1424 1833 slideEl.setAttribute( 'data-orig-file', attrs.origFile );
1425 1834
1426 - if ( useInPageThumbnails ) {
1427 - // Use the image already loaded in the gallery as a preview.
1428 - attrs.previewImage = attrs.src;
1429 - }
1835 + // Reuse the thumbnail the browser has already decoded for this image
1836 + // in the post. It costs nothing to display and gives the slide
1837 + // something to show while the full-size image is downloading.
1838 + attrs.previewImage = item.currentSrc || item.getAttribute( 'src' ) || '';
1430 1839
1431 1840 var slide = { el: slideEl, attrs: attrs, index: i };
1432 1841 carousel.slides.push( slide );
1433 1842 }
@@ -1434,9 +1843,9 @@
1434 1843 } );
1435 1844 }
1436 1845
1437 1846 function loadSwiper( gallery, options ) {
1438 - if ( ! window.Swiper670 ) {
1847 + if ( ! window.JetpackSwiper ) {
1439 1848 var loader = document.querySelector( '#jp-carousel-loading-overlay' );
1440 1849 domUtil.show( loader );
1441 1850 var jsScript = document.createElement( 'script' );
1442 1851 jsScript.id = 'jetpack-carousel-swiper-js';
@@ -1468,8 +1877,13 @@
1468 1877 if ( ! data ) {
1469 1878 return; // don't run if the default gallery functions weren't used
1470 1879 }
1471 1880
1881 + const images = gallery.querySelectorAll( settings.imgSelector );
1882 + if ( ! images.length ) {
1883 + return; // don't run if we found no images in the gallery (somehow it has images that aren't in the media library?)
1884 + }
1885 +
1472 1886 initializeCarousel();
1473 1887
1474 1888 if ( carousel.isOpen ) {
1475 1889 return; // don't open if already opened
@@ -1499,17 +1913,24 @@
1499 1913 }
1500 1914
1501 1915 domUtil.emitEvent( carousel.overlay, 'jp_carousel.beforeOpen' );
1502 1916 carousel.gallery.innerHTML = '';
1917 + commentsCache = {};
1918 + commentsFetching = {};
1503 1919
1504 1920 // Need to set the overlay manually to block or swiper does't initialise properly.
1505 1921 carousel.overlay.style.opacity = 1;
1506 1922 carousel.overlay.style.display = 'block';
1507 1923
1508 - initCarouselSlides( gallery.querySelectorAll( settings.imgSelector ), settings.startIndex );
1924 + initCarouselSlides( images, settings.startIndex );
1509 1925
1510 - swiper = new window.Swiper670( '.jp-carousel-swiper-container', {
1926 + swiper = new window.JetpackSwiper( '.jp-carousel-swiper-container', {
1511 1927 centeredSlides: true,
1928 + /*
1929 + Swiper's 300ms default is most of what makes slide-to-slide feel slow. Keep a
1930 + visible movement -- the slide is a real affordance on touch -- but a shorter one.
1931 + */
1932 + speed: domUtil.prefersReducedMotion() ? 0 : 250,
1512 1933 zoom: true,
1513 1934 loop: carousel.slides.length > 1,
1514 1935 // Turn off interactions and hide navigation arrows if there is only one slide.
1515 1936 enabled: carousel.slides.length > 1,
@@ -1533,26 +1954,18 @@
1533 1954 threshold: 5,
1534 1955 } );
1535 1956
1536 1957 swiper.on( 'slideChange', function ( swiper ) {
1537 - var index;
1538 - // Swiper indexes slides from 1, plus when looping to left last slide ends up
1539 - // as 0 and looping to right first slide as total slides + 1. These are adjusted
1540 - // here to match index of carousel.slides.
1541 - if ( swiper.activeIndex === 0 ) {
1542 - index = carousel.slides.length - 1;
1543 - } else if ( swiper.activeIndex === carousel.slides.length + 1 ) {
1544 - index = 0;
1545 - } else {
1546 - index = swiper.activeIndex - 1;
1958 + if ( ! carousel.isOpen ) {
1959 + return;
1547 1960 }
1548 - selectSlideAtIndex( index );
1549 -
1961 + selectSlideAtIndex( swiper.realIndex );
1550 1962 carousel.overlay.classList.remove( 'jp-carousel-hide-controls' );
1551 1963 } );
1552 1964
1553 1965 swiper.on( 'zoomChange', function ( swiper, scale ) {
1554 1966 if ( scale > 1 ) {
1967 + loadZoomImage( carousel.currentSlide );
1555 1968 carousel.overlay.classList.add( 'jp-carousel-hide-controls' );
1556 1969 }
1557 1970
1558 1971 if ( scale === 1 ) {
@@ -1582,10 +1995,86 @@
1582 1995 domUtil.emitEvent( carousel.overlay, 'jp_carousel.afterOpen' );
1583 1996 } );
1584 1997 }
1585 1998
1586 - // Register the event listener for starting the gallery
1587 - document.body.addEventListener( 'click', function ( e ) {
1999 + // Register the event listeners for starting the gallery
2000 + document.body.addEventListener( 'click', handleInteraction );
2001 + document.body.addEventListener( 'keydown', handleInteraction );
2002 + document.querySelectorAll( galleryItemSelector + 'img' ).forEach( function ( galleryImage ) {
2003 + if ( shouldOpenModal( galleryImage ) ) {
2004 + makeGalleryImageAccessible( galleryImage );
2005 + }
2006 + } );
2007 +
2008 + function handleInteraction( e ) {
2009 + if ( e.type === 'click' ) {
2010 + handleClick( e );
2011 + return;
2012 + }
2013 +
2014 + if ( e.type === 'keydown' ) {
2015 + const parentElement = document.activeElement.parentElement;
2016 + const isParentCarouselContainer =
2017 + parentElement && parentElement.classList.contains( 'tiled-gallery__item' );
2018 +
2019 + if ( ( e.key === ' ' || e.key === 'Enter' ) && isParentCarouselContainer ) {
2020 + handleClick( e );
2021 + }
2022 + }
2023 + }
2024 +
2025 + function normalizeUrl( url ) {
2026 + return ( url || '' ).split( '?' )[ 0 ].replace( /\/$/, '' );
2027 + }
2028 +
2029 + function shouldOpenModal( el ) {
2030 + if ( el.tagName === 'A' ) {
2031 + el = el.querySelector( 'img' ) || el;
2032 + }
2033 +
2034 + var parent = el.parentElement;
2035 + var grandparent = parent ? parent.parentElement : null;
2036 +
2037 + // If Gallery is made up of individual Image blocks check for custom link before
2038 + // loading carousel. The custom link may be the parent or could be a descendant
2039 + // of the parent if the image has rounded corners.
2040 + var parentHref = null;
2041 + if ( grandparent && grandparent.classList.contains( 'wp-block-image' ) ) {
2042 + parentHref = parent.getAttribute( 'href' );
2043 + } else if (
2044 + parent &&
2045 + parent.classList.contains( 'wp-block-image' ) &&
2046 + parent.querySelector( ':scope > a' )
2047 + ) {
2048 + parentHref = parent.querySelector( ':scope > a' ).getAttribute( 'href' );
2049 + }
2050 +
2051 + // If the link does not point to the attachment or media file then assume Image has
2052 + // a custom link so don't load the carousel.
2053 + if ( parentHref ) {
2054 + var cleanHref = normalizeUrl( parentHref );
2055 + var cleanOrig = normalizeUrl( el.getAttribute( 'data-orig-file' ) );
2056 + var cleanPerm = normalizeUrl( el.getAttribute( 'data-permalink' ) );
2057 +
2058 + if ( cleanHref !== cleanOrig && cleanHref !== cleanPerm ) {
2059 + return false;
2060 + }
2061 + }
2062 +
2063 + // Do not open the modal if we are looking at a gallery caption from before WP5, which may contain a link.
2064 + if ( parent && parent.classList.contains( 'gallery-caption' ) ) {
2065 + return false;
2066 + }
2067 +
2068 + // Do not open the modal if we are looking at a caption of a gallery block, which may contain a link.
2069 + if ( domUtil.matches( parent, 'figcaption' ) ) {
2070 + return false;
2071 + }
2072 +
2073 + return true;
2074 + }
2075 +
2076 + function handleClick( e ) {
1588 2077 var isCompatible =
1589 2078 window.CSS && window.CSS.supports && window.CSS.supports( 'display', 'grid' );
1590 2079
1591 2080 // IE11 support is being dropped in August 2021. The new swiper.js libray is not IE11 compat
@@ -1601,46 +2090,12 @@
1601 2090 if ( ! testForData( gallery ) ) {
1602 2091 return;
1603 2092 }
1604 2093
1605 - var parent = target.parentElement;
1606 - var grandparent = parent.parentElement;
1607 -
1608 - // If Gallery is made up of individual Image blocks check for custom link before
1609 - // loading carousel. The custom link may be the parent or could be a descendant
1610 - // of the parent if the image has rounded corners.
1611 - var parentHref = null;
1612 - if ( grandparent && grandparent.classList.contains( 'wp-block-image' ) ) {
1613 - parentHref = parent.getAttribute( 'href' );
1614 - } else if (
1615 - parent &&
1616 - parent.classList.contains( 'wp-block-image' ) &&
1617 - parent.querySelector( ':scope > a' )
1618 - ) {
1619 - parentHref = parent.querySelector( ':scope > a' ).getAttribute( 'href' );
1620 - }
1621 -
1622 - // If the link does not point to the attachment or media file then assume Image has
1623 - // a custom link so don't load the carousel.
1624 - if (
1625 - parentHref &&
1626 - parentHref.split( '?' )[ 0 ] !==
1627 - target.getAttribute( 'data-orig-file' ).split( '?' )[ 0 ] &&
1628 - parentHref !== target.getAttribute( 'data-permalink' )
1629 - ) {
2094 + if ( ! shouldOpenModal( target ) ) {
1630 2095 return;
1631 2096 }
1632 2097
1633 - // Do not open the modal if we are looking at a gallery caption from before WP5, which may contain a link.
1634 - if ( parent.classList.contains( 'gallery-caption' ) ) {
1635 - return;
1636 - }
1637 -
1638 - // Do not open the modal if we are looking at a caption of a gallery block, which may contain a link.
1639 - if ( domUtil.matches( parent, 'figcaption' ) ) {
1640 - return;
1641 - }
1642 -
1643 2098 // Set height to auto.
1644 2099 // Fix some themes where closing carousel brings view back to top.
1645 2100 document.documentElement.style.height = 'auto';
1646 2101
@@ -1653,9 +2108,9 @@
1653 2108 var item = domUtil.closest( target, itemSelector );
1654 2109 var index = Array.prototype.indexOf.call( gallery.querySelectorAll( itemSelector ), item );
1655 2110 loadSwiper( gallery, { startIndex: index } );
1656 2111 }
1657 - } );
2112 + }
1658 2113
1659 2114 // Handle lightbox (single image gallery) for images linking to 'Attachment Page'.
1660 2115 if ( Number( jetpackCarouselStrings.single_image_gallery ) === 1 ) {
1661 2116 processSingleImageGallery();