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 +632 -192 13.6.2 → 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,40 +107,49 @@
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 131 el.style.pointerEvents = 'none';
120 132
121 - var animate = function ( t0, duration ) {
122 - var t = performance.now();
123 - var diff = t - t0;
124 - var ratio = diff / duration;
133 + // Commit the starting opacity, otherwise the browser has nothing to animate from.
134 + void el.offsetWidth;
125 135
126 - if ( ratio < 1 ) {
127 - el.style.opacity = start + ( end - start ) * ratio;
128 - requestAnimationFrame( () => animate( t0, duration ) );
129 - } else {
130 - el.style.opacity = end;
131 - el.style.removeProperty( 'pointer-events' );
132 - callback();
133 - }
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;
134 151 };
135 -
136 - requestAnimationFrame( function () {
137 - // Double rAF for browser compatibility.
138 - requestAnimationFrame( function () {
139 - animate( performance.now(), 200 );
140 - } );
141 - } );
142 152 }
143 153
144 154 function fadeIn( el, callback ) {
145 155 callback = callback || util.noop;
@@ -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 }
@@ -770,10 +836,14 @@
770 836
771 837 var current = carousel.currentSlide;
772 838 var attachmentId = current.attrs.attachmentId;
773 839
840 + // Load current image immediately
774 841 loadFullImage( carousel.slides[ index ] );
775 842
843 + // Preload adjacent images in background
844 + preloadAdjacentImages( index );
845 +
776 846 if (
777 847 Number( jetpackCarouselStrings.display_background_image ) === 1 &&
778 848 ! carousel.slides[ index ].backgroundImage
779 849 ) {
@@ -792,9 +862,9 @@
792 862 updateFullSizeLink( current );
793 863
794 864 if ( Number( jetpackCarouselStrings.display_comments ) === 1 ) {
795 865 testCommentsOpened( carousel.slides[ index ].attrs.commentsOpened );
796 - fetchComments( attachmentId );
866 + showCommentsForSlide( current );
797 867 domUtil.hide( carousel.info.querySelector( '#jp-carousel-comment-post-results' ) );
798 868 }
799 869
800 870 // Update pagination in footer.
@@ -804,9 +874,9 @@
804 874 pagination.innerHTML = '<span>' + currentPage + ' / ' + carousel.slides.length + '</span>';
805 875 }
806 876
807 877 // Record pageview in WP Stats, for each new image loaded full-screen.
808 - if ( jetpackCarouselStrings.stats ) {
878 + if ( jetpackCarouselStrings.stats && carousel.isOpen ) {
809 879 new Image().src =
810 880 document.location.protocol +
811 881 '//pixel.wp.com/g.gif?' +
812 882 jetpackCarouselStrings.stats +
@@ -815,11 +885,14 @@
815 885 '&rand=' +
816 886 Math.random();
817 887 }
818 888
819 - pageview( attachmentId );
889 + if ( carousel.isOpen ) {
890 + pageview( attachmentId );
891 + }
820 892
821 - window.location.hash = lastKnownLocationHash = '#jp-carousel-' + attachmentId;
893 + lastKnownLocationHash = '#jp-carousel-' + attachmentId;
894 + window.location.hash = lastKnownLocationHash;
822 895 }
823 896
824 897 function restoreScroll() {
825 898 window.scrollTo( window.scrollX || window.pageXOffset || 0, scrollPos || 0 );
@@ -834,12 +907,14 @@
834 907 disableKeyboardNavigation();
835 908
836 909 domUtil.emitEvent( carousel.overlay, 'jp_carousel.beforeClose' );
837 910 restoreScroll();
911 + carousel.isOpen = false;
838 912 swiper.destroy();
839 - carousel.isOpen = false;
840 913 // Clear slide data for DOM garbage collection.
841 914 carousel.slides = [];
915 + commentsCache = {};
916 + commentsFetching = {};
842 917 carousel.currentSlide = undefined;
843 918 carousel.gallery.innerHTML = '';
844 919
845 920 domUtil.fadeOut( carousel.overlay, function () {
@@ -853,8 +928,43 @@
853 928 height: window.innerHeight - 64, //subtract height of bottom info bar,
854 929 };
855 930 }
856 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 +
857 967 function selectBestImageUrl( args ) {
858 968 if ( typeof args !== 'object' ) {
859 969 args = {};
860 970 }
@@ -866,9 +976,14 @@
866 976 if ( typeof args.origWidth === 'undefined' || typeof args.maxWidth === 'undefined' ) {
867 977 return args.origFile;
868 978 }
869 979
870 - 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 ) {
871 986 return args.origFile;
872 987 }
873 988
874 989 // Check if the image is being served by Photon (using a regular expression on the hostname).
@@ -877,15 +992,12 @@
877 992 imageLinkParser.href = args.largeFile;
878 993
879 994 var isPhotonUrl = /^i[0-2]\.wp\.com$/i.test( imageLinkParser.hostname );
880 995
881 - var mediumSizeParts = getImageSizeParts( args.mediumFile, args.origWidth, isPhotonUrl );
882 996 var largeSizeParts = getImageSizeParts( args.largeFile, args.origWidth, isPhotonUrl );
883 997
884 998 var largeWidth = parseInt( largeSizeParts[ 0 ], 10 );
885 999 var largeHeight = parseInt( largeSizeParts[ 1 ], 10 );
886 - var mediumWidth = parseInt( mediumSizeParts[ 0 ], 10 );
887 - var mediumHeight = parseInt( mediumSizeParts[ 1 ], 10 );
888 1000
889 1001 args.origMaxWidth = args.maxWidth;
890 1002 args.origMaxHeight = args.maxHeight;
891 1003
@@ -894,33 +1006,46 @@
894 1006 args.maxWidth = args.maxWidth * window.devicePixelRatio;
895 1007 args.maxHeight = args.maxHeight * window.devicePixelRatio;
896 1008 }
897 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 +
898 1023 if ( largeWidth >= args.maxWidth || largeHeight >= args.maxHeight ) {
899 1024 return args.largeFile;
900 1025 }
901 1026
902 - if ( mediumWidth >= args.maxWidth || mediumHeight >= args.maxHeight ) {
903 - return args.mediumFile;
904 - }
905 -
906 1027 if ( isPhotonUrl ) {
907 1028 // args.origFile doesn't point to a Photon url, so in this case we use args.largeFile
908 1029 // to return the photon url of the original image.
909 - var largeFileIndex = args.largeFile.lastIndexOf( '?' );
910 - var origPhotonUrl = args.largeFile;
911 - if ( largeFileIndex !== -1 ) {
912 - origPhotonUrl = args.largeFile.substring( 0, largeFileIndex );
913 - // If we have a really large image load a smaller version
914 - // that is closer to the viewable size
915 - if ( args.origWidth > args.maxWidth || args.origHeight > args.maxHeight ) {
916 - // @2x the max sizes so we get a high enough resolution for zooming.
917 - args.origMaxWidth = args.maxWidth * 2;
918 - args.origMaxHeight = args.maxHeight * 2;
919 - origPhotonUrl += '?fit=' + args.origMaxWidth + '%2C' + args.origMaxHeight;
920 - }
1030 + if ( args.largeFile.lastIndexOf( '?' ) === -1 ) {
1031 + return args.largeFile;
921 1032 }
922 - 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();
923 1048 }
924 1049
925 1050 return args.origFile;
926 1051 }
@@ -929,14 +1054,14 @@
929 1054 var size = isPhotonUrl
930 1055 ? file.replace( /.*=([\d]+%2C[\d]+).*$/, '$1' )
931 1056 : file.replace( /.*-([\d]+x[\d]+)\..+$/, '$1' );
932 1057
933 - var sizeParts =
934 - size !== file
935 - ? isPhotonUrl
936 - ? size.split( '%2C' )
937 - : size.split( 'x' )
938 - : [ 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 + }
939 1064
940 1065 // If one of the dimensions is set to 9999, then the actual value of that dimension can't be retrieved from the url.
941 1066 // In that case, we set the value to 0.
942 1067 if ( sizeParts[ 0 ] === '9999' ) {
@@ -980,11 +1105,11 @@
980 1105 return value;
981 1106 }
982 1107
983 1108 function updateTitleCaptionAndDesc( data ) {
984 - var caption = '';
985 - var title = '';
986 - var desc = '';
1109 + var caption;
1110 + var title;
1111 + var desc;
987 1112 var captionMainElement;
988 1113 var captionInfoExtraElement;
989 1114 var titleElement;
990 1115 var descriptionElement;
@@ -1029,9 +1154,9 @@
1029 1154 descriptionElement.innerHTML = desc;
1030 1155 domUtil.show( descriptionElement );
1031 1156
1032 1157 if ( ! title && ! caption ) {
1033 - captionMainElement.innerHTML = domUtil.stripHTML( desc );
1158 + captionMainElement.textContent = domUtil.stripHTML( desc );
1034 1159 domUtil.show( captionMainElement );
1035 1160 }
1036 1161 }
1037 1162
@@ -1036,13 +1161,13 @@
1036 1161 }
1037 1162
1038 1163 if ( title ) {
1039 1164 var plainTitle = domUtil.stripHTML( title );
1040 - titleElement.innerHTML = plainTitle;
1165 + titleElement.textContent = plainTitle;
1041 1166
1042 1167 if ( ! caption ) {
1043 - captionMainElement.innerHTML = plainTitle;
1044 - captionInfoExtraElement.innerHTML = plainTitle;
1168 + captionMainElement.textContent = plainTitle;
1169 + captionInfoExtraElement.textContent = plainTitle;
1045 1170
1046 1171 domUtil.show( captionMainElement );
1047 1172 }
1048 1173
@@ -1056,9 +1181,14 @@
1056 1181 if ( ! meta || Number( jetpackCarouselStrings.display_exif ) !== 1 ) {
1057 1182 return false;
1058 1183 }
1059 1184
1060 - 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 +
1061 1191 var html = '';
1062 1192
1063 1193 for ( var key in meta ) {
1064 1194 var val = meta[ key ];
@@ -1082,10 +1212,31 @@
1082 1212
1083 1213 html += '<li><h5>' + jetpackCarouselStrings[ key ] + '</h5>' + val + '</li>';
1084 1214 }
1085 1215
1086 - ul.innerHTML = html;
1087 - 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 + }
1088 1239 }
1089 1240
1090 1241 // Update the contents of the jp-carousel-image-download link
1091 1242 function updateFullSizeLink( currentSlide ) {
@@ -1116,9 +1267,9 @@
1116 1267 permalink.style.removeProperty( 'display' );
1117 1268 }
1118 1269
1119 1270 function testCommentsOpened( opened ) {
1120 - var commentForm = carousel.container.querySelector( '.jp-carousel-comment-form-container' );
1271 + var commentForm = carousel.info.querySelector( '#jp-carousel-comment-form-container' );
1121 1272 var isOpened = parseInt( opened, 10 ) === 1;
1122 1273
1123 1274 if ( isOpened ) {
1124 1275 domUtil.fadeIn( commentForm );
@@ -1126,22 +1277,111 @@
1126 1277 domUtil.fadeOut( commentForm );
1127 1278 }
1128 1279 }
1129 1280
1130 - function fetchComments( attachmentId, offset ) {
1131 - var shouldClear = offset === undefined;
1132 - 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(
1133 1288 '.jp-carousel-icon-comments .jp-carousel-has-comments-indicator'
1134 1289 );
1135 1290
1136 - commentsIndicator.classList.remove( 'jp-carousel-show' );
1291 + if ( ! indicator ) {
1292 + return;
1293 + }
1137 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 +
1138 1314 clearInterval( commentInterval );
1315 + domUtil.hide( carousel.info.querySelector( '#jp-carousel-comments-loading' ) );
1139 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 +
1140 1362 if ( ! attachmentId ) {
1141 1363 return;
1142 1364 }
1143 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 +
1144 1384 if ( ! offset || offset < 1 ) {
1145 1385 offset = 0;
1146 1386 }
1147 1387
@@ -1154,8 +1394,16 @@
1154 1394 comments.innerHTML = '';
1155 1395 }
1156 1396
1157 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 +
1158 1406 var url =
1159 1407 jetpackCarouselStrings.ajaxurl +
1160 1408 '?action=get_attachment_comments' +
1161 1409 '&nonce=' +
@@ -1166,14 +1414,40 @@
1166 1414 offset;
1167 1415 xhr.open( 'GET', url );
1168 1416 xhr.setRequestHeader( 'X-Requested-With', 'XMLHttpRequest' );
1169 1417
1170 - var onError = function () {
1418 + // Reveal the comments and clear the loading state; callers gate this themselves.
1419 + var revealComments = function () {
1171 1420 domUtil.fadeIn( comments );
1172 1421 domUtil.fadeOut( commentsLoading );
1173 1422 };
1174 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 +
1175 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 +
1176 1450 // Ignore the results if they arrive late and we're now on a different slide.
1177 1451 if (
1178 1452 ! carousel.currentSlide ||
1179 1453 carousel.currentSlide.attrs.attachmentId !== attachmentId
@@ -1184,14 +1458,16 @@
1184 1458 var isSuccess = xhr.status >= 200 && xhr.status < 300;
1185 1459 var data;
1186 1460 try {
1187 1461 data = JSON.parse( xhr.responseText );
1188 - } catch ( e ) {
1462 + } catch {
1189 1463 // Do nothing.
1190 1464 }
1191 1465
1192 1466 if ( ! isSuccess || ! data || ! Array.isArray( data ) ) {
1193 - return onError();
1467 + // Already past the identity/current-slide guards above.
1468 + revealComments();
1469 + return;
1194 1470 }
1195 1471
1196 1472 if ( shouldClear ) {
1197 1473 comments.innerHTML = '';
@@ -1198,8 +1474,13 @@
1198 1474 }
1199 1475
1200 1476 for ( var i = 0; i < data.length; i++ ) {
1201 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 + }
1202 1483 var comment = document.createElement( 'div' );
1203 1484 comment.classList.add( 'jp-carousel-comment' );
1204 1485 comment.setAttribute( 'id', 'jp-carousel-comment-' + entry.id );
1205 1486 comment.innerHTML =
@@ -1215,25 +1496,46 @@
1215 1496 '</div>' +
1216 1497 entry.content +
1217 1498 '</div>';
1218 1499 comments.appendChild( comment );
1500 + }
1219 1501
1220 - // Set the interval to check for a new page of comments.
1221 - clearInterval( commentInterval );
1222 - commentInterval = setInterval( function () {
1223 - if ( carousel.container.scrollTop + 150 > window.innerHeight ) {
1224 - fetchComments( attachmentId, offset + 10 );
1225 - clearInterval( commentInterval );
1226 - }
1227 - }, 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 );
1228 1510 }
1229 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 +
1230 1522 if ( data.length > 0 ) {
1231 1523 domUtil.show( comments );
1232 - commentsIndicator.innerText = data.length;
1233 - commentsIndicator.classList.add( 'jp-carousel-show' );
1524 + updateCommentsIndicator( count );
1234 1525 }
1235 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 +
1236 1538 domUtil.hide( commentsLoading );
1237 1539 };
1238 1540
1239 1541 xhr.onerror = onError;
@@ -1241,52 +1543,165 @@
1241 1543 xhr.send();
1242 1544 }
1243 1545
1244 1546 function loadFullImage( slide ) {
1245 - var el = slide.el;
1246 1547 var attrs = slide.attrs;
1247 - var image = el.querySelector( 'img' );
1548 + var image = slide.el.querySelector( 'img' );
1248 1549
1249 - if ( ! image.hasAttribute( 'data-loaded' ) ) {
1250 - var hasPreview = !! attrs.previewImage;
1251 - var thumbSize = attrs.thumbSize;
1550 + if ( image.hasAttribute( 'data-loaded' ) ) {
1551 + return;
1552 + }
1252 1553
1253 - if ( ! hasPreview || ( thumbSize && el.offsetWidth > thumbSize.width ) ) {
1254 - image.src = attrs.src;
1255 - } else {
1256 - image.src = attrs.previewImage;
1257 - }
1554 + image.setAttribute( 'itemprop', 'image' );
1555 + image.setAttribute( 'data-loaded', 1 );
1258 1556
1259 - image.setAttribute( 'itemprop', 'image' );
1260 - 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;
1261 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;
1262 1605 }
1263 1606
1264 - function loadBackgroundImage( slide ) {
1265 - 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 + }
1266 1619
1267 - if ( swiper && swiper.slides ) {
1268 - currentSlide = swiper.slides[ swiper.activeIndex ];
1620 + var image = slide.el.querySelector( 'img' );
1621 + if ( ! image || image.hasAttribute( 'data-zoom-loaded' ) ) {
1622 + return;
1269 1623 }
1270 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 ) {
1271 1662 var image = slide.attrs.originalElement;
1272 - var isLoaded = image.complete && image.naturalHeight !== 0;
1273 1663
1274 - if ( isLoaded ) {
1275 - applyBackgroundImage( slide, currentSlide, image );
1664 + if ( ! image ) {
1276 1665 return;
1277 1666 }
1278 1667
1279 - image.onload = function () {
1280 - 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 );
1281 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 } );
1282 1687 }
1283 1688
1284 - function applyBackgroundImage( slide, currentSlide, image ) {
1689 + function applyBackgroundImage( slide, image ) {
1285 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.
1286 1700 slide.backgroundImage = url;
1287 - currentSlide.style.backgroundImage = 'url(' + url + ')';
1288 - currentSlide.style.backgroundSize = 'cover';
1701 + slide.el.style.backgroundImage = 'url(' + url + ')';
1702 + slide.el.style.backgroundSize = 'cover';
1703 + slide.el.style.backgroundPosition = 'center';
1289 1704 }
1290 1705
1291 1706 function clearCommentTextAreaValue() {
1292 1707 if ( carousel.commentField ) {
@@ -1299,16 +1714,14 @@
1299 1714
1300 1715 if ( size ) {
1301 1716 var parts = size.split( ',' );
1302 1717 return { width: parseInt( parts[ 0 ], 10 ), height: parseInt( parts[ 1 ], 10 ) };
1303 - } else {
1304 - return {
1305 - width:
1306 - el.getAttribute( 'data-original-width' ) || el.getAttribute( 'width' ) || undefined,
1307 - height:
1308 - el.getAttribute( 'data-original-height' ) || el.getAttribute( 'height' ) || undefined,
1309 - };
1310 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 + };
1311 1724 }
1312 1725
1313 1726 function initCarouselSlides( items, startIndex ) {
1314 1727 carousel.slides = [];
@@ -1315,15 +1728,13 @@
1315 1728
1316 1729 var max = calculateMaxSlideDimensions();
1317 1730
1318 1731 // If the startIndex is not 0 then preload the clicked image first.
1319 - if ( startIndex !== 0 ) {
1732 + if ( startIndex !== 0 && items[ startIndex ].getAttribute( 'data-gallery-src' ) !== null ) {
1320 1733 var img = new Image();
1321 1734 img.src = items[ startIndex ].getAttribute( 'data-gallery-src' );
1322 1735 }
1323 1736
1324 - var useInPageThumbnails = !! domUtil.closest( items[ 0 ], '.tiled-gallery.type-rectangular' );
1325 -
1326 1737 // create the 'slide'
1327 1738 Array.prototype.forEach.call( items, function ( item, i ) {
1328 1739 var permalinkEl = domUtil.closest( item, 'a' );
1329 1740 var origFile = item.getAttribute( 'data-orig-file' ) || item.getAttribute( 'src-orig' );
@@ -1342,12 +1753,12 @@
1342 1753 var attrs = {
1343 1754 originalElement: item,
1344 1755 attachmentId: attrID,
1345 1756 commentsOpened: item.getAttribute( 'data-comments-opened' ) || '0',
1757 + commentsCount: item.getAttribute( 'data-comments-count' ) || '0',
1346 1758 imageMeta: domUtil.getJSONAttribute( item, 'data-image-meta' ) || {},
1347 1759 title: item.getAttribute( 'data-image-title' ) || '',
1348 1760 desc: item.getAttribute( 'data-image-description' ) || '',
1349 - mediumFile: item.getAttribute( 'data-medium-file' ) || '',
1350 1761 largeFile: item.getAttribute( 'data-large-file' ) || '',
1351 1762 origFile: origFile || '',
1352 1763 thumbSize: { width: item.naturalWidth, height: item.naturalHeight },
1353 1764 caption: caption || '',
@@ -1370,17 +1781,28 @@
1370 1781
1371 1782 if ( typeof wpcom !== 'undefined' && wpcom.carousel && wpcom.carousel.generateImgSrc ) {
1372 1783 attrs.src = wpcom.carousel.generateImgSrc( item, max );
1373 1784 } else {
1374 - attrs.src = selectBestImageUrl( {
1375 - origFile: attrs.src,
1376 - origWidth: attrs.origWidth,
1377 - origHeight: attrs.origHeight,
1378 - maxWidth: max.width,
1379 - maxHeight: max.height,
1380 - mediumFile: attrs.mediumFile,
1381 - largeFile: attrs.largeFile,
1382 - } );
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 ) );
1383 1805 }
1384 1806
1385 1807 // Set the final src.
1386 1808 item.setAttribute( 'data-gallery-src', attrs.src );
@@ -1392,9 +1814,8 @@
1392 1814
1393 1815 // Initially, the image is a 1x1 transparent gif.
1394 1816 // The preview is shown as a background image on the slide itself.
1395 1817 var image = new Image();
1396 - image.src = attrs.src;
1397 1818
1398 1819 var slideEl = document.createElement( 'div' );
1399 1820 slideEl.classList.add( 'swiper-slide' );
1400 1821 slideEl.setAttribute( 'itemprop', 'associatedMedia' );
@@ -1410,12 +1831,12 @@
1410 1831 slideEl.setAttribute( 'data-attachment-id', attrs.attachmentId );
1411 1832 slideEl.setAttribute( 'data-permalink', attrs.permalink );
1412 1833 slideEl.setAttribute( 'data-orig-file', attrs.origFile );
1413 1834
1414 - if ( useInPageThumbnails ) {
1415 - // Use the image already loaded in the gallery as a preview.
1416 - attrs.previewImage = attrs.src;
1417 - }
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' ) || '';
1418 1839
1419 1840 var slide = { el: slideEl, attrs: attrs, index: i };
1420 1841 carousel.slides.push( slide );
1421 1842 }
@@ -1422,9 +1843,9 @@
1422 1843 } );
1423 1844 }
1424 1845
1425 1846 function loadSwiper( gallery, options ) {
1426 - if ( ! window.Swiper670 ) {
1847 + if ( ! window.JetpackSwiper ) {
1427 1848 var loader = document.querySelector( '#jp-carousel-loading-overlay' );
1428 1849 domUtil.show( loader );
1429 1850 var jsScript = document.createElement( 'script' );
1430 1851 jsScript.id = 'jetpack-carousel-swiper-js';
@@ -1492,8 +1913,10 @@
1492 1913 }
1493 1914
1494 1915 domUtil.emitEvent( carousel.overlay, 'jp_carousel.beforeOpen' );
1495 1916 carousel.gallery.innerHTML = '';
1917 + commentsCache = {};
1918 + commentsFetching = {};
1496 1919
1497 1920 // Need to set the overlay manually to block or swiper does't initialise properly.
1498 1921 carousel.overlay.style.opacity = 1;
1499 1922 carousel.overlay.style.display = 'block';
@@ -1499,10 +1922,15 @@
1499 1922 carousel.overlay.style.display = 'block';
1500 1923
1501 1924 initCarouselSlides( images, settings.startIndex );
1502 1925
1503 - swiper = new window.Swiper670( '.jp-carousel-swiper-container', {
1926 + swiper = new window.JetpackSwiper( '.jp-carousel-swiper-container', {
1504 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,
1505 1933 zoom: true,
1506 1934 loop: carousel.slides.length > 1,
1507 1935 // Turn off interactions and hide navigation arrows if there is only one slide.
1508 1936 enabled: carousel.slides.length > 1,
@@ -1526,26 +1954,18 @@
1526 1954 threshold: 5,
1527 1955 } );
1528 1956
1529 1957 swiper.on( 'slideChange', function ( swiper ) {
1530 - var index;
1531 - // Swiper indexes slides from 1, plus when looping to left last slide ends up
1532 - // as 0 and looping to right first slide as total slides + 1. These are adjusted
1533 - // here to match index of carousel.slides.
1534 - if ( swiper.activeIndex === 0 ) {
1535 - index = carousel.slides.length - 1;
1536 - } else if ( swiper.activeIndex === carousel.slides.length + 1 ) {
1537 - index = 0;
1538 - } else {
1539 - index = swiper.activeIndex - 1;
1958 + if ( ! carousel.isOpen ) {
1959 + return;
1540 1960 }
1541 - selectSlideAtIndex( index );
1542 -
1961 + selectSlideAtIndex( swiper.realIndex );
1543 1962 carousel.overlay.classList.remove( 'jp-carousel-hide-controls' );
1544 1963 } );
1545 1964
1546 1965 swiper.on( 'zoomChange', function ( swiper, scale ) {
1547 1966 if ( scale > 1 ) {
1967 + loadZoomImage( carousel.currentSlide );
1548 1968 carousel.overlay.classList.add( 'jp-carousel-hide-controls' );
1549 1969 }
1550 1970
1551 1971 if ( scale === 1 ) {
@@ -1578,8 +1998,13 @@
1578 1998
1579 1999 // Register the event listeners for starting the gallery
1580 2000 document.body.addEventListener( 'click', handleInteraction );
1581 2001 document.body.addEventListener( 'keydown', handleInteraction );
2002 + document.querySelectorAll( galleryItemSelector + 'img' ).forEach( function ( galleryImage ) {
2003 + if ( shouldOpenModal( galleryImage ) ) {
2004 + makeGalleryImageAccessible( galleryImage );
2005 + }
2006 + } );
1582 2007
1583 2008 function handleInteraction( e ) {
1584 2009 if ( e.type === 'click' ) {
1585 2010 handleClick( e );
@@ -1592,14 +2017,63 @@
1592 2017 parentElement && parentElement.classList.contains( 'tiled-gallery__item' );
1593 2018
1594 2019 if ( ( e.key === ' ' || e.key === 'Enter' ) && isParentCarouselContainer ) {
1595 2020 handleClick( e );
1596 - e.preventDefault(); // Prevent scrolling on space
1597 - return;
1598 2021 }
1599 2022 }
1600 2023 }
1601 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 +
1602 2076 function handleClick( e ) {
1603 2077 var isCompatible =
1604 2078 window.CSS && window.CSS.supports && window.CSS.supports( 'display', 'grid' );
1605 2079
@@ -1616,43 +2090,9 @@
1616 2090 if ( ! testForData( gallery ) ) {
1617 2091 return;
1618 2092 }
1619 2093
1620 - var parent = target.parentElement;
1621 - var grandparent = parent.parentElement;
1622 -
1623 - // If Gallery is made up of individual Image blocks check for custom link before
1624 - // loading carousel. The custom link may be the parent or could be a descendant
1625 - // of the parent if the image has rounded corners.
1626 - var parentHref = null;
1627 - if ( grandparent && grandparent.classList.contains( 'wp-block-image' ) ) {
1628 - parentHref = parent.getAttribute( 'href' );
1629 - } else if (
1630 - parent &&
1631 - parent.classList.contains( 'wp-block-image' ) &&
1632 - parent.querySelector( ':scope > a' )
1633 - ) {
1634 - parentHref = parent.querySelector( ':scope > a' ).getAttribute( 'href' );
1635 - }
1636 -
1637 - // If the link does not point to the attachment or media file then assume Image has
1638 - // a custom link so don't load the carousel.
1639 - if (
1640 - parentHref &&
1641 - parentHref.split( '?' )[ 0 ] !==
1642 - target.getAttribute( 'data-orig-file' ).split( '?' )[ 0 ] &&
1643 - parentHref !== target.getAttribute( 'data-permalink' )
1644 - ) {
1645 - return;
1646 - }
1647 -
1648 - // Do not open the modal if we are looking at a gallery caption from before WP5, which may contain a link.
1649 - if ( parent.classList.contains( 'gallery-caption' ) ) {
1650 - return;
1651 - }
1652 -
1653 - // Do not open the modal if we are looking at a caption of a gallery block, which may contain a link.
1654 - if ( domUtil.matches( parent, 'figcaption' ) ) {
2094 + if ( ! shouldOpenModal( target ) ) {
1655 2095 return;
1656 2096 }
1657 2097
1658 2098 // Set height to auto.