// Note that this file deals only with hue+sat. See Light9ColorPicker for the value component. import debug from "debug"; import { css, html, LitElement } from "lit"; import { customElement, query } from "lit/decorators.js"; import color from "onecolor"; import { SubEvent } from "sub-events"; const log = debug("control.color.pick"); export function clamp(x: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, x)); } class RainbowCoord { // origin is rainbow top-lefft constructor(public x: number, public y: number) {} } export class ClientCoord { // origin is top-left of client viewport (regardless of page scroll) constructor(public x: number, public y: number) {} } // Load the rainbow, and map between colors and pixels. class RainbowCanvas { ctx: CanvasRenderingContext2D; colorPos: { [color: string]: RainbowCoord } = {}; _loaded = false; _loadWatchers: (() => void)[] = []; constructor(url: string, public size: RainbowCoord) { var elem = document.createElement("canvas"); elem.width = size.x; elem.height = size.y; this.ctx = elem.getContext("2d")!; var img = new Image(); img.onload = () => { this.ctx.drawImage(img, 0, 0); this._readImage(); this._loaded = true; this._loadWatchers.forEach(function (cb) { cb(); }); this._loadWatchers = []; }; img.src = url; } onLoad(cb: () => void) { // we'll call this when posFor is available if (this._loaded) { cb(); return; } this._loadWatchers.push(cb); } _readImage() { var data = this.ctx.getImageData(0, 0, this.size.x, this.size.y).data; for (var y = 0; y < this.size.y; y += 1) { for (var x = 0; x < this.size.x; x += 1) { var base = (y * this.size.x + x) * 4; let px = [data[base + 0], data[base + 1], data[base + 2], 255]; if (px[0] == 0 && px[1] == 0 && px[2] == 0) { // (there's no black on the rainbow images) throw new Error(`color picker canvas (${this.size.x}) returns 0,0,0`); } var c = color(px).hex(); this.colorPos[c] = new RainbowCoord(x, y); } } } colorAt(pos: RainbowCoord) { var data = this.ctx.getImageData(pos.x, pos.y, 1, 1).data; return color([data[0], data[1], data[2], 255]).hex(); } posFor(col: string): RainbowCoord { if (col == "#000000") { throw new Error("no match"); } log("col", col); if (col == "#ffffff") { return new RainbowCoord(400 / 2, 0); } let bright = color(col).value(1).hex(); let r = parseInt(bright.slice(1, 3), 16), g = parseInt(bright.slice(3, 5), 16), b = parseInt(bright.slice(5, 7), 16); // We may not have a match for this color exactly (e.g. on // the small image), so we have to search for a near one. // 0, 1, -1, 2, -2, ... let walk = function (x: number): number { return -x + (x > 0 ? 0 : 1); }; var radius = 8; for (var dr = 0; dr < radius; dr = walk(dr)) { for (var dg = 0; dg < radius; dg = walk(dg)) { for (var db = 0; db < radius; db = walk(db)) { // Don't need bounds check- out of range // corrupt colors just won't match. const color2 = color([r + dr, g + dg, b + db, 255]); const pos = this.colorPos[color2.hex()]; if (pos !== undefined) { return pos; } } } } throw new Error("no match"); } } // One-per-page element that floats above everything. Plus the scrim element, which is also per-page. @customElement("light9-color-picker-float") class Light9ColorPickerFloat extends LitElement { static styles = [ css` :host { z-index: 10; position: fixed; /* host coords are the same as client coords /* left: 0; top: 0; width: 100%; height: 100%; /* Updated later. */ display: none; } #largeCrosshair { position: absolute; left: -60px; top: -62px; pointer-events: none; } #largeCrosshair { background: url(/colorpick_crosshair_large.svg); width: 1000px; height: 1000px; } #largeRainbowComp { z-index: 2; position: relative; width: 400px; height: 200px; border: 10px solid #000; box-shadow: 8px 11px 40px 0px rgba(0, 0, 0, 0.74); overflow: hidden; } #largeRainbow { background: url(/colorpick_rainbow_large.png); width: 400px; height: 200px; user-select: none; } #outOfBounds { user-select: none; z-index: 1; background: #00000060; position: absolute; left: 0; top: 0; width: 100%; height: 100%; } `, ]; @query("#largeCrosshair") largeCrosshairEl!: HTMLElement; @query("#largeRainbow") largeRainbowEl!: HTMLElement; canvasMove: SubEvent = new SubEvent(); outsideMove: SubEvent = new SubEvent(); mouseUp: SubEvent = new SubEvent(); render() { return html`
`; } // make top-left of rainbow image be at this pos placeRainbow(pos: ClientCoord) { const el = this.shadowRoot?.querySelector("#largeRainbowComp")! as HTMLElement; const cssBorder = 10; el.style.left = pos.x - cssBorder + "px"; el.style.top = pos.y - cssBorder + "px"; } moveLargeCrosshair(pos: RainbowCoord) { const ch = this.largeCrosshairEl; ch.style.left = pos.x - ch.offsetWidth / 2 + "px"; ch.style.top = pos.y - ch.offsetHeight / 2 + "px"; } private onCanvasMove(ev: MouseEvent) { this.canvasMove.emit(new RainbowCoord(ev.offsetX, ev.offsetY)); } private onMouseUp(ev: MouseEvent) { this.mouseUp.emit(); } private onOutOfBoundsMove(ev: MouseEvent) { this.outsideMove.emit(new ClientCoord(ev.clientX, ev.clientY)); } } class PickerFloat { private rainbow?: RainbowCanvas; private currentListener?: (hsc: string) => void; private rainbowOrigin: ClientCoord = new ClientCoord(0, 0); private floatEl?: Light9ColorPickerFloat; pageInit() { this.getFloatEl(); this.getRainbow(); } private forceHostStyle(el: HTMLElement) { el.style.zIndex = "10"; el.style.position = "fixed"; el.style.left = "0"; el.style.top = "0"; el.style.width = "100%"; el.style.height = "100%"; el.style.display = "none"; } private getFloatEl(): Light9ColorPickerFloat { if (!this.floatEl) { this.floatEl = document.createElement("light9-color-picker-float") as Light9ColorPickerFloat; this.forceHostStyle(this.floatEl); this.subscribeToFloatElement(this.floatEl); document.body.appendChild(this.floatEl); } return this.floatEl; } private subscribeToFloatElement(el: Light9ColorPickerFloat) { el.canvasMove.subscribe(this.onCanvasMove.bind(this)); el.outsideMove.subscribe(this.onOutsideMove.bind(this)); el.mouseUp.subscribe(() => { this.hide(); }); } private onCanvasMove(pos: RainbowCoord) { pos = new RainbowCoord( // clamp(pos.x, 0, 400 - 1), // clamp(pos.y, 0, 200 - 1) ); this.getFloatEl().moveLargeCrosshair(pos); if (this.currentListener) { this.currentListener(this.getRainbow().colorAt(pos)); } } private onOutsideMove(pos: ClientCoord) { const rp = this.toRainbow(pos); this.onCanvasMove(rp); } private getRainbow(): RainbowCanvas { if (!this.rainbow) { this.rainbow = new RainbowCanvas("/colorpick_rainbow_large.png", new RainbowCoord(400, 200)); } return this.rainbow; } startPick(clickPoint: ClientCoord, startColor: string, onNewHueSatColor: (hsc: string) => void) { const el = this.getFloatEl(); let pos: RainbowCoord; try { pos = this.getRainbow().posFor(startColor); } catch (e) { pos = new RainbowCoord(-999, -999); } this.rainbowOrigin = new ClientCoord( // clickPoint.x - clamp(pos.x, 0, 400), // clickPoint.y - clamp(pos.y, 0, 200) ); el.placeRainbow(this.rainbowOrigin); setTimeout(() => { this.getFloatEl().moveLargeCrosshair(pos); }, 1); el.style.display = "block"; this.currentListener = onNewHueSatColor; } private hide() { const el = this.getFloatEl(); el.style.display = "none"; this.currentListener = undefined; } private toRainbow(pos: ClientCoord): RainbowCoord { return new RainbowCoord( // pos.x - this.rainbowOrigin.x, // pos.y - this.rainbowOrigin.y ); } } export const pickerFloat = new PickerFloat();