Last active
March 30, 2023 14:00
-
-
Save hteumeuleu/51b5a8ea95cb47e344b0cb47bc1f2289 to your computer and use it in GitHub Desktop.
Outlook.com darkModeHandler
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { ContentHandler } from 'owa-controls-content-handler-base'; | |
import { transformElementForDarkMode } from 'owa-dark-mode-utilities'; | |
import { | |
ATTR_COLOR, | |
ATTR_BGCOLOR, | |
DATA_OG_STYLE_COLOR, | |
DATA_OG_ATTR_COLOR, | |
DATA_OG_STYLE_BACKGROUNDCOLOR, | |
DATA_OG_ATTR_BGCOLOR, | |
} from 'owa-content-colors-constants'; | |
import type { AlteredElement } from 'owa-dark-mode-utilities'; | |
export const DARK_MODE_HANDLER_NAME = 'darkModeHandler'; | |
type BaseColor = string | undefined; | |
interface GetBaseColor { | |
(): BaseColor; | |
} | |
const DARK_MODE_SELECTOR = '*'; | |
const EMPTY_STRING = ''; | |
interface IsExcludedElement { | |
(element: HTMLElement): boolean; | |
} | |
export class DarkModeHandler implements ContentHandler { | |
public readonly cssSelector = DARK_MODE_SELECTOR; | |
// Strict mode was enabled in this package. See aka.ms/client-web-strict-mode for details. | |
// -> Error TS2416 (32,21): Property 'keywords' in type 'DarkModeHandler' is not assignable to the same property in base type 'ContentHandler'. | |
// @ts-expect-error | |
public readonly keywords = null; | |
private alteredElements: AlteredElement[]; | |
private getBaseColor: GetBaseColor; | |
private isExcludedElement: IsExcludedElement | undefined; | |
constructor(getBaseColor: GetBaseColor, isExcludedElement?: IsExcludedElement) { | |
this.alteredElements = []; | |
this.getBaseColor = getBaseColor; | |
this.isExcludedElement = isExcludedElement; | |
} | |
public readonly handler = (element: HTMLElement, keyword?: string) => { | |
if (this.isExcludedElement && this.isExcludedElement(element)) { | |
return; | |
} | |
const alteredElement = transformElementForDarkMode( | |
element, | |
// Strict mode was enabled in this package. See aka.ms/client-web-strict-mode for details. | |
// -> Error TS2345 (54,13): Argument of type 'BaseColor' is not assignable to parameter of type 'string'. | |
// @ts-expect-error | |
this.getBaseColor(), | |
false /* useSimpleMethod */ | |
); | |
if (alteredElement) { | |
this.alteredElements.push(alteredElement); | |
} | |
}; | |
public readonly undoHandler = (elements: HTMLElement[]) => { | |
this.alteredElements.forEach(alteredElement => { | |
const { element, styleColor, attrColor, styleBGColor, attrBGColor } = alteredElement; | |
// It's possible we set an attribute based on default assumptions, and don't have a cached value to return to. | |
// Therefore, reset all attributes to their cached values or empty strings. | |
element.style.color = styleColor ? styleColor : EMPTY_STRING; | |
attrColor && element.setAttribute(ATTR_COLOR, attrColor); | |
element.style.backgroundColor = styleBGColor ? styleBGColor : EMPTY_STRING; | |
attrBGColor && element.setAttribute(ATTR_BGCOLOR, attrBGColor); | |
// Clean up our custom attr from DOM | |
element.removeAttribute(DATA_OG_STYLE_COLOR); | |
element.removeAttribute(DATA_OG_ATTR_COLOR); | |
element.removeAttribute(DATA_OG_STYLE_BACKGROUNDCOLOR); | |
element.removeAttribute(DATA_OG_ATTR_BGCOLOR); | |
}); | |
this.alteredElements = []; | |
}; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Color from 'color'; | |
import { | |
ATTR_COLOR, | |
ATTR_BGCOLOR, | |
DATA_OG_STYLE_COLOR, | |
DATA_OG_ATTR_COLOR, | |
DATA_OG_STYLE_BACKGROUNDCOLOR, | |
DATA_OG_ATTR_BGCOLOR, | |
} from 'owa-content-colors-constants'; | |
const VALID_CONTRAST_VALUE = 4.5; | |
const EMPTY_STRING = ''; | |
export interface AlteredElement { | |
element: HTMLElement; | |
styleColor: string | null; | |
attrColor: string | null; | |
styleBGColor: string | null; | |
attrBGColor: string | null; | |
} | |
export default function transformElementForDarkMode( | |
element: HTMLElement, | |
baseColor: string, | |
useSimpleMethod?: boolean | |
): AlteredElement | null { | |
// If any element has an invalid color value, Color will throw an exception when trying to create a color object. | |
try { | |
const baseBGColor = Color(baseColor); | |
let elementAltered = false; | |
const computedStyles = window.getComputedStyle(element); | |
// The color mapped to "white" is always used for the background in the reading pane. | |
// The actual color will be flipped to a dark value when darkMode is enabled. | |
const styleColor = element.style.color; | |
let textColor = Color(computedStyles.color || undefined); | |
// Legacy color support, do not remove even though HTML5 deprecates it | |
let attrColor = element.getAttribute(ATTR_COLOR); | |
// With the simple recolor flag on, recolor only elements with explicit color applied. | |
// This should be fine since we inline all CSS at the CTS step. | |
// Else, do our original contrast check logic. | |
if (useSimpleMethod ? styleColor || attrColor : !isValidContrast(textColor, baseBGColor)) { | |
textColor = fixContrast(textColor, baseBGColor, !!useSimpleMethod, baseColor); | |
element.style.setProperty('color', textColor.rgb().string(), 'important'); | |
element.setAttribute(DATA_OG_STYLE_COLOR, styleColor ? styleColor : EMPTY_STRING); | |
// Word seems to prioritize color over CSS color so copy/paste has issues without this | |
if (attrColor) { | |
element.setAttribute(ATTR_COLOR, textColor.rgb().string()); | |
element.setAttribute(DATA_OG_ATTR_COLOR, attrColor); | |
} | |
elementAltered = true; | |
} | |
// Get the background color from the element giving priority to style. | |
// If the element contains no background color, default to dark mode background to past contrast checks. | |
const styleBGColor = element.style.backgroundColor; | |
let bgColor = computedStyles.backgroundColor | |
? Color(computedStyles.backgroundColor) | |
: Color(styleBGColor || undefined); | |
// Legacy bgcolor support, do not remove even though HTML5 deprecates it | |
let attrBGColor = element.getAttribute(ATTR_BGCOLOR); | |
if (useSimpleMethod ? styleBGColor || attrBGColor : !isValidContrast(bgColor, textColor)) { | |
bgColor = fixContrast(bgColor, textColor, !!useSimpleMethod, baseColor); | |
element.style.setProperty('background-color', bgColor.rgb().string(), 'important'); | |
element.setAttribute( | |
DATA_OG_STYLE_BACKGROUNDCOLOR, | |
styleBGColor ? styleBGColor : EMPTY_STRING | |
); | |
// Word seems to prioritize bgcolor over CSS background-color so copy/paste has issues without this | |
if (attrBGColor) { | |
element.setAttribute(ATTR_BGCOLOR, bgColor.rgb().string()); | |
element.setAttribute(DATA_OG_ATTR_BGCOLOR, attrBGColor); | |
} | |
elementAltered = true; | |
} | |
// If we altered the element cache its attributes for restoring via the undoHandler. | |
if (elementAltered) { | |
return { | |
element: element, | |
styleColor, | |
attrColor, | |
styleBGColor, | |
attrBGColor, | |
}; | |
} | |
} catch (e) { | |
// Simply swallow malformed CSS and move on as to avoid component errors. | |
} | |
return null; | |
} | |
function isValidContrast(color1: Color, color2: Color): boolean { | |
return color1.contrast(color2) >= VALID_CONTRAST_VALUE; | |
} | |
export function fixContrast( | |
color: Color, | |
comparisonColor: Color, | |
useSimpleMethod: boolean, | |
baseColor: string | |
): Color { | |
const baseBGColor = Color(baseColor); | |
const baseLValue = baseBGColor.lab().array()[0]; | |
// Create the CIELAB color array from the provided color | |
const colorLab = color.lab().array(); | |
let newLValue; | |
// With simple recolor, we don't try to contrast adjust anymore. | |
// This means that white text will become background colored. | |
if (useSimpleMethod) { | |
// Flipped and scaled L value because baseLValue reduced the range from [0, 100] to [baseLValue, 100] | |
newLValue = (100 - colorLab[0]) * ((100 - baseLValue) / 100) + baseLValue; | |
} else { | |
// L value for the color we need to contrast against | |
const comparisonLValue = comparisonColor.lab().array()[0]; | |
const ceilingDarkLValue = 50; // values above this can't contrast against 100 | |
const floorLightLValue = 50 + baseLValue; // values below this can't contrast against baseLValue | |
const midpointLValue = (floorLightLValue + ceilingDarkLValue) / 2; | |
// Scaled L value because baseLValue reduced the range from [0, 100] to [baseLValue, 100] | |
newLValue = colorLab[0] * ((100 - baseLValue) / 100) + baseLValue; | |
// Flip, or not flip, depends on whichever value is more contrasting | |
// Also avoid the dead zone in the middle caused by baseLValue not being 0 | |
if (comparisonLValue > midpointLValue) { | |
// comparisonLValue isLight, newLValue should isDark | |
newLValue = Math.min(newLValue, 2 * midpointLValue - newLValue); | |
newLValue = | |
((newLValue - baseLValue) * (ceilingDarkLValue - baseLValue)) / | |
(midpointLValue - baseLValue) + | |
baseLValue; | |
} else { | |
// comparisonLValue isDark, newLValue should isLight | |
newLValue = Math.max(newLValue, 2 * midpointLValue - newLValue); | |
newLValue = | |
100 - ((100 - newLValue) * (100 - floorLightLValue)) / (100 - midpointLValue); | |
} | |
} | |
// Create a new color from the old color values and new luminance value. | |
return Color.lab(newLValue, colorLab[1], colorLab[2]).rgb().alpha(color.alpha()); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment