Skip to content

Commit

Permalink
Merge pull request #81 from atom-community/dom-styles-reader
Browse files Browse the repository at this point in the history
feat: add StyleReader to commons-ui
  • Loading branch information
aminya authored Jun 26, 2021
2 parents 874a898 + a471bac commit c96cbf4
Show file tree
Hide file tree
Showing 4 changed files with 296 additions and 0 deletions.
85 changes: 85 additions & 0 deletions spec/dom-style-reader-spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"use babel"
import { StyleReader } from "../commons-ui/dom-style-reader"

const styles = `
atom-text-editor {
position: relative;
}
atom-text-editor-minimap[stand-alone] {
width: 100px;
height: 100px;
}
atom-text-editor {
line-height: 17px;
}
atom-text-editor atom-text-editor-minimap {
background: rgba(255,0,0,0.3);
}
atom-text-editor atom-text-editor-minimap .minimap-scroll-indicator {
background: rgba(0,0,255,0.3);
}
atom-text-editor atom-text-editor-minimap .minimap-visible-area {
background: rgba(0,255,0,0.3);
opacity: 1;
}
atom-text-editor atom-text-editor-minimap .open-minimap-quick-settings {
opacity: 1 !important;
}
`

describe("StyleReader", () => {
const styleReader = new StyleReader()

let body: HTMLElement
let targetElement: HTMLElement

beforeEach(async () => {
body = atom.workspace.getElement()
jasmine.attachToDOM(body)
targetElement = (await atom.workspace.open(__filename)).getElement()

const styleNode = document.createElement("style")
styleNode.textContent = styles
body.appendChild(styleNode)
})

it("can get the color of the text", () => {
expect(styleReader.retrieveStyleFromDom([".editor"], "color", targetElement, true)).toEqual(`rgb(157, 165, 180)`)
})

describe("color rotation", () => {
let additionnalStyleNode

function setup(color = "read") {
styleReader.invalidateDOMStylesCache()

additionnalStyleNode = document.createElement("style")
additionnalStyleNode.textContent = `
atom-text-editor .editor, .editor {
color: ${color};
-webkit-filter: hue-rotate(180deg);
}
`

body.appendChild(additionnalStyleNode)
}

it("when a hue-rotate filter is applied to a rgb color computes the new color by applying the hue rotation", () => {
setup("red")
expect(styleReader.retrieveStyleFromDom([".editor"], "color", targetElement, true)).toEqual(`rgb(0, 109, 109)`)
})

it("computes the new color by applying the hue rotation", () => {
setup("rgba(255, 0, 0, 0)")
expect(styleReader.retrieveStyleFromDom([".editor"], "color", targetElement, true)).toEqual(
`rgba(0, 109, 109, 0)`
)
})
})
})
7 changes: 7 additions & 0 deletions spec/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"use babel"

export function sleep(time: number) {
return new Promise((resolve) => {
setTimeout(resolve, time)
})
}
203 changes: 203 additions & 0 deletions src-commons-ui/dom-style-reader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
"use strict"

/**
* This class is used to read the styles informations (e.g. color and background-color) from the DOM to use when
* rendering canvas. This is used in Minimap and Terminal It attaches a dummyNode to the targetNode, renders them, and
* finds the computed style back.
*/
export class StyleReader {
/** The cache object */
private domStylesCache = new Map<string, Record<string, string | undefined>>()

private dummyNode?: HTMLElement

/** Used to check if the dummyNode is on the current targetNode */
private targetNode?: HTMLElement

/** Set to true once tokenized */
// private hasTokenizedOnce = false

/**
* Returns the computed values for the given property and scope in the DOM.
*
* This function insert a dummy element in the DOM to compute its style, return the specified property, and clear the
* content of the dummy element.
*
* @param scopes A list of classes reprensenting the scope to build
* @param property The name of the style property to compute
* @param targetNode
* @param getFromCache Whether to cache the computed value or not
* @returns The computed property's value used in CanvasDrawer
*/
retrieveStyleFromDom(
scopes: string[],
property: string,
targetNode: HTMLElement,
getFromCache: boolean = true
): string {
if (scopes.length === 0) {
return ""
} // no scopes
const key = scopes.join(" ")
let cachedData = this.domStylesCache.get(key)

if (cachedData !== undefined) {
if (getFromCache) {
// if should get the value from the cache
const value = cachedData[property]
if (value !== undefined) {
// value exists
return value
} // value not in the cache - get fresh value
} // don't use cache - get fresh value
} else {
// key did not exist. create it
cachedData = {}
}

this.ensureDummyNodeExistence(targetNode)
const dummyNode = this.dummyNode as HTMLElement

let parent = dummyNode
for (let i = 0, len = scopes.length; i < len; i++) {
const scope = scopes[i]
const node = document.createElement("span")
node.className = scope.replace(dotRegexp, " ") // TODO why replace is needed?
parent.appendChild(node)
parent = node
}

const style = window.getComputedStyle(parent)
let value = style.getPropertyValue(property)

// rotate hue if webkit-filter available
const filter = style.getPropertyValue("-webkit-filter")
if (filter.includes("hue-rotate")) {
value = rotateHue(value, filter)
}

if (value !== "") {
cachedData[property] = value
this.domStylesCache.set(key, cachedData)
}

dummyNode.innerHTML = ""
return value
}

/**
* Creates a DOM node container for all the operations that need to read styles properties from DOM.
*
* @param targetNode
*/
private ensureDummyNodeExistence(targetNode: HTMLElement) {
if (this.targetNode !== targetNode || this.dummyNode === undefined) {
this.dummyNode = document.createElement("span")
this.dummyNode.style.visibility = "hidden"

// attach to the target node
targetNode.appendChild(this.dummyNode)
this.targetNode = targetNode
}
}

/** Invalidates the cache by emptying the cache object. used in MinimapElement */
invalidateDOMStylesCache() {
this.domStylesCache.clear()
}

/** Invalidates the cache only for the first tokenization event. */
/*
private invalidateIfFirstTokenization () {
if (this.hasTokenizedOnce) {
return
}
this.invalidateDOMStylesCache()
this.hasTokenizedOnce = true
}
*/
}

// ## ## ######## ## ######## ######## ######## ######
// ## ## ## ## ## ## ## ## ## ## ##
// ## ## ## ## ## ## ## ## ## ##
// ######### ###### ## ######## ###### ######## ######
// ## ## ## ## ## ## ## ## ##
// ## ## ## ## ## ## ## ## ## ##
// ## ## ######## ######## ## ######## ## ## ######

const dotRegexp = /\.+/g
const rgbExtractRegexp = /rgb(a?)\((\d+), (\d+), (\d+)(, (\d+(\.\d+)?))?\)/
const hueRegexp = /hue-rotate\((-?\d+)deg\)/

/**
* Computes the output color of `value` with a rotated hue defined in `filter`.
*
* @param value The CSS color to apply the rotation on
* @param filter The CSS hue rotate filter declaration
* @returns The rotated CSS color
*/
function rotateHue(value: string, filter: string): string {
const match = value.match(rgbExtractRegexp)
if (match === null) {
return ""
}
const [, , rStr, gStr, bStr, , aStr] = match

const hueMatch = filter.match(hueRegexp)
if (hueMatch === null) {
return ""
}

const [, hueStr] = hueMatch

let [r, g, b, a, hue] = [rStr, gStr, bStr, aStr, hueStr].map(Number)
;[r, g, b] = rotate(r, g, b, hue)

if (isNaN(a)) {
return `rgb(${r}, ${g}, ${b})`
} else {
return `rgba(${r}, ${g}, ${b}, ${a})`
}
}

/**
* Computes the hue rotation on the provided `r`, `g` and `b` channels by the amount of `angle`.
*
* @param r The red channel of the color to rotate
* @param g The green channel of the color to rotate
* @param b The blue channel of the color to rotate
* @param angle The angle to rotate the hue with
* @returns The rotated color channels
*/
function rotate(r: number, g: number, b: number, angle: number): number[] {
const matrix = [1, 0, 0, 0, 1, 0, 0, 0, 1]
const lumR = 0.2126
const lumG = 0.7152
const lumB = 0.0722
const hueRotateR = 0.143
const hueRotateG = 0.14
const hueRotateB = 0.283
const cos = Math.cos((angle * Math.PI) / 180)
const sin = Math.sin((angle * Math.PI) / 180)

matrix[0] = lumR + (1 - lumR) * cos - lumR * sin
matrix[1] = lumG - lumG * cos - lumG * sin
matrix[2] = lumB - lumB * cos + (1 - lumB) * sin
matrix[3] = lumR - lumR * cos + hueRotateR * sin
matrix[4] = lumG + (1 - lumG) * cos + hueRotateG * sin
matrix[5] = lumB - lumB * cos - hueRotateB * sin
matrix[6] = lumR - lumR * cos - (1 - lumR) * sin
matrix[7] = lumG - lumG * cos + lumG * sin
matrix[8] = lumB + (1 - lumB) * cos + lumB * sin

return [
clamp(matrix[0] * r + matrix[1] * g + matrix[2] * b),
clamp(matrix[3] * r + matrix[4] * g + matrix[5] * b),
clamp(matrix[6] * r + matrix[7] * g + matrix[8] * b),
]
}

function clamp(num: number) {
return Math.ceil(Math.max(0, Math.min(255, num)))
}
1 change: 1 addition & 0 deletions src-commons-ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from "./float-pane/ViewContainer"
export * from "./float-pane/selectable-overlay"
export * from "./MarkdownRenderer"
export * from "./scrollIntoView"
export * from "./dom-style-reader"

0 comments on commit c96cbf4

Please sign in to comment.