Files @ 1082f0725c32
Branch filter:

Location: light9/web/floating_color_picker.ts - annotation

drewp@bigasterisk.com
fix PlayerState semantics
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
4556eebe5d73
// 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<RainbowCoord> = new SubEvent();
  outsideMove: SubEvent<ClientCoord> = new SubEvent();
  mouseUp: SubEvent<void> = new SubEvent();

  render() {
    return html`
      <!-- Temporary scrim on the rest of the page. It looks like we're dimming
            the page to look pretty, but really this is so we can track the mouse
            when it's outside the large canvas. -->
      <div id="outOfBounds" @mousemove=${this.onOutOfBoundsMove} @mouseup=${this.onMouseUp}></div>
      <div id="largeRainbowComp">
        <div id="largeRainbow" @mousemove=${this.onCanvasMove} @mouseup=${this.onMouseUp}></div>
        <div id="largeCrosshair"></div>
      </div>
    `;
  }

  // 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();