mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2025-12-23 10:11:54 +00:00
Implement color picker for primary/secondary working colors (#70)
* feat/color-picker: rework * feat(161) lint * feat(161) Remove response handlers * feat(161) fix rgb <-> hsv conversion * feat(161) inverse swatchs and add checkered bg * feat(161) remove temporary color assignment * feat(161) move cursor outside of the box * feat(161) @Keavon feedbacks * feat(161) lint * feat(161) fix opacity-picker color * feat(161) --saturation-picker-color
This commit is contained in:
parent
a70605b514
commit
b56dfd746f
4 changed files with 329 additions and 31 deletions
|
|
@ -154,6 +154,7 @@
|
|||
height: 100%;
|
||||
|
||||
svg {
|
||||
background: #ffffff;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
|
@ -222,10 +223,6 @@ export default defineComponent({
|
|||
}
|
||||
todo(toolIndex);
|
||||
},
|
||||
async updatePrimaryColor(c: { r: number; g: number; b: number; a: number }) {
|
||||
const { update_primary_color, Color } = await wasm;
|
||||
update_primary_color(new Color(c.r, c.g, c.b, c.a));
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
registerResponseHandler(ResponseType.UpdateCanvas, (responseData: Response) => {
|
||||
|
|
@ -239,9 +236,6 @@ export default defineComponent({
|
|||
|
||||
window.addEventListener("keyup", (e: KeyboardEvent) => this.keyUp(e));
|
||||
window.addEventListener("keydown", (e: KeyboardEvent) => this.keyDown(e));
|
||||
|
||||
// TODO: Implement an actual UI for chosing colors (this is completely temporary)
|
||||
this.updatePrimaryColor({ r: 247 / 255, g: 76 / 255, b: 0 / 255, a: 0.6 });
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,30 +1,31 @@
|
|||
<template>
|
||||
<div class="popover-color-picker" @click="notImplemented">
|
||||
<div class="color-picker">
|
||||
<div class="selection-circle"></div>
|
||||
<div class="popover-color-picker">
|
||||
<div class="saturation-picker" ref="saturationPicker" data-picker-action="MoveSaturation" @pointerdown="onPointerDown">
|
||||
<div ref="saturationCursor" class="selection-circle"></div>
|
||||
</div>
|
||||
<div class="hue-picker">
|
||||
<div class="selection-pincers"></div>
|
||||
<div class="hue-picker" ref="huePicker" data-picker-action="MoveHue" @pointerdown="onPointerDown">
|
||||
<div ref="hueCursor" class="selection-pincers"></div>
|
||||
</div>
|
||||
<div class="opacity-picker">
|
||||
<div class="selection-pincers"></div>
|
||||
<div class="opacity-picker" ref="opacityPicker" data-picker-action="MoveOpacity" @pointerdown="onPointerDown">
|
||||
<div ref="opacityCursor" class="selection-pincers"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.popover-color-picker {
|
||||
--saturation-picker-color: #ff0000;
|
||||
--opacity-picker-color: #ff0000;
|
||||
display: flex;
|
||||
|
||||
.color-picker {
|
||||
--hue: #ff0000;
|
||||
.saturation-picker {
|
||||
width: 256px;
|
||||
background-blend-mode: multiply;
|
||||
background: linear-gradient(to bottom, #ffffff, #000000), linear-gradient(to right, #ffffff, var(--hue));
|
||||
background: linear-gradient(to bottom, #ffffff, #000000), linear-gradient(to right, #ffffff, var(--saturation-picker-color));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.color-picker,
|
||||
.saturation-picker,
|
||||
.hue-picker,
|
||||
.opacity-picker {
|
||||
height: 256px;
|
||||
|
|
@ -46,7 +47,7 @@
|
|||
}
|
||||
|
||||
.opacity-picker {
|
||||
background: linear-gradient(to bottom, #ff0000, transparent);
|
||||
background: linear-gradient(to bottom, var(--opacity-picker-color), transparent);
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
|
|
@ -64,10 +65,11 @@
|
|||
|
||||
.selection-circle {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
left: 0%;
|
||||
top: 0%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
|
|
@ -89,6 +91,7 @@
|
|||
top: 0%;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
|
|
@ -115,13 +118,181 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import { clamp, hsvToRgb, rgbToHsv, isRGB } from "../../lib/utils";
|
||||
|
||||
const enum ColorPickerState {
|
||||
Idle = "Idle",
|
||||
MoveHue = "MoveHue",
|
||||
MoveOpacity = "MoveOpacity",
|
||||
MoveSaturation = "MoveSaturation",
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
components: {},
|
||||
props: {},
|
||||
props: {
|
||||
color: {
|
||||
type: Object,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
state: ColorPickerState.Idle,
|
||||
// Disable proxy on this object
|
||||
// https://v3.vuejs.org/api/options-data.html#data-2
|
||||
// eslint-disable-next-line vue/no-reserved-keys
|
||||
_: {
|
||||
colorPicker: {
|
||||
color: { h: 0, s: 0, v: 0, a: 1 },
|
||||
hue: {
|
||||
rect: { width: 0, height: 0, top: 0, left: 0 },
|
||||
},
|
||||
opacity: {
|
||||
rect: { width: 0, height: 0, top: 0, left: 0 },
|
||||
},
|
||||
saturation: {
|
||||
rect: { width: 0, height: 0, top: 0, left: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$watch("color", this.updateColor, { immediate: true });
|
||||
},
|
||||
unmounted() {
|
||||
this.removeEvents();
|
||||
},
|
||||
methods: {
|
||||
notImplemented() {
|
||||
alert("Color picker is not functional yet");
|
||||
addEvents() {
|
||||
document.addEventListener("pointermove", this.onPointerMove);
|
||||
document.addEventListener("pointerup", this.onPointerUp);
|
||||
},
|
||||
|
||||
removeEvents() {
|
||||
document.removeEventListener("pointermove", this.onPointerMove);
|
||||
document.removeEventListener("pointerup", this.onPointerUp);
|
||||
},
|
||||
|
||||
getRef<T>(name: string) {
|
||||
return this.$refs[name] as T;
|
||||
},
|
||||
|
||||
onPointerDown(e: PointerEvent) {
|
||||
if (!(e.currentTarget instanceof Element)) return;
|
||||
const picker = e.currentTarget.getAttribute("data-picker-action");
|
||||
this.state = (() => {
|
||||
switch (picker) {
|
||||
case "MoveHue":
|
||||
return ColorPickerState.MoveHue;
|
||||
case "MoveOpacity":
|
||||
return ColorPickerState.MoveOpacity;
|
||||
case "MoveSaturation":
|
||||
return ColorPickerState.MoveSaturation;
|
||||
default:
|
||||
return ColorPickerState.Idle;
|
||||
}
|
||||
})();
|
||||
|
||||
if (this.state !== ColorPickerState.Idle) {
|
||||
this.addEvents();
|
||||
this.updateRects();
|
||||
this.onPointerMove(e);
|
||||
}
|
||||
},
|
||||
|
||||
onPointerMove(e: PointerEvent) {
|
||||
const { colorPicker } = this.$data._;
|
||||
|
||||
if (this.state === ColorPickerState.MoveHue) {
|
||||
this.setHuePosition(e.clientY - colorPicker.hue.rect.top);
|
||||
} else if (this.state === ColorPickerState.MoveOpacity) {
|
||||
this.setOpacityPosition(e.clientY - colorPicker.opacity.rect.top);
|
||||
} else if (this.state === ColorPickerState.MoveSaturation) {
|
||||
this.setSaturationPosition(e.clientX - colorPicker.saturation.rect.left, e.clientY - colorPicker.saturation.rect.top);
|
||||
}
|
||||
|
||||
if (this.state !== ColorPickerState.Idle) {
|
||||
this.updateHue();
|
||||
this.$emit("update:color", hsvToRgb(colorPicker.color));
|
||||
}
|
||||
},
|
||||
|
||||
onPointerUp() {
|
||||
if (this.state !== ColorPickerState.Idle) {
|
||||
this.state = ColorPickerState.Idle;
|
||||
this.removeEvents();
|
||||
}
|
||||
},
|
||||
|
||||
updateRects() {
|
||||
const { colorPicker } = this.$data._;
|
||||
|
||||
const saturationPicker = this.getRef<HTMLDivElement>("saturationPicker");
|
||||
const saturation = saturationPicker.getBoundingClientRect();
|
||||
colorPicker.saturation.rect.width = saturation.width;
|
||||
colorPicker.saturation.rect.height = saturation.height;
|
||||
colorPicker.saturation.rect.left = saturation.left;
|
||||
colorPicker.saturation.rect.top = saturation.top;
|
||||
|
||||
const huePicker = this.getRef<HTMLDivElement>("huePicker");
|
||||
const hue = huePicker.getBoundingClientRect();
|
||||
colorPicker.hue.rect.width = hue.width;
|
||||
colorPicker.hue.rect.height = hue.height;
|
||||
colorPicker.hue.rect.left = hue.left;
|
||||
colorPicker.hue.rect.top = hue.top;
|
||||
|
||||
const opacityPicker = this.getRef<HTMLDivElement>("opacityPicker");
|
||||
const opacity = opacityPicker.getBoundingClientRect();
|
||||
colorPicker.opacity.rect.width = opacity.width;
|
||||
colorPicker.opacity.rect.height = opacity.height;
|
||||
colorPicker.opacity.rect.left = opacity.left;
|
||||
colorPicker.opacity.rect.top = opacity.top;
|
||||
},
|
||||
|
||||
setSaturationPosition(x: number, y: number) {
|
||||
const { colorPicker } = this.$data._;
|
||||
const saturationCursor = this.getRef<HTMLDivElement>("saturationCursor");
|
||||
const saturationPosition = [clamp(x, 0, colorPicker.saturation.rect.width), clamp(y, 0, colorPicker.saturation.rect.height)];
|
||||
saturationCursor.style.transform = `translate(${saturationPosition[0]}px, ${saturationPosition[1]}px)`;
|
||||
colorPicker.color.s = saturationPosition[0] / colorPicker.saturation.rect.width;
|
||||
colorPicker.color.v = (1 - saturationPosition[1] / colorPicker.saturation.rect.height) * 255;
|
||||
},
|
||||
|
||||
setHuePosition(y: number) {
|
||||
const { colorPicker } = this.$data._;
|
||||
const hueCursor = this.getRef<HTMLDivElement>("hueCursor");
|
||||
const huePosition = clamp(y, 0, colorPicker.hue.rect.height);
|
||||
hueCursor.style.transform = `translateY(${huePosition}px)`;
|
||||
colorPicker.color.h = clamp(1 - huePosition / colorPicker.hue.rect.height);
|
||||
},
|
||||
|
||||
setOpacityPosition(y: number) {
|
||||
const { colorPicker } = this.$data._;
|
||||
const opacityCursor = this.getRef<HTMLDivElement>("opacityCursor");
|
||||
const opacityPosition = clamp(y, 0, colorPicker.opacity.rect.height);
|
||||
opacityCursor.style.transform = `translateY(${opacityPosition}px)`;
|
||||
colorPicker.color.a = clamp(1 - opacityPosition / colorPicker.opacity.rect.height);
|
||||
},
|
||||
|
||||
updateHue() {
|
||||
const { colorPicker } = this.$data._;
|
||||
let color = hsvToRgb({ h: colorPicker.color.h, s: 1, v: 255, a: 1 });
|
||||
this.$el.style.setProperty("--saturation-picker-color", `rgb(${color.r}, ${color.g}, ${color.b})`);
|
||||
color = hsvToRgb(colorPicker.color);
|
||||
this.$el.style.setProperty("--opacity-picker-color", `rgb(${color.r}, ${color.g}, ${color.b})`);
|
||||
},
|
||||
|
||||
updateColor() {
|
||||
if (this.state !== ColorPickerState.Idle) return;
|
||||
const { color } = this;
|
||||
if (!isRGB(color)) return;
|
||||
const { colorPicker } = this.$data._;
|
||||
colorPicker.color = rgbToHsv(color);
|
||||
this.updateRects();
|
||||
this.setSaturationPosition(colorPicker.color.s * colorPicker.saturation.rect.width, (1 - colorPicker.color.v / 255) * colorPicker.saturation.rect.height);
|
||||
this.setOpacityPosition((1 - colorPicker.color.a) * colorPicker.opacity.rect.height);
|
||||
this.setHuePosition((1 - colorPicker.color.h) * colorPicker.hue.rect.height);
|
||||
this.updateHue();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
<template>
|
||||
<div class="swatch-pair">
|
||||
<div class="secondary swatch">
|
||||
<button @click="clickSecondarySwatch" style="background: #ffffff"></button>
|
||||
<button @click="clickSecondarySwatch" ref="secondaryButton"></button>
|
||||
<Popover :direction="PopoverDirection.Right" horizontal ref="secondarySwatchPopover">
|
||||
<ColorPicker />
|
||||
<ColorPicker v-model:color="secondaryColor" />
|
||||
</Popover>
|
||||
</div>
|
||||
<div class="primary swatch">
|
||||
<button @click="clickPrimarySwatch" style="background: #000000"></button>
|
||||
<button @click="clickPrimarySwatch" ref="primaryButton"></button>
|
||||
<Popover :direction="PopoverDirection.Right" horizontal ref="primarySwatchPopover">
|
||||
<ColorPicker />
|
||||
<ColorPicker v-model:color="primaryColor" />
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -28,6 +28,7 @@
|
|||
position: relative;
|
||||
|
||||
button {
|
||||
--swatch-color: #ffffff;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
|
|
@ -37,6 +38,19 @@
|
|||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
background: linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%), linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%),
|
||||
linear-gradient(#ffffff, #ffffff);
|
||||
background-size: 16px 16px;
|
||||
background-position: 0 0, 8px 8px;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--swatch-color);
|
||||
}
|
||||
}
|
||||
|
||||
.popover {
|
||||
|
|
@ -52,10 +66,13 @@
|
|||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { rgbToDecimalRgb } from "@/lib/utils";
|
||||
import { defineComponent } from "vue";
|
||||
import ColorPicker from "../../popovers/ColorPicker.vue";
|
||||
import Popover, { PopoverDirection } from "../overlays/Popover.vue";
|
||||
|
||||
const wasm = import("../../../../wasm/pkg");
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
Popover,
|
||||
|
|
@ -64,18 +81,51 @@ export default defineComponent({
|
|||
props: {},
|
||||
methods: {
|
||||
clickPrimarySwatch() {
|
||||
(this.$refs.primarySwatchPopover as typeof Popover).setOpen();
|
||||
(this.$refs.secondarySwatchPopover as typeof Popover).setClosed();
|
||||
this.getRef<typeof Popover>("primarySwatchPopover").setOpen();
|
||||
this.getRef<typeof Popover>("secondarySwatchPopover").setClosed();
|
||||
},
|
||||
|
||||
clickSecondarySwatch() {
|
||||
(this.$refs.secondarySwatchPopover as typeof Popover).setOpen();
|
||||
(this.$refs.primarySwatchPopover as typeof Popover).setClosed();
|
||||
this.getRef<typeof Popover>("secondarySwatchPopover").setOpen();
|
||||
this.getRef<typeof Popover>("primarySwatchPopover").setClosed();
|
||||
},
|
||||
|
||||
getRef<T>(name: string) {
|
||||
return this.$refs[name] as T;
|
||||
},
|
||||
|
||||
async updatePrimaryColor() {
|
||||
const { update_primary_color, Color } = await wasm;
|
||||
|
||||
let color = this.primaryColor;
|
||||
const button = this.getRef<HTMLButtonElement>("primaryButton");
|
||||
button.style.setProperty("--swatch-color", `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`);
|
||||
|
||||
color = rgbToDecimalRgb(this.primaryColor);
|
||||
update_primary_color(new Color(color.r, color.g, color.b, color.a));
|
||||
},
|
||||
|
||||
async updateSecondaryColor() {
|
||||
const { update_secondary_color, Color } = await wasm;
|
||||
|
||||
let color = this.secondaryColor;
|
||||
const button = this.getRef<HTMLButtonElement>("secondaryButton");
|
||||
button.style.setProperty("--swatch-color", `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`);
|
||||
|
||||
color = rgbToDecimalRgb(this.secondaryColor);
|
||||
update_secondary_color(new Color(color.r, color.g, color.b, color.a));
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
PopoverDirection,
|
||||
primaryColor: { r: 0, g: 0, b: 0, a: 1 },
|
||||
secondaryColor: { r: 255, g: 255, b: 255, a: 1 },
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$watch("primaryColor", this.updatePrimaryColor, { immediate: true });
|
||||
this.$watch("secondaryColor", this.updateSecondaryColor, { immediate: true });
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
83
client/web/src/lib/utils.ts
Normal file
83
client/web/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
export interface RGB {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a: number;
|
||||
}
|
||||
|
||||
export interface HSV {
|
||||
h: number;
|
||||
s: number;
|
||||
v: number;
|
||||
a: number;
|
||||
}
|
||||
|
||||
export function hsvToRgb(hsv: HSV): RGB {
|
||||
let { h } = hsv;
|
||||
const { s, v } = hsv;
|
||||
h *= 6;
|
||||
const i = Math.floor(h);
|
||||
const f = h - i;
|
||||
const p = v * (1 - s);
|
||||
const q = v * (1 - f * s);
|
||||
const t = v * (1 - (1 - f) * s);
|
||||
const mod = i % 6;
|
||||
const r = Math.round([v, q, p, p, t, v][mod]);
|
||||
const g = Math.round([t, v, v, q, p, p][mod]);
|
||||
const b = Math.round([p, p, t, v, v, q][mod]);
|
||||
return { r, g, b, a: hsv.a };
|
||||
}
|
||||
|
||||
export function rgbToHsv(rgb: RGB) {
|
||||
const { r, g, b } = rgb;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const d = max - min;
|
||||
const s = max === 0 ? 0 : d / max;
|
||||
const v = max;
|
||||
let h = 0;
|
||||
if (max === min) {
|
||||
h = 0;
|
||||
} else {
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0);
|
||||
break;
|
||||
case g:
|
||||
h = (b - r) / d + 2;
|
||||
break;
|
||||
case b:
|
||||
h = (r - g) / d + 4;
|
||||
break;
|
||||
default:
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
return { h, s, v, a: rgb.a };
|
||||
}
|
||||
|
||||
export function rgbToDecimalRgb(rgb: RGB) {
|
||||
const r = rgb.r / 255;
|
||||
const g = rgb.g / 255;
|
||||
const b = rgb.b / 255;
|
||||
return { r, g, b, a: rgb.a };
|
||||
}
|
||||
|
||||
export function clamp(value: number, min = 0, max = 1) {
|
||||
return Math.max(min, Math.min(value, max));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function isRGB(data: any): data is RGB {
|
||||
if (typeof data !== "object" || data === null) return false;
|
||||
return (
|
||||
typeof data.r === "number" &&
|
||||
!Number.isNaN(data.r) &&
|
||||
typeof data.g === "number" &&
|
||||
!Number.isNaN(data.g) &&
|
||||
typeof data.b === "number" &&
|
||||
!Number.isNaN(data.b) &&
|
||||
typeof data.a === "number" &&
|
||||
!Number.isNaN(data.a)
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue