-
Michal Malý authoredMichal Malý authored
color-picker.tsx 26.27 KiB
/**
* Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Lada Biedermannová <Lada.Biedermannova@ibt.cas.cz>
* @author Jiří Černý <jiri.cerny@ibt.cas.cz>
* @author Michal Malý <michal.maly@ibt.cas.cz>
* @author Bohdan Schneider <Bohdan.Schneider@ibt.cas.cz>
*/
import React from 'react';
import ReactDOM from 'react-dom';
import { Colors } from './colors';
import { PushButton, SpinBox } from './controls';
import { parseInt as parseIntMS } from '../../mol-io/reader/common/text/number-parser';
import './assets/imgs/triangle-down.svg';
import './assets/imgs/triangle-up.svg';
const PALETTE_CURSOR_HALFSIZE = 10;
const VALUE_CURSOR_THICKNESS = 3;
const MIN_RGB = 0;
const MAX_RGB = 255;
const MIN_HUE = 0;
const MAX_HUE = 359;
const MIN_SATVAL = 0;
const MAX_SATVAL = 100;
function isRgbVal(v: number) {
if (isNaN(v))
return false;
return v >= MIN_RGB && v <= MAX_RGB;
}
function isHueVal(v: number) {
if (isNaN(v))
return false;
return v >= MIN_HUE && v <= MAX_HUE;
}
function isSatValVal(v: number) {
if (isNaN(v))
return false;
return v >= MIN_SATVAL && v <= MAX_SATVAL;
}
function stoi(s: string) {
return parseIntMS(s, 0, s.length);
}
interface State {
h: number;
s: number;
v: number;
restoreOnCancel: boolean;
hIn: string;
sIn: string;
vIn: string;
rIn: string;
gIn: string;
bIn: string;
}
export class ColorPicker extends React.Component<ColorPicker.Props, State> {
private paletteRef: React.RefObject<HTMLCanvasElement>;
private valueColumnRef: React.RefObject<HTMLCanvasElement>;
private selfRef: React.RefObject<HTMLDivElement>;
private mouseListenerAttached: boolean;
private touchListenerAttached: boolean;
private lastPaletteX: number;
private lastPaletteY: number;
private lastValueY: number;
constructor(props: ColorPicker.Props) {
super(props);
this.paletteRef = React.createRef();
this.valueColumnRef = React.createRef();
this.selfRef = React.createRef();
this.mouseListenerAttached = false;
this.touchListenerAttached = false;
this.lastPaletteX = 0;
this.lastPaletteY = 0;
this.lastValueY = 0;
const { h, s, v } = Colors.colorToHsv(this.props.initialColor);
const { r, g, b } = Colors.colorToRgb(this.props.initialColor);
this.state = {
h: Math.round(h),
s,
v,
restoreOnCancel: false,
hIn: h.toString(),
sIn: v.toString(),
vIn: s.toString(),
rIn: r.toString(),
gIn: g.toString(),
bIn: b.toString(),
};
}
private calcLeft() {
const self = this.selfRef.current;
if (!self)
return this.props.left;
const bw = document.body.clientWidth;
const right = self.offsetLeft + self.clientWidth;
const overhang = right - bw;
if (overhang > 0)
return bw - self.clientWidth - 25;
return self.offsetLeft;
}
private calcTop() {
const self = this.selfRef.current;
if (!self)
return this.props.top;
const bh = document.body.clientHeight;
const bottom = self.offsetTop + self.clientHeight;
const overhang = bottom - bh;
if (overhang > 0)
return bh - self.clientHeight - 25;
return self.offsetTop;
}
private changeColorFromPalette(ex: number, ey: number) {
const tainer = this.selfRef.current!;
const palette = this.paletteRef.current!;
let x = ex - tainer.offsetLeft - tainer.clientLeft - palette.offsetLeft - palette.clientLeft;
let y = ey - tainer.offsetTop - tainer.clientTop - palette.offsetTop - palette.clientTop;
if (x < 0)
x = 0;
else if (x >= palette.width)
x = palette.width - 1;
if (y < 0)
y = 0;
else if (y >= palette.height)
y = palette.height - 1;
const { h, s } = this.paletteCoordsToHueSat(x, y);
this.updateColorHsv({ h, s, v: this.state.v });
}
private changeColorFromValue(ey: number) {
const tainer = this.selfRef.current!;
const valCol = this.valueColumnRef.current!;
let y = ey - tainer.offsetTop - tainer.clientTop - valCol.offsetTop - valCol.clientTop;
if (y < 0)
y = 0;
else if (y >= valCol.height)
y = valCol.height - 1;
const v = this.valueColumnCoordToVal(y);
this.updateColorHsv({ h: this.state.h, s: this.state.s, v });
}
private dispose() {
document.body.removeChild(this.props.parentElement);
}
private drawPalette() {
if (!this.paletteRef.current)
return;
const canvas = this.paletteRef.current;
const ctx = canvas.getContext('2d');
if (!ctx)
return;
this.drawPalleteInternal(ctx, canvas.width, canvas.height);
this.drawPaletteCursorInternal(ctx, canvas.width, canvas.height, this.state.h, this.state.s);
}
private drawPalleteInternal(ctx: CanvasRenderingContext2D, width: number, height: number) {
const hueStep = 360 / width;
const satStep = 1.0 / height;
ctx.clearRect(0, 0, width, height);
for (let x = 0; x < width; x++) {
const hue = hueStep * x;
for (let y = 0; y < height; y++) {
const sat = 1.0 - satStep * y;
ctx.fillStyle = Colors.hsvToHexString(hue, sat, 1.0);
ctx.fillRect(x, y, 1, 1);
}
}
}
private drawPaletteCursor(hue: number, sat: number) {
if (!this.paletteRef.current)
return;
const canvas = this.paletteRef.current;
const ctx = canvas.getContext('2d');
if (!ctx)
return;
this.drawPaletteCursorInternal(ctx, canvas.width, canvas.height, hue, sat);
}
private drawPaletteCursorInternal(ctx: CanvasRenderingContext2D, width: number, height: number, hue: number, sat: number) {
const hueStep = 360 / width;
const satStep = 1.0 / height;
const fullSize = Math.floor(PALETTE_CURSOR_HALFSIZE * 2);
let fx = Math.floor(this.lastPaletteX - PALETTE_CURSOR_HALFSIZE - 1);
if (fx < 0) fx = 0;
let tx = fx + fullSize + 2;
if (tx > width) tx = width;
let fy = Math.floor(this.lastPaletteY - PALETTE_CURSOR_HALFSIZE - 1);
if (fy < 0) fy = 0;
let ty = fy + fullSize + 2;
if (ty > height) ty = height;
for (let x = fx; x < tx; x++) {
const hue = hueStep * x;
for (let y = fy; y < ty; y++) {
const sat = 1.0 - satStep * y;
ctx.fillStyle = Colors.hsvToHexString(hue, sat, 1.0);
ctx.fillRect(x, y, 1, 1);
}
}
const cx = Math.round(hue / hueStep);
const cy = Math.round((1.0 - sat) / satStep);
ctx.beginPath();
ctx.fillStyle = 'rgba(0, 0, 0, 1.0)';
ctx.lineWidth = 2;
ctx.moveTo(cx - PALETTE_CURSOR_HALFSIZE, cy);
ctx.lineTo(cx + PALETTE_CURSOR_HALFSIZE, cy);
ctx.moveTo(cx, cy - PALETTE_CURSOR_HALFSIZE);
ctx.lineTo(cx, cy + PALETTE_CURSOR_HALFSIZE);
ctx.closePath();
ctx.stroke();
this.lastPaletteX = cx;
this.lastPaletteY = cy;
}
private drawValueColumn() {
if (!this.valueColumnRef.current)
return;
const canvas = this.valueColumnRef.current;
const ctx = canvas.getContext('2d');
if (!ctx)
return;
this.drawValueColumnInternal(ctx, canvas.width, canvas.height);
this.drawValueColumnCursorInternal(ctx, canvas.width, canvas.height, this.state.v);
}
private drawValueColumnInternal(ctx: CanvasRenderingContext2D, width: number, height: number) {
const valStep = 1.0 / height;
for (let y = 0; y < height; y++) {
const cv = 1.0 - y * valStep;
ctx.fillStyle = Colors.hsvToHexString(this.state.h, this.state.s, cv);
ctx.fillRect(0, y, width, 1);
}
}
private drawValueColumnCursor(val: number) {
if (!this.valueColumnRef.current)
return;
const canvas = this.valueColumnRef.current;
const ctx = canvas.getContext('2d');
if (!ctx)
return;
this.drawValueColumnCursorInternal(ctx, canvas.width, canvas.height, val);
}
private drawValueColumnCursorInternal(ctx: CanvasRenderingContext2D, width: number, height: number, val: number) {
const valStep = 1.0 / height;
let fy = Math.floor(this.lastValueY - 1);
if (fy < 0) fy = 0;
let ty = Math.floor(fy + VALUE_CURSOR_THICKNESS + 2);
if (ty > height)
ty = height;
for (let y = fy; y < ty; y++) {
const cv = 1.0 - y * valStep;
ctx.fillStyle = Colors.hsvToHexString(this.state.h, this.state.s, cv);
ctx.fillRect(0, y, width, 1);
}
const y = Math.round((1.0 - val) / valStep);
const halfWidth = Math.round(width / 2);
ctx.fillStyle = 'rgba(255, 255, 255, 1.0)';
ctx.fillRect(0, y, halfWidth, VALUE_CURSOR_THICKNESS);
ctx.fillStyle = 'rgba(0, 0, 0, 1.0)';
ctx.fillRect(halfWidth, y, width - halfWidth, VALUE_CURSOR_THICKNESS);
this.lastValueY = y;
}
private paletteCoordsToHueSat(x: number, y: number) {
const palette = this.paletteRef.current!;
const h = 360 * x / palette.width;
const s = 1.0 - 1.0 * y / palette.height;
return { h, s };
}
private onGlobalMouseMovedValue = (evt: MouseEvent) => {
if ((evt.buttons & 1) === 0) {
window.removeEventListener('mousemove', this.onGlobalMouseMovedValue);
this.mouseListenerAttached = false;
return;
}
this.changeColorFromValue(evt.pageY);
};
private onGlobalMouseMovedPalette = (evt: MouseEvent) => {
if ((evt.buttons & 1) === 0) {
window.removeEventListener('mousemove', this.onGlobalMouseMovedPalette);
this.mouseListenerAttached = false;
return;
}
this.changeColorFromPalette(evt.pageX, evt.pageY);
};
private onGlobalTouchMovedPalette = (evt: TouchEvent) => {
if (evt.touches.length !== 0)
this.changeColorFromPalette(evt.touches[0].pageX, evt.touches[0].pageY);
};
private updateColorHsv(hsv: { h: number, s: number, v: number }) {
const rgb = Colors.hsv2rgb(hsv.h, hsv.s, hsv.v);
this.updateColor(rgb, hsv);
}
private updateColorRgb(rgb: { r: number, g: number, b: number }) {
const hsv = Colors.rgb2hsv(rgb.r, rgb.g, rgb.b);
this.updateColor(rgb, hsv);
}
private updateColor(rgb: { r: number, g: number, b: number }, hsv: { h: number, s: number, v: number }) {
let update: Partial<State> = { ...hsv };
if (this.state.rIn === '')
update = { ...update, rIn: rgb.r.toString() };
if (this.state.gIn === '')
update = { ...update, gIn: rgb.g.toString() };
if (this.state.bIn === '')
update = { ...update, bIn: rgb.b.toString() };
if (this.state.hIn === '')
update = { ...update, hIn: hsv.h.toString() };
if (this.state.sIn === '')
update = { ...update, sIn: hsv.s.toString() };
if (this.state.vIn === '')
update = { ...update, vIn: hsv.v.toString() };
this.setState({
...this.state,
...update,
});
}
private onGlobalTouchMovedValue = (evt: TouchEvent) => {
if (evt.touches.length !== 0)
this.changeColorFromValue(evt.touches[0].pageY);
};
private valueColumnCoordToVal(y: number) {
const valCol = this.valueColumnRef.current!;
return 1.0 - 1.0 * y / valCol.height;
}
componentDidMount() {
this.drawPalette();
this.drawValueColumn();
this.forceUpdate();
}
componentDidUpdate(_prevProps: ColorPicker.Props, prevState: State) {
if (this.state.h !== prevState.h || this.state.s !== prevState.s) {
this.drawPaletteCursor(this.state.h, this.state.s);
this.drawValueColumn();
}
if (this.state.v !== prevState.v) {
this.drawValueColumnCursor(this.state.v);
}
}
componentWillUnmount() {
window.removeEventListener('mousemove', this.onGlobalMouseMovedValue);
window.removeEventListener('mousemove', this.onGlobalMouseMovedPalette);
window.removeEventListener('touchmove', this.onGlobalTouchMovedValue);
window.removeEventListener('touchmove', this.onGlobalTouchMovedPalette);
}
render() {
return (
<div
ref={this.selfRef}
style={{
background: 'white',
border: '0.15em solid #ccc',
boxShadow: '0 0 0.3em 0 rgba(0, 0, 0, 0.5)',
left: this.calcLeft(),
padding: '0.5em',
position: 'absolute',
top: this.calcTop(),
zIndex: 99,
}}
>
<div
style={{
display: 'grid',
gridColumnGap: '0.5em',
gridTemplateColumns: 'auto auto',
marginBottom: '0.5em',
}}
>
<canvas
width={360}
height={256}
ref={this.paletteRef}
onMouseDown={evt => {
if ((evt.buttons & 1) === 0 || this.mouseListenerAttached)
return;
this.changeColorFromPalette(evt.pageX, evt.pageY);
this.mouseListenerAttached = true;
window.addEventListener('mousemove', this.onGlobalMouseMovedPalette);
}}
onMouseUp={evt => {
if (evt.buttons & 1) {
window.removeEventListener('mousemove', this.onGlobalMouseMovedPalette);
this.mouseListenerAttached = false;
}
}}
onTouchStart={evt => {
if (this.touchListenerAttached)
return;
window.addEventListener('touchmove', this.onGlobalTouchMovedPalette);
this.touchListenerAttached = true;
if (evt.touches.length !== 0)
this.changeColorFromPalette(evt.touches[0].pageX, evt.touches[0].pageY);
}}
onTouchEnd={_evt => {
window.removeEventListener('touchmove', this.onGlobalTouchMovedPalette);
this.touchListenerAttached = false;
}}
/>
<canvas
width={30}
height={256}
ref={this.valueColumnRef}
style={{
height: '100%',
width: '1em',
}}
onMouseDown={evt => {
if ((evt.buttons & 1) === 0 || this.mouseListenerAttached)
return;
this.changeColorFromValue(evt.pageY);
this.mouseListenerAttached = true;
window.addEventListener('mousemove', this.onGlobalMouseMovedValue);
}}
onMouseUp={evt => {
if (evt.buttons & 1) {
window.removeEventListener('mousemove', this.onGlobalMouseMovedValue);
this.mouseListenerAttached = false;
}
}}
onTouchStart={evt => {
if (this.touchListenerAttached)
return;
window.addEventListener('touchmove', this.onGlobalTouchMovedValue);
this.touchListenerAttached = true;
if (evt.touches.length !== 0)
this.changeColorFromValue(evt.touches[0].pageY);
}}
onTouchEnd={_evt => {
window.removeEventListener('touchmove', this.onGlobalTouchMovedValue);
this.touchListenerAttached = false;
}}
onWheel={evt => {
if (evt.deltaY === 0)
return;
let v = this.state.v - 0.01 * Math.sign(evt.deltaY);
if (v < 0)
v = 0;
else if (v > 1)
v = 1;
this.setState({ ...this.state, v });
}}
/>
</div>
<div
style={{
display: 'flex',
marginBottom: '0.5em',
}}
>
<div
style={{
background: Colors.colorToHexString(this.props.initialColor),
flex: 1,
height: '2em',
}}
/>
<div
style={{
background: Colors.colorToHexString(Colors.colorFromHsv(this.state.h, this.state.s, this.state.v)),
flex: 1,
height: '2em',
}}
/>
</div>
<div
style={{
display: 'grid',
gridColumnGap: '0.5em',
gridTemplateColumns: 'auto 4em auto 4em auto 4em',
marginBottom: '0.5em',
}}
>
<div>R</div>
<SpinBox
min={MIN_RGB}
max={MAX_RGB}
step={1}
value={this.state.rIn === '' ? null : Math.round(Colors.hsv2rgb(this.state.h, this.state.s, this.state.v).r)}
onChange={rIn => {
if (rIn === '')
this.setState({ ...this.state, rIn });
else {
const r = stoi(rIn);
if (!isRgbVal(r))
return;
const { g, b } = Colors.hsv2rgb(this.state.h, this.state.s, this.state.v);
this.updateColorRgb({ r, g, b });
}
}}
pathPrefix={this.props.pathPrefix}
/>
<div>G</div>
<SpinBox
min={MIN_RGB}
max={MAX_RGB}
step={1}
value={this.state.gIn === '' ? null : Math.round(Colors.hsv2rgb(this.state.h, this.state.s, this.state.v).g)}
onChange={gIn => {
if (gIn === '')
this.setState({ ...this.state, gIn });
else {
const g = stoi(gIn);
if (!isRgbVal(g))
return;
const { r, b } = Colors.hsv2rgb(this.state.h, this.state.s, this.state.v);
this.updateColorRgb({ r, g, b });
}
}}
pathPrefix={this.props.pathPrefix}
/>
<div>B</div>
<SpinBox
min={MIN_RGB}
max={MAX_RGB}
step={1}
value={this.state.bIn === '' ? null : Math.round(Colors.hsv2rgb(this.state.h, this.state.s, this.state.v).b)}
onChange={bIn => {
if (bIn === '')
this.setState({ ...this.state, bIn });
else {
const b = stoi(bIn);
if (!isRgbVal(b))
return;
const { r, g } = Colors.hsv2rgb(this.state.h, this.state.s, this.state.v);
this.updateColorRgb({ r, g, b });
}
}}
pathPrefix={this.props.pathPrefix}
/>
</div>
<div
style={{
display: 'grid',
gridColumnGap: '0.5em',
gridTemplateColumns: 'auto 4em auto 4em auto 4em',
marginBottom: '0.5em',
}}
>
<div>H</div>
<SpinBox
min={MIN_HUE}
max={MAX_HUE}
step={1}
value={this.state.hIn === '' ? null : Math.round(this.state.h)}
onChange={hIn => {
if (hIn === '')
this.setState({ ...this.state, hIn });
else {
const h = stoi(hIn);
if (!isHueVal(h))
return;
this.updateColorHsv({ h, s: this.state.s, v: this.state.v });
}
}}
pathPrefix={this.props.pathPrefix}
/>
<div>S</div>
<SpinBox
min={MIN_SATVAL}
max={MAX_SATVAL}
step={1}
value={this.state.sIn === '' ? null : Math.round(this.state.s * 100)}
onChange={sIn => {
if (sIn === '')
this.setState({ ...this.state, sIn });
else {
const s = stoi(sIn);
if (!isSatValVal(s))
return;
this.updateColorHsv({ h: this.state.h, s: s / 100, v: this.state.v });
}
}}
pathPrefix={this.props.pathPrefix}
/>
<div>V</div>
<SpinBox
min={MIN_SATVAL}
max={MAX_SATVAL}
step={1}
value={this.state.vIn === '' ? null : Math.round(this.state.v * 100)}
onChange={vIn => {
if (vIn === '')
this.setState({ ...this.state, vIn });
else {
const v = stoi(vIn);
if (!isSatValVal(v))
return;
this.updateColorHsv({ h: this.state.h, s: this.state.s, v: v / 100 });
}
}}
pathPrefix={this.props.pathPrefix}
/>
</div>
<div
style={{
display: 'flex',
gap: '0.5em',
}}
>
<PushButton
text='OK'
onClick={() => {
this.props.onColorPicked(Colors.colorFromHsv(this.state.h, this.state.s, this.state.v));
this.dispose();
}}
enabled={true}
/>
<PushButton
text='Preview'
onClick={() => {
this.props.onColorPicked(Colors.colorFromHsv(this.state.h, this.state.s, this.state.v));
this.setState({ ...this.state, restoreOnCancel: true });
}}
enabled={true}
/>
<PushButton
text='Cancel'
onClick={() => {
if (this.state.restoreOnCancel)
this.props.onColorPicked(this.props.initialColor);
this.dispose();
}}
enabled={true}
/>
</div>
</div>
);
}
}
export namespace ColorPicker {
export interface OnColorPicked {
(color: number): void;
}
export interface Props {
initialColor: number;
left: number;
top: number;
onColorPicked: OnColorPicked;
parentElement: HTMLElement;
pathPrefix: string;
}
export function create<T>(evt: React.MouseEvent<T, MouseEvent>, initialColor: number, handler: OnColorPicked, pathPrefix = '') {
const tainer = document.createElement('div');
tainer.classList.add('rmsp-color-picker-nest');
document.body.appendChild(tainer);
ReactDOM.render(
<ColorPicker
initialColor={initialColor}
left={evt.clientX}
top={evt.clientY}
onColorPicked={handler}
parentElement={tainer}
pathPrefix={pathPrefix}
/>,
tainer
);
}
}