diff --git a/spec/dom-style-reader-spec.js b/spec/dom-style-reader-spec.js new file mode 100644 index 00000000..1b7d295b --- /dev/null +++ b/spec/dom-style-reader-spec.js @@ -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)` + ) + }) + }) +}) diff --git a/spec/utils.js b/spec/utils.js new file mode 100644 index 00000000..a27df33c --- /dev/null +++ b/spec/utils.js @@ -0,0 +1,7 @@ +"use babel" + +export function sleep(time: number) { + return new Promise((resolve) => { + setTimeout(resolve, time) + }) +} diff --git a/src-commons-ui/dom-style-reader.ts b/src-commons-ui/dom-style-reader.ts new file mode 100644 index 00000000..2ebe53c1 --- /dev/null +++ b/src-commons-ui/dom-style-reader.ts @@ -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>() + + 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))) +} diff --git a/src-commons-ui/index.ts b/src-commons-ui/index.ts index f03be6f6..e530e042 100644 --- a/src-commons-ui/index.ts +++ b/src-commons-ui/index.ts @@ -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"