diff --git a/CHANGELOG.md b/CHANGELOG.md index b6257ce44..36e57f2a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ ### Others - deck-utils: v2.4.0 ([CHANGELOG](https://github.com/deckgo/deckdeckgo/blob/master/utils/deck/CHANGELOG.md)) -- utils: v1.2.0 ([CHANGELOG](https://github.com/deckgo/deckdeckgo/blob/master/utils/utils/CHANGELOG.md)) +- utils: v1.3.0 ([CHANGELOG](https://github.com/deckgo/deckdeckgo/blob/master/utils/utils/CHANGELOG.md)) - starter kit: v2.6.4 ([CHANGELOG](https://github.com/deckgo/deckdeckgo-starter/blob/master/CHANGELOG.md)) diff --git a/studio/package-lock.json b/studio/package-lock.json index 89a54bce7..e79b3ab9b 100644 --- a/studio/package-lock.json +++ b/studio/package-lock.json @@ -102,6 +102,13 @@ "requires": { "@deckdeckgo/utils": "1.2.0", "prismjs": "^1.21.0" + }, + "dependencies": { + "@deckdeckgo/utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@deckdeckgo/utils/-/utils-1.2.0.tgz", + "integrity": "sha512-VAZAOeHdMKITOMawcur5Ih71F7VouAZyihSZda9s24q6dqf3K+3nHjtLa+v8cOzTIUkFkVzWGggvTSrcKjQMkw==" + } } }, "@deckdeckgo/inline-editor": { @@ -263,9 +270,9 @@ "dev": true }, "@deckdeckgo/utils": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@deckdeckgo/utils/-/utils-1.2.0.tgz", - "integrity": "sha512-VAZAOeHdMKITOMawcur5Ih71F7VouAZyihSZda9s24q6dqf3K+3nHjtLa+v8cOzTIUkFkVzWGggvTSrcKjQMkw==" + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@deckdeckgo/utils/-/utils-1.3.0.tgz", + "integrity": "sha512-QrV3jyqIlL+u1NUqSyMsnIsfORLqBuzEhK2+rGYCT89TLVhnPpOP0b11aP/XvHyJMzG9mZvjadLN/MQ9ls9UGw==" }, "@deckdeckgo/youtube": { "version": "1.1.2", diff --git a/studio/package.json b/studio/package.json index 3270c94ff..66235f4be 100644 --- a/studio/package.json +++ b/studio/package.json @@ -40,7 +40,7 @@ "@deckdeckgo/slide-title": "^1.1.3", "@deckdeckgo/slide-youtube": "^1.1.2", "@deckdeckgo/social": "^2.0.0", - "@deckdeckgo/utils": "^1.2.0", + "@deckdeckgo/utils": "^1.3.0", "@deckdeckgo/youtube": "^1.1.2", "@ionic/core": "^5.3.1", "firebase": "^7.17.2", diff --git a/studio/src/app/components/editor/app-slide-contrast/app-slide-contrast.scss b/studio/src/app/components/editor/app-slide-contrast/app-slide-contrast.scss new file mode 100644 index 000000000..5d980530b --- /dev/null +++ b/studio/src/app/components/editor/app-slide-contrast/app-slide-contrast.scss @@ -0,0 +1,40 @@ +app-slide-contrast { + position: absolute; + top: 16px; + left: 16px; + + transition: opacity 0.5s; + + visibility: initial; + opacity: 1; + + &:not(.warning) { + visibility: hidden; + opacity: 0; + } + + button { + display: flex; + justify-content: center; + align-items: center; + + background: var(--ion-color-warning); + color: var(--ion-color-warning-contrast); + + padding: 6px 12px; + border-radius: 64px; + + position: relative; + overflow: hidden; + + outline: 0; + + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + + font-size: var(--font-size-small); + + ion-label { + margin-right: 4px; + } + } +} diff --git a/studio/src/app/components/editor/app-slide-contrast/app-slide-contrast.tsx b/studio/src/app/components/editor/app-slide-contrast/app-slide-contrast.tsx new file mode 100644 index 000000000..2f47f2fa1 --- /dev/null +++ b/studio/src/app/components/editor/app-slide-contrast/app-slide-contrast.tsx @@ -0,0 +1,133 @@ +import {Component, h, Host, Listen, State} from '@stencil/core'; + +import {popoverController} from '@ionic/core'; + +import {ContrastUtils} from '../../../utils/editor/contrast.utils'; +import {NodeUtils} from '../../../utils/editor/node.utils'; + +@Component({ + tag: 'app-slide-contrast', + styleUrl: 'app-slide-contrast.scss', +}) +export class AppSlideContrast { + private readonly lowestAACompliantLevel: number = 3; + + @State() + private warning: boolean = false; + + @Listen('slidesDidLoad', {target: 'document'}) + async onSlidesDidLoad() { + await this.analyzeContrast(); + } + + @Listen('slideDidUpdate', {target: 'document'}) + async onSlideDidUpdate() { + await this.analyzeContrast(); + } + + @Listen('deckDidChange', {target: 'document'}) + async onDeckDidChange() { + await this.analyzeContrast(); + } + + @Listen('slideNextDidChange', {target: 'document'}) + @Listen('slidePrevDidChange', {target: 'document'}) + @Listen('slideToChange', {target: 'document'}) + async onSlideNavigate() { + await this.analyzeContrast(); + } + + private async analyzeContrast() { + this.warning = await this.hasLowContrast(); + } + + private async hasLowContrast(): Promise { + const deck: HTMLElement = document.querySelector('main > deckgo-deck'); + + if (!deck) { + return false; + } + + const index = await (deck as any).getActiveIndex(); + + const slide: HTMLElement = deck.querySelector('.deckgo-slide-container:nth-child(' + (index + 1) + ')'); + + if (!slide) { + return false; + } + + const slots: NodeListOf = slide.querySelectorAll( + '[slot="title"]:not(:empty),[slot="content"]:not(:empty),[slot="start"]:not(:empty),[slot="end"]:not(:empty),[slot="header"]:not(:empty),[slot="footer"]:not(:empty),[slot="author"]:not(:empty),deckgo-drr > section:not(:empty)' + ); + + if (!slots || slots.length <= 0) { + return false; + } + + // Slots with direct text children + const slotsWithText: HTMLElement[] = await NodeUtils.childrenTextNode(slots); + + // All children () of the slots + const children: HTMLElement[] = await NodeUtils.children(slots); + + const elements: HTMLElement[] = + children && children.length > 0 + ? slotsWithText && slotsWithText.length > 0 + ? [...Array.from(slotsWithText), ...children] + : [...children] + : slotsWithText && slotsWithText.length > 0 + ? [...slotsWithText] + : null; + + if (!elements) { + return false; + } + + const promises: Promise[] = Array.from(elements).map((element: HTMLElement) => this.calculateRatio(element, deck, slide)); + + const contrasts: number[] = await Promise.all(promises); + + if (!contrasts || contrasts.length <= 0) { + return false; + } + + const lowContrast: number | undefined = contrasts.find((contrast: number) => contrast < this.lowestAACompliantLevel); + + return lowContrast !== undefined; + } + + private async calculateRatio(element: HTMLElement, deck: HTMLElement, slide: HTMLElement) { + const bgColor = await NodeUtils.findColors(element, 'background', deck, slide); + const color = await NodeUtils.findColors(element, 'color', deck, slide); + + console.log('yo', bgColor, color); + + return ContrastUtils.calculateContrastRatio(bgColor, color); + } + + private async openInformation($event: UIEvent) { + const popover: HTMLIonPopoverElement = await popoverController.create({ + component: 'app-contrast-info', + event: $event, + mode: 'ios', + cssClass: 'info', + }); + + await popover.present(); + } + + render() { + return ( + + + + ); + } +} diff --git a/studio/src/app/pages/editor/app-editor/app-editor.tsx b/studio/src/app/pages/editor/app-editor/app-editor.tsx index 7df7243ba..c96b5dc61 100644 --- a/studio/src/app/pages/editor/app-editor/app-editor.tsx +++ b/studio/src/app/pages/editor/app-editor/app-editor.tsx @@ -652,6 +652,7 @@ export class AppEditor { {this.footer} + , diff --git a/studio/src/app/popovers/editor/app-contrast-info/app-contrast-info.tsx b/studio/src/app/popovers/editor/app-contrast-info/app-contrast-info.tsx new file mode 100644 index 000000000..e9f21471d --- /dev/null +++ b/studio/src/app/popovers/editor/app-contrast-info/app-contrast-info.tsx @@ -0,0 +1,35 @@ +import {Component, Element, h} from '@stencil/core'; + +@Component({ + tag: 'app-contrast-info', +}) +export class AppContrastInfo { + @Element() el: HTMLElement; + + private async closePopover() { + await (this.el.closest('ion-popover') as HTMLIonPopoverElement).dismiss(); + } + + render() { + return ( +
+

Low contrast

+

We noticed that (a part of) the text color of this slide does not meet contrast ratio standards.

+

+ Elements are compared according{' '} + + WCAG + {' '} + Level AA. +

+ +

Note that if you are using semi-transparent background, the contrast ratio cannot be precise.

+
+ this.closePopover()}> + Got it + +
+
+ ); + } +} diff --git a/studio/src/app/popovers/editor/app-get-help/app-get-help.tsx b/studio/src/app/popovers/editor/app-get-help/app-get-help.tsx index cc8e7bd84..4494fa8f6 100644 --- a/studio/src/app/popovers/editor/app-get-help/app-get-help.tsx +++ b/studio/src/app/popovers/editor/app-get-help/app-get-help.tsx @@ -29,7 +29,7 @@ export class AppGetHelp { .

-
+
this.closePopover()}> Got it diff --git a/studio/src/app/utils/editor/contrast.utils.tsx b/studio/src/app/utils/editor/contrast.utils.tsx new file mode 100644 index 000000000..cd9de1568 --- /dev/null +++ b/studio/src/app/utils/editor/contrast.utils.tsx @@ -0,0 +1,110 @@ +import {extractRgb, extractRgba} from '@deckdeckgo/utils'; + +export class ContrastUtils { + static async calculateContrastRatio(bgColor: string | undefined, color: string | undefined): Promise { + const bgColorWithDefault: string = bgColor === undefined || bgColor === '' ? `rgb(255, 255, 255)` : bgColor; + const colorWithDefault: string = color === undefined || color === '' ? `rgb(0, 0, 0)` : color; + + // The text color may or may not be semi-transparent, but that doesn't matter + const bgRgba: number[] | undefined = extractRgba(bgColorWithDefault); + + if (!bgRgba || bgRgba.length < 4 || bgRgba[3] >= 1) { + return this.calculateContrastRatioOpaque(bgColorWithDefault, colorWithDefault); + } + + return this.calculateContrastRatioAlpha(bgColorWithDefault, colorWithDefault); + } + + private static calculateLuminance(rgb: number[]): number { + const a = rgb.map((v) => { + v /= 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + }); + return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; + } + + private static calculateColorContrastRatio(firstColorLum: number, secondColorLum: number): number { + // return firstColorLum > secondColorLum ? (secondColorLum + 0.05) / (firstColorLum + 0.05) : (firstColorLum + 0.05) / (secondColorLum + 0.05); + + const l1 = firstColorLum + 0.05; + const l2 = secondColorLum + 0.05; + + let ratio = l1 / l2; + + if (l2 > l1) { + ratio = 1 / ratio; + } + + return ratio; + } + + // Source: https://github.com/LeaVerou/contrast-ratio/blob/eb7fe8f16206869f8d36d517d7eb0962830d0e81/color.js#L86 + private static async convertAlphaRgba(color: string, base: number[]): Promise { + const rgba: number[] | undefined = extractRgba(color); + + if (!rgba || rgba.length < 4) { + return color; + } + + const alpha: number = rgba[3]; + + const rgb: number[] = []; + + for (let i = 0; i < 3; i++) { + rgb.push(rgba[i] * alpha + base[i] * base[3] * (1 - alpha)); + } + + // Not used here + // rgb[3] = alpha + base[3] * (1 - alpha); + + return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; + } + + private static async calculateColorContrastRatioWithBase( + bgColor: string, + lumColor: number, + base: number[] + ): Promise<{luminanceOverlay: number; ratio: number}> { + const overlay = extractRgb(await this.convertAlphaRgba(bgColor, base)); + + const lumOverlay: number = this.calculateLuminance(overlay); + + return { + luminanceOverlay: lumOverlay, + ratio: this.calculateColorContrastRatio(lumOverlay, lumColor), + }; + } + + private static async calculateContrastRatioAlpha(bgColor: string, color: string): Promise { + const lumColor: number = this.calculateLuminance(extractRgb(color)); + + const onBlack: {luminanceOverlay: number; ratio: number} = await this.calculateColorContrastRatioWithBase(bgColor, lumColor, [0, 0, 0, 1]); + const onWhite: {luminanceOverlay: number; ratio: number} = await this.calculateColorContrastRatioWithBase(bgColor, lumColor, [255, 255, 255, 1]); + + const max = Math.max(onBlack.ratio, onWhite.ratio); + + let min = 1; + if (onBlack.luminanceOverlay > lumColor) { + min = onBlack.ratio; + } else if (onWhite.luminanceOverlay < lumColor) { + min = onWhite.ratio; + } + + return (min + max) / 2; + } + + private static async calculateContrastRatioOpaque(bgColor: string, color: string): Promise { + const bgRgb: number[] | undefined = extractRgb(bgColor); + const colorRgb: number[] | undefined = extractRgb(color); + + if (bgColor === undefined || colorRgb === undefined) { + // 0 being AA and AAA level friendly. We assume that if for some reason we can't extract color, we better not display a warning about it. + return 0; + } + + const lumBg: number = this.calculateLuminance(bgRgb); + const lumColor: number = this.calculateLuminance(colorRgb); + + return this.calculateColorContrastRatio(lumBg, lumColor); + } +} diff --git a/studio/src/app/utils/editor/node.utils.tsx b/studio/src/app/utils/editor/node.utils.tsx new file mode 100644 index 000000000..1be09e516 --- /dev/null +++ b/studio/src/app/utils/editor/node.utils.tsx @@ -0,0 +1,57 @@ +export interface NodeColors { + bgColor: string | undefined; + color: string | undefined; +} + +export class NodeUtils { + static async childrenTextNode(elements: NodeListOf): Promise { + return Array.from(elements).reduce((acc: HTMLElement[], slot: HTMLElement) => { + const text = Array.from(slot.childNodes).find((child) => child.nodeType === Node.TEXT_NODE); + + if (text !== null && text !== undefined && text.textContent.replace(/(?:\r\n|\r|\n|\s)/g, '') !== '') { + acc.push(slot); + } + + return acc; + }, []); + } + + static async children(elements: NodeListOf): Promise { + return Array.from(elements).reduce((acc: HTMLElement[], slot: HTMLElement) => { + const children: NodeListOf = slot.querySelectorAll('*'); + + if (children && children.length > 0) { + acc.push(...Array.from(children)); + } + + return acc; + }, []); + } + + static async findColors(node: HTMLElement, color: 'color' | 'background', slide: HTMLElement, deck: HTMLElement): Promise { + // Just in case + if (node.nodeName.toUpperCase() === 'HTML' || node.nodeName.toUpperCase() === 'BODY') { + return undefined; + } + + if (!node.parentNode) { + return undefined; + } + + if (node.isEqualNode(deck)) { + return deck.style.getPropertyValue(`--${color}`); + } + + if (node.isEqualNode(slide) && slide.style[color] !== '') { + return slide.style[color]; + } + + const styleAttr: string = color === 'background' ? 'background-color' : 'color'; + + if (node.style[styleAttr] !== '' && node.style[styleAttr] !== 'initial') { + return node.style[styleAttr]; + } + + return await this.findColors(node.parentElement, color, slide, deck); + } +} diff --git a/studio/src/components.d.ts b/studio/src/components.d.ts index 833c205e8..c105e527f 100644 --- a/studio/src/components.d.ts +++ b/studio/src/components.d.ts @@ -109,6 +109,8 @@ export namespace Components { } interface AppContactForm { } + interface AppContrastInfo { + } interface AppCreateSlide { } interface AppCustomData { @@ -367,6 +369,8 @@ export namespace Components { "redirect": string; "redirectId": string; } + interface AppSlideContrast { + } interface AppSlideNavigate { } interface AppSlotType { @@ -508,6 +512,12 @@ declare global { prototype: HTMLAppContactFormElement; new (): HTMLAppContactFormElement; }; + interface HTMLAppContrastInfoElement extends Components.AppContrastInfo, HTMLStencilElement { + } + var HTMLAppContrastInfoElement: { + prototype: HTMLAppContrastInfoElement; + new (): HTMLAppContrastInfoElement; + }; interface HTMLAppCreateSlideElement extends Components.AppCreateSlide, HTMLStencilElement { } var HTMLAppCreateSlideElement: { @@ -1000,6 +1010,12 @@ declare global { prototype: HTMLAppSigninElement; new (): HTMLAppSigninElement; }; + interface HTMLAppSlideContrastElement extends Components.AppSlideContrast, HTMLStencilElement { + } + var HTMLAppSlideContrastElement: { + prototype: HTMLAppSlideContrastElement; + new (): HTMLAppSlideContrastElement; + }; interface HTMLAppSlideNavigateElement extends Components.AppSlideNavigate, HTMLStencilElement { } var HTMLAppSlideNavigateElement: { @@ -1080,6 +1096,7 @@ declare global { "app-color-text-background": HTMLAppColorTextBackgroundElement; "app-contact": HTMLAppContactElement; "app-contact-form": HTMLAppContactFormElement; + "app-contrast-info": HTMLAppContrastInfoElement; "app-create-slide": HTMLAppCreateSlideElement; "app-custom-data": HTMLAppCustomDataElement; "app-custom-images": HTMLAppCustomImagesElement; @@ -1162,6 +1179,7 @@ declare global { "app-share-deck": HTMLAppShareDeckElement; "app-share-options": HTMLAppShareOptionsElement; "app-signin": HTMLAppSigninElement; + "app-slide-contrast": HTMLAppSlideContrastElement; "app-slide-navigate": HTMLAppSlideNavigateElement; "app-slot-type": HTMLAppSlotTypeElement; "app-team": HTMLAppTeamElement; @@ -1285,6 +1303,8 @@ declare namespace LocalJSX { } interface AppContactForm { } + interface AppContrastInfo { + } interface AppCreateSlide { "onSignIn"?: (event: CustomEvent) => void; } @@ -1568,6 +1588,8 @@ declare namespace LocalJSX { "redirect"?: string; "redirectId"?: string; } + interface AppSlideContrast { + } interface AppSlideNavigate { "onReorder"?: (event: CustomEvent) => void; } @@ -1615,6 +1637,7 @@ declare namespace LocalJSX { "app-color-text-background": AppColorTextBackground; "app-contact": AppContact; "app-contact-form": AppContactForm; + "app-contrast-info": AppContrastInfo; "app-create-slide": AppCreateSlide; "app-custom-data": AppCustomData; "app-custom-images": AppCustomImages; @@ -1697,6 +1720,7 @@ declare namespace LocalJSX { "app-share-deck": AppShareDeck; "app-share-options": AppShareOptions; "app-signin": AppSignin; + "app-slide-contrast": AppSlideContrast; "app-slide-navigate": AppSlideNavigate; "app-slot-type": AppSlotType; "app-team": AppTeam; @@ -1732,6 +1756,7 @@ declare module "@stencil/core" { "app-color-text-background": LocalJSX.AppColorTextBackground & JSXBase.HTMLAttributes; "app-contact": LocalJSX.AppContact & JSXBase.HTMLAttributes; "app-contact-form": LocalJSX.AppContactForm & JSXBase.HTMLAttributes; + "app-contrast-info": LocalJSX.AppContrastInfo & JSXBase.HTMLAttributes; "app-create-slide": LocalJSX.AppCreateSlide & JSXBase.HTMLAttributes; "app-custom-data": LocalJSX.AppCustomData & JSXBase.HTMLAttributes; "app-custom-images": LocalJSX.AppCustomImages & JSXBase.HTMLAttributes; @@ -1814,6 +1839,7 @@ declare module "@stencil/core" { "app-share-deck": LocalJSX.AppShareDeck & JSXBase.HTMLAttributes; "app-share-options": LocalJSX.AppShareOptions & JSXBase.HTMLAttributes; "app-signin": LocalJSX.AppSignin & JSXBase.HTMLAttributes; + "app-slide-contrast": LocalJSX.AppSlideContrast & JSXBase.HTMLAttributes; "app-slide-navigate": LocalJSX.AppSlideNavigate & JSXBase.HTMLAttributes; "app-slot-type": LocalJSX.AppSlotType & JSXBase.HTMLAttributes; "app-team": LocalJSX.AppTeam & JSXBase.HTMLAttributes; diff --git a/studio/src/global/theme/editor/editor-fullscreen.scss b/studio/src/global/theme/editor/editor-fullscreen.scss index 1a5047142..aa3832122 100644 --- a/studio/src/global/theme/editor/editor-fullscreen.scss +++ b/studio/src/global/theme/editor/editor-fullscreen.scss @@ -60,6 +60,15 @@ box-shadow: none; } } + + app-slide-contrast { + display: none; + } + } + + app-slide-contrast { + top: 32px; + left: 32px; } } diff --git a/utils/utils/CHANGELOG.md b/utils/utils/CHANGELOG.md index 906e26a20..9d9bf6438 100644 --- a/utils/utils/CHANGELOG.md +++ b/utils/utils/CHANGELOG.md @@ -1,3 +1,11 @@ + + +# 1.3.0 (2020-08-15) + +- improve `extractRgb` to support decimals value (as for example `rgb(5.5, 4.7, 4)`) +- expose function `extractRgb` +- add and expose function `extractRgba` + # 1.2.0 (2020-07-31) diff --git a/utils/utils/package-lock.json b/utils/utils/package-lock.json index 7df9c7b0d..c2e6f5f07 100644 --- a/utils/utils/package-lock.json +++ b/utils/utils/package-lock.json @@ -1,6 +1,6 @@ { "name": "@deckdeckgo/utils", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/utils/utils/package.json b/utils/utils/package.json index 821dffdf1..72e158016 100644 --- a/utils/utils/package.json +++ b/utils/utils/package.json @@ -1,6 +1,6 @@ { "name": "@deckdeckgo/utils", - "version": "1.2.0", + "version": "1.3.0", "author": "David Dal Busco", "description": "A collection of utils methods and functions developed for DeckDeckGo", "license": "MIT", diff --git a/utils/utils/src/utils/color-utils.ts b/utils/utils/src/utils/color-utils.ts index 7667e8e23..8bae4b254 100644 --- a/utils/utils/src/utils/color-utils.ts +++ b/utils/utils/src/utils/color-utils.ts @@ -23,15 +23,25 @@ export async function rgbToHex(rgb: string | undefined): Promise v.toString(16).padStart(2, '0')).join('')}`; }; - const extractRgb = (rgb: string): number[] | undefined => { - const match: RegExpMatchArray | null = rgb.match(/(\d+),\s*(\d+),\s*(\d+)/); + return toHex(extractRgb(rgb)); +} - if (!match) { - return undefined; - } +export function extractRgb(rgb: string): number[] | undefined { + const match: RegExpMatchArray | null = rgb.match(/([.\d]+),\s*([.\d]+),\s*([.\d]+)/); - return match.splice(1, 3).map((v) => Number(v)); - }; + if (!match) { + return undefined; + } - return toHex(extractRgb(rgb)); + return match.splice(1, 3).map((v) => Number(v)); +} + +export function extractRgba(rgb: string): number[] | undefined { + const match: RegExpMatchArray | null = rgb.match(/([.\d]+),\s*([.\d]+),\s*([.\d]+),\s*([.\d]+)/); + + if (!match) { + return undefined; + } + + return match.splice(1, 4).map((v) => Number(v)); }