Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions e2e/visual.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ ARTICLES.forEach(([article, { targets }]) => {
test.describe(`${resolution.join(',')}`, () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: resolution[0], height: resolution[1] });
// Make sure all images are loaded before taking screenshots
for (const img of await page.locator('#content > :has(.Picture):not(.MasterGallery)').all()) {
await img.scrollIntoViewIfNeeded();
}
});

// Above the fold is being troublesome and doesn't add much
Expand All @@ -86,6 +90,7 @@ ARTICLES.forEach(([article, { targets }]) => {
const locator = page.locator(target).first();
await expect(locator).toHaveCount(1);
await locator.scrollIntoViewIfNeeded();
await page.waitForTimeout(100);
await expect(locator).toHaveScreenshot({
timeout: 30000,
stylePath: join(__dirname, 'screenshots.css')
Expand Down
1 change: 1 addition & 0 deletions playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default defineConfig({
// workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
timeout: 60000,
expect: {
toHaveScreenshot: { maxDiffPixels: 500, maxDiffPixelRatio: 0.02 }
},
Expand Down
22 changes: 14 additions & 8 deletions src/app/components/Header/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { $, detach, detectVideoId, getChildImage, isElement } from '../../utils/
import { clampNumber, formattedDate, getRatios, isDefined } from '../../utils/misc';
import Picture from '../Picture';
import ScrollHint from '../ScrollHint';
import { activate as activateUParallax } from '../UParallax';
import VideoPlayer from '../VideoPlayer';
import YouTubePlayer from '../YouTubePlayer';
import styles from './index.lazy.scss';
Expand All @@ -25,6 +24,7 @@ import styles from './index.lazy.scss';
* @prop {boolean} isPale
* @prop {boolean} isVideoYouTube
* @prop {Partial<import('src/app/meta').MetaData>} meta
* @prop {{value: number; units: string}} [mediaWidth]
* @prop {Element[]} miscContentEls
* @prop {import('../../utils/misc').Ratios} ratios
* @prop {boolean} shouldVideoPlayOnce
Expand All @@ -46,6 +46,7 @@ const Header = ({
isPale,
isVideoYouTube,
meta = {},
mediaWidth,
miscContentEls = [],
ratios = {},
shouldVideoPlayOnce,
Expand Down Expand Up @@ -77,6 +78,9 @@ const Header = ({
xl: isAbreast ? '1x1' : ratios.xl
};

/**
* @type {HTMLElement | undefined}
*/
let mediaEl;

if (imgEl) {
Expand All @@ -102,11 +106,6 @@ const Header = ({
});
}

if (mediaEl && !isLayered && !isAbreast) {
// mediaEl.classList.add('u-parallax');
// activateUParallax(mediaEl);
}

const titleEl = html`
<h1>
${isKicker && meta.title && meta.title.indexOf(': ') > -1
Expand Down Expand Up @@ -168,7 +167,10 @@ const Header = ({
<div class="${className}" data-scheme="${scheme}" data-theme="${THEME}">
${mediaEl
? html`
<div class="Header-media${isLayered && !isAbreast && mediaEl.tagName !== 'DIV' ? ' u-parallax' : ''}">
<div
class="Header-media${isLayered && !isAbreast && mediaEl.tagName !== 'DIV' ? ' u-parallax' : ''}"
style="${mediaWidth ? '--od-header-media-width: ' + mediaWidth.value + mediaWidth.units : ''}"
>
${!isLayered && !isAbreast && !meta.isFuture ? ScrollHint() : null} ${mediaEl}
</div>
`
Expand Down Expand Up @@ -259,7 +261,8 @@ export const transformSection = (section, meta) => {
const isKicker = section.configString.indexOf('kicker') > -1;
const shouldSupplant = section.configString.indexOf('supplant') > -1;
const shouldVideoPlayOnce = section.configString.indexOf('once') > -1;

/** @type {(string|undefined)[]} */
const [, , mediaWidthValue, mediaWidthUnit] = section.configString.match(/mediawidth(([0-9]+)(px|pct|rem))/) || [];
let candidateNodes = section.betweenNodes;

if (!isNoMedia && meta.relatedMedia != null) {
Expand Down Expand Up @@ -311,6 +314,9 @@ export const transformSection = (section, meta) => {
isFloating,
isLayered,
isKicker,
mediaWidth: mediaWidthValue
? { value: +mediaWidthValue, units: mediaWidthUnit?.replace('pct', '%') || 'px' }
: undefined,
miscContentEls: [],
shouldVideoPlayOnce
}
Expand Down
13 changes: 13 additions & 0 deletions src/app/components/Header/index.lazy.scss
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ $abreastChildSpacing: $unit * 2.5;
background-color: $color-lightBg;
}

&:has(.is-original) {
width: var(--od-header-media-width);
margin: auto;
:not(.is-layered) > & {
max-height: none;
}
}

.is-original {
max-height: calc(100vh - var(--Main-offsetTop) - var(--Header-contentPeek));
}

&:has(.is-original),
.is-abreast > & {
background-color: transparent;
}
Expand Down
60 changes: 40 additions & 20 deletions src/app/components/Picture/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @ts-check
import { getImages } from '@abcnews/terminus-fetch';
import cn from 'classnames';
import html from 'nanohtml';
Expand All @@ -15,20 +16,13 @@ const DEFAULT_RATIOS = {
xl: '16x9'
};
const WIDTHS = [700, 940, 1400, 2150];
const RATIO_SIZE_WIDTH_INDICES = {
'16x9': { sm: 0, md: 1, lg: 3, xl: 3 },
'3x2': { sm: 0, md: 1, lg: 1, xl: 1 },
'4x3': { sm: 0, md: 1, lg: 1, xl: 1 },
'1x1': { sm: 0, md: 1, lg: 2, xl: 2 },
'3x4': { sm: 0, md: 1, lg: 1, xl: 1 }
};

/**
*
* @param {object} obj
* @param {string} [obj.src]
* @param {string|null} [obj.alt]
* @param {object} [obj.ratios]
* @param {Record<string, string>} [obj.ratios]
* @param {string} [obj.linkUrl]
* @param {boolean} [obj.isContained]
* @param {boolean} [obj.shouldLazyLoad]
Expand All @@ -49,6 +43,9 @@ const Picture = ({
xl: ratios.xl || DEFAULT_RATIOS.xl
};

/**
* @type {Record<string, string|null>}
*/
const sources = {
[MQ.SM]: src,
[MQ.MD]: src,
Expand All @@ -57,32 +54,49 @@ const Picture = ({
[MQ.XL]: src
};

// When 'use original image' is checked in CM the imageDoc will only contain a single entry in the 'ratios' object.
// That means when we generate renditions for the image from our desired WIDTHS only renditions for the ratio of the
// original image will be generated.
const imageDoc = lookupImageByAssetURL(src); // Will only work if image's document was catalogued during initMeta

// Therefore we assume if there's only a single entry, this is a 'use original image' image.
const isOriginal = Object.keys(imageDoc?.media?.image.primary.ratios || {}).length === 1;

if (imageDoc) {
const { renditions } = getImages(imageDoc, WIDTHS);
if (renditions && renditions.length) {
sources[MQ.SM] = srcsetFromRenditions(renditions, ratios.sm, 'sm');
sources[MQ.MD] = srcsetFromRenditions(renditions, ratios.md, 'md');
sources[MQ.LANDSCAPE_LT_LG] = srcsetFromRenditions(renditions, ratios.lg, 'md');
sources[MQ.LG] = srcsetFromRenditions(renditions, ratios.lg, 'lg');
sources[MQ.XL] = srcsetFromRenditions(renditions, ratios.xl, 'xl');
sources[MQ.SM] = srcsetFromRenditions(renditions, ratios.sm);
sources[MQ.MD] = srcsetFromRenditions(renditions, ratios.md);
sources[MQ.LANDSCAPE_LT_LG] = srcsetFromRenditions(renditions, ratios.lg);
sources[MQ.LG] = srcsetFromRenditions(renditions, ratios.lg);
sources[MQ.XL] = srcsetFromRenditions(renditions, ratios.xl);
// This 'all' media query/srcset combo is used when an image has 'use original image' checked in CM. This works
// because the only renditions will be ones with a ratio that matches the original image. Therefore (unless the
// 'original' image happens to be the same as one of the standard ratios) this 'all' key in the sources object
// will be the only one populated with a srcset. All the others will be null.
sources['all'] = srcsetFromRenditions(renditions);
}

alt = imageDoc.alt;
alt = imageDoc.alt || '';
}

const sizerEl = Sizer(ratios);

const srcsets = Object.entries(sources).filter(([, srcset]) => !!srcset);
const pictureEl = html`<picture
>${srcsets.map(
// TODO: Ideally this would have a more nuanced sizes attribute
// TODO: Ideally this would have a more nuanced sizes attribute right now it is assumed that images display at
// full viewport width, but that is not always the case.
([media, srcset]) => html`<source media=${media} srcset=${srcset} sizes="100vw"></source>`
)}</picture
>`;

const rootEl = html`<a class=${cn('Picture', { 'is-contained': isContained })}>${sizerEl}${pictureEl}</a>`;
/**
* @type {HTMLElement & {api?: import('./lazy').LazyLoadAPI}}
*/
const rootEl = html`<a class=${cn('Picture', { 'is-contained': isContained, 'is-original': isOriginal })}
>${sizerEl}${pictureEl}</a
>`;

if (linkUrl) {
rootEl.setAttribute('href', linkUrl);
Expand All @@ -93,11 +107,11 @@ const Picture = ({
} else {
const imgEl = document.createElement('img');

imgEl.setAttribute('alt', alt);
imgEl.addEventListener('load', () => rootEl.api.loadedHook && rootEl.api.loadedHook(imgEl));
alt && imgEl.setAttribute('alt', alt);
imgEl.addEventListener('load', () => rootEl.api?.loadedHook && rootEl.api.loadedHook(imgEl));
append(pictureEl, imgEl);

rootEl.api = {};
delete rootEl.api;
rootEl.setAttribute('loaded', '');
}

Expand All @@ -108,13 +122,19 @@ const Picture = ({

export default Picture;

/**
*
* @param {{width: number;height: number;ratio: string;url: string;isUndersizedBinary: boolean;}[]} renditions
* @param {string} [preferredRatio]
* @returns
*/
function srcsetFromRenditions(renditions, preferredRatio) {
if (!renditions) {
return null;
}

// Filter renditions for preferredRatio
const preferredRatioRenditions = renditions.filter(r => r.ratio === preferredRatio);
const preferredRatioRenditions = renditions.filter(r => preferredRatio === undefined || r.ratio === preferredRatio);
if (preferredRatioRenditions.length === 0) {
return null;
}
Expand Down
7 changes: 7 additions & 0 deletions src/app/components/Picture/index.lazy.scss
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@
will-change: opacity;
}

&.is-original {
max-height: 100%;
img {
object-fit: contain;
}
}

@media #{$mq-lt-lg} {
&.is-contained img {
object-fit: contain;
Expand Down
38 changes: 32 additions & 6 deletions src/app/components/Picture/lazy.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @ts-check
import { PLACEHOLDER_IMAGE_CUSTOM_PROPERTY } from '../../constants';
import { enqueue, subscribe, unsubscribe } from '../../scheduler';
import { append, detach } from '../../utils/dom';
Expand Down Expand Up @@ -41,17 +42,42 @@ const unregister = api => {
}
};

/**
* @typedef {object} LazyLoadAPI
* @prop {() => void} forget
* @prop {() => DOMRect | null} getRect
* @prop {() => void} load
* @prop {boolean} isLoaded
* @prop {boolean} isLoading
* @prop {() => void} unload
* @prop {(imgEl: HTMLImageElement) => void} [loadedHook]
*/

/**
*
* @param {object} options
* @param {HTMLElement & {api?: LazyLoadAPI}} options.rootEl
* @param {HTMLElement} options.placeholderEl
* @param {HTMLPictureElement} options.pictureEl
* @param {string} options.blurSrc
* @param {string | null} options.alt
*/
export const addLazyLoadableAPI = ({ rootEl, placeholderEl, pictureEl, blurSrc, alt }) => {
let api = null;
let imgEl = null;
/**
* @type {LazyLoadAPI}
*/
let api;
/**
* @type {HTMLImageElement | undefined}
*/
let imgEl;
let hasPlaceholder = false;

const getRect = () => {
// Fixed images should use their parent's rect, as they're always in the viewport
const position = window.getComputedStyle(rootEl).position;
const measurableEl = position === 'fixed' ? rootEl.parentElement : rootEl;

return measurableEl.getBoundingClientRect();
return measurableEl && measurableEl.getBoundingClientRect();
};

const load = () => {
Expand All @@ -62,7 +88,7 @@ export const addLazyLoadableAPI = ({ rootEl, placeholderEl, pictureEl, blurSrc,
api.isLoading = true;
rootEl.setAttribute('loading', '');
imgEl = document.createElement('img');
imgEl.setAttribute('alt', alt);
alt && imgEl.setAttribute('alt', alt);
imgEl.addEventListener('load', onLoad, false);
append(pictureEl, imgEl);

Expand All @@ -86,7 +112,7 @@ export const addLazyLoadableAPI = ({ rootEl, placeholderEl, pictureEl, blurSrc,
rootEl.removeAttribute('loading');
rootEl.removeAttribute('loaded');
detach(imgEl);
imgEl = null;
imgEl = undefined;
};

const onLoad = () => {
Expand Down
6 changes: 6 additions & 0 deletions src/app/components/Sizer/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @ts-check
import { enqueue, subscribe } from '../../scheduler';
import styles from './index.lazy.scss';

Expand All @@ -10,9 +11,14 @@ const DEFAULT_SIZE_RATIOS = {
};
export const SIZES = Object.keys(DEFAULT_SIZE_RATIOS);

/** @type {HTMLDivElement[]} */
const instances = [];
/** @type {number} */
let lastKnownNumInstances;

/**
* @param {Record<string, string>} sizeRatios
*/
const Sizer = sizeRatios => {
const el = document.createElement('div');

Expand Down