Refactor color picker floating menu (#809)

* Move FloatingMenu template component

* Rewrite the ColorPicker component so it's not horrifically bad code

* Move FloatingMenu into the ColorPicker component

* Little Imaginate fixes

* Add todo
This commit is contained in:
Keavon Chambers
2022-10-23 17:29:04 -07:00
parent 1219e26d17
commit 7f9c59dd99
14 changed files with 177 additions and 290 deletions
@@ -990,7 +990,7 @@ impl DocumentMessageHandler {
DocumentRenderMode::OnlyBelowLayerInFolder(below_layer_path) => self.graphene_document.render_layers_below(below_layer_path, render_data).unwrap(), DocumentRenderMode::OnlyBelowLayerInFolder(below_layer_path) => self.graphene_document.render_layers_below(below_layer_path, render_data).unwrap(),
}; };
let artboards = self.artboard_message_handler.artboards_graphene_document.render_root(render_data); let artboards = self.artboard_message_handler.artboards_graphene_document.render_root(render_data);
let outside_artboards_color = if self.artboard_message_handler.artboard_ids.is_empty() { "#ffffff" } else { "#000000" }; let outside_artboards_color = if self.artboard_message_handler.artboard_ids.is_empty() { "#ffffff" } else { "#222222" };
let outside_artboards = format!(r#"<rect x="0" y="0" width="100%" height="100%" fill="{}" />"#, outside_artboards_color); let outside_artboards = format!(r#"<rect x="0" y="0" width="100%" height="100%" fill="{}" />"#, outside_artboards_color);
let matrix = transform let matrix = transform
.to_cols_array() .to_cols_array()
@@ -914,7 +914,7 @@ fn node_section_imaginate(imaginate_layer: &ImaginateLayer, layer: &Layer, persi
\n\ \n\
Include an artist name like \"Rembrandt\" or art medium like \"watercolor\" or \"photography\" to influence the look. List multiple to meld styles.\n\ Include an artist name like \"Rembrandt\" or art medium like \"watercolor\" or \"photography\" to influence the look. List multiple to meld styles.\n\
\n\ \n\
To boost (or lessen) the importance of a word or phrase, wrap it in quotes ending with a colon and a multiplier, for example:\n\ To boost (or lessen) the importance of a word or phrase, wrap it in parentheses ending with a colon and a multiplier, for example:\n\
\"Colorless green ideas (sleep:1.3) furiously\" \"Colorless green ideas (sleep:1.3) furiously\"
" "
.trim() .trim()
@@ -1,30 +1,29 @@
<template> <template>
<LayoutRow class="color-picker"> <FloatingMenu :open="open" @update:open="(isOpen) => emitOpenState(isOpen)" :direction="direction" :type="'Popover'">
<LayoutCol class="saturation-picker" ref="saturationPicker" @pointerdown="(e: PointerEvent) => onPointerDown(e)"> <LayoutRow class="color-picker">
<div ref="saturationCursor" class="selection-circle"></div> <LayoutCol class="saturation-value-picker" :style="{ '--saturation-value-picker-hue': hueColorCSS }" @pointerdown="(e: PointerEvent) => beginDrag(e)" data-saturation-value-picker>
</LayoutCol> <div class="selection-circle" :style="{ top: `${(1 - value) * 100}%`, left: `${saturation * 100}%` }"></div>
<LayoutCol class="hue-picker" ref="huePicker" @pointerdown="(e: PointerEvent) => onPointerDown(e)"> </LayoutCol>
<div ref="hueCursor" class="selection-pincers"></div> <LayoutCol class="hue-picker" @pointerdown="(e: PointerEvent) => beginDrag(e)" data-hue-picker>
</LayoutCol> <div class="selection-pincers" :style="{ top: `${(1 - hue) * 100}%` }"></div>
<LayoutCol class="opacity-picker" ref="opacityPicker" @pointerdown="(e: PointerEvent) => onPointerDown(e)"> </LayoutCol>
<div ref="opacityCursor" class="selection-pincers"></div> <LayoutCol class="opacity-picker" :style="{ '--opacity-picker-color': color.toRgbCSS() }" @pointerdown="(e: PointerEvent) => beginDrag(e)" data-opacity-picker>
</LayoutCol> <div class="selection-pincers" :style="{ top: `${(1 - opacity) * 100}%` }"></div>
</LayoutRow> </LayoutCol>
</LayoutRow>
</FloatingMenu>
</template> </template>
<style lang="scss"> <style lang="scss">
.color-picker { .color-picker {
--saturation-picker-hue: #ff0000; .saturation-value-picker {
--opacity-picker-color: #ff0000;
.saturation-picker {
width: 256px; width: 256px;
background-blend-mode: multiply; background-blend-mode: multiply;
background: linear-gradient(to bottom, #ffffff, #000000), linear-gradient(to right, #ffffff, var(--saturation-picker-hue)); background: linear-gradient(to bottom, #ffffff, #000000), linear-gradient(to right, #ffffff, var(--saturation-value-picker-hue));
position: relative; position: relative;
} }
.saturation-picker, .saturation-value-picker,
.hue-picker, .hue-picker,
.opacity-picker { .opacity-picker {
height: 256px; height: 256px;
@@ -118,39 +117,72 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import { hsvaToRgba, rgbaToHsva } from "@/utility-functions/color";
import { clamp } from "@/utility-functions/math"; import { clamp } from "@/utility-functions/math";
import { type RGBA } from "@/wasm-communication/messages"; import { Color } from "@/wasm-communication/messages";
import FloatingMenu, { type MenuDirection } from "@/components/layout/FloatingMenu.vue";
import LayoutCol from "@/components/layout/LayoutCol.vue"; import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
type ColorPickerState = "Idle" | "MoveHue" | "MoveOpacity" | "MoveSaturation";
// TODO: Clean up the fundamental code design in this file to simplify it and use better practices.
// TODO: Such as removing the `picker*` data variables and reducing the number of functions which call each other in weird, non-obvious ways.
export default defineComponent({ export default defineComponent({
emits: ["update:color"], emits: ["update:color", "update:open"],
props: { props: {
color: { type: Object as PropType<RGBA>, required: true }, color: { type: Object as PropType<Color>, required: true },
open: { type: Boolean as PropType<boolean>, required: true },
direction: { type: String as PropType<MenuDirection>, default: "Bottom" },
}, },
data() { data() {
const hsva = this.color.toHSVA();
return { return {
state: "Idle" as ColorPickerState, draggingPickerTrack: undefined as HTMLElement | undefined,
pickerHSVA: { h: 0, s: 0, v: 0, a: 1 }, hue: hsva.h,
pickerHueRect: { width: 0, height: 0, top: 0, left: 0 }, saturation: hsva.s,
pickerOpacityRect: { width: 0, height: 0, top: 0, left: 0 }, value: hsva.v,
pickerSaturationRect: { width: 0, height: 0, top: 0, left: 0 }, opacity: hsva.a,
}; };
}, },
mounted() { computed: {
this.$watch("color", this.updateColor, { immediate: true }); hueColorCSS() {
}, return new Color({ h: this.hue, s: 1, v: 1, a: 1 }).toRgbCSS();
unmounted() { },
this.removeEvents();
}, },
methods: { methods: {
beginDrag(e: PointerEvent) {
const target = (e.target || undefined) as HTMLElement | undefined;
this.draggingPickerTrack = target?.closest("[data-saturation-value-picker], [data-hue-picker], [data-opacity-picker]") || undefined;
this.addEvents();
this.onPointerMove(e);
},
onPointerMove(e: PointerEvent) {
if (this.draggingPickerTrack?.hasAttribute("data-saturation-value-picker")) {
const rectangle = this.draggingPickerTrack.getBoundingClientRect();
this.saturation = clamp((e.clientX - rectangle.left) / rectangle.width, 0, 1);
this.value = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
} else if (this.draggingPickerTrack?.hasAttribute("data-hue-picker")) {
const rectangle = this.draggingPickerTrack.getBoundingClientRect();
this.hue = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
} else if (this.draggingPickerTrack?.hasAttribute("data-opacity-picker")) {
const rectangle = this.draggingPickerTrack.getBoundingClientRect();
this.opacity = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
}
// Just in case the mouseup event is lost
if (e.buttons === 0) this.removeEvents();
// The `color` prop's watcher calls `this.updateColor()`
this.$emit("update:color", new Color({ h: this.hue, s: this.saturation, v: this.value, a: this.opacity }));
},
onPointerUp() {
this.removeEvents();
},
emitOpenState(isOpen: boolean) {
this.$emit("update:open", isOpen);
},
addEvents() { addEvents() {
document.addEventListener("pointermove", this.onPointerMove); document.addEventListener("pointermove", this.onPointerMove);
document.addEventListener("pointerup", this.onPointerUp); document.addEventListener("pointerup", this.onPointerUp);
@@ -159,145 +191,12 @@ export default defineComponent({
document.removeEventListener("pointermove", this.onPointerMove); document.removeEventListener("pointermove", this.onPointerMove);
document.removeEventListener("pointerup", this.onPointerUp); document.removeEventListener("pointerup", this.onPointerUp);
}, },
onPointerDown(e: PointerEvent) { },
const saturationPicker = this.$refs.saturationPicker as typeof LayoutCol; unmounted() {
const saturationPickerElement = saturationPicker?.$el as HTMLElement | undefined; this.removeEvents();
const huePicker = this.$refs.huePicker as typeof LayoutCol;
const huePickerElement = huePicker?.$el as HTMLElement | undefined;
const opacityPicker = this.$refs.opacityPicker as typeof LayoutCol;
const opacityPickerElement = opacityPicker?.$el as HTMLElement | undefined;
if (!(e.currentTarget instanceof HTMLElement) || !saturationPickerElement || !huePickerElement || !opacityPickerElement) return;
if (saturationPickerElement.contains(e.currentTarget)) {
this.state = "MoveSaturation";
} else if (huePickerElement.contains(e.currentTarget)) {
this.state = "MoveHue";
} else if (opacityPickerElement.contains(e.currentTarget)) {
this.state = "MoveOpacity";
} else {
this.state = "Idle";
}
if (this.state === "Idle") return;
this.addEvents();
this.updateRects();
this.onPointerMove(e);
},
onPointerMove(e: PointerEvent) {
switch (this.state) {
case "MoveHue":
this.setHueCursorPosition(e.clientY - this.pickerHueRect.top);
break;
case "MoveOpacity":
this.setOpacityCursorPosition(e.clientY - this.pickerOpacityRect.top);
break;
case "MoveSaturation":
this.setSaturationCursorPosition(e.clientX - this.pickerSaturationRect.left, e.clientY - this.pickerSaturationRect.top);
break;
default:
return;
}
this.updateHue();
// The `color` prop's watcher calls `this.updateColor()`
this.$emit("update:color", hsvaToRgba(this.pickerHSVA));
},
onPointerUp() {
if (this.state === "Idle") return;
this.state = "Idle";
this.removeEvents();
},
updateRects() {
const saturationPicker = this.$refs.saturationPicker as typeof LayoutCol;
const saturationPickerElement = saturationPicker?.$el as HTMLElement | undefined;
const huePicker = this.$refs.huePicker as typeof LayoutCol;
const huePickerElement = huePicker?.$el as HTMLElement | undefined;
const opacityPicker = this.$refs.opacityPicker as typeof LayoutCol;
const opacityPickerElement = opacityPicker?.$el as HTMLElement | undefined;
if (!saturationPickerElement || !huePickerElement || !opacityPickerElement) return;
// Saturation
const saturation = saturationPickerElement.getBoundingClientRect();
this.pickerSaturationRect.width = saturation.width;
this.pickerSaturationRect.height = saturation.height;
this.pickerSaturationRect.left = saturation.left;
this.pickerSaturationRect.top = saturation.top;
// Hue
const hue = huePickerElement.getBoundingClientRect();
this.pickerHueRect.width = hue.width;
this.pickerHueRect.height = hue.height;
this.pickerHueRect.left = hue.left;
this.pickerHueRect.top = hue.top;
// Opacity
const opacity = opacityPickerElement.getBoundingClientRect();
this.pickerOpacityRect.width = opacity.width;
this.pickerOpacityRect.height = opacity.height;
this.pickerOpacityRect.left = opacity.left;
this.pickerOpacityRect.top = opacity.top;
},
setSaturationCursorPosition(x: number, y: number) {
const saturationPositionX = clamp(x, 0, this.pickerSaturationRect.width);
const saturationPositionY = clamp(y, 0, this.pickerSaturationRect.height);
const saturationCursor = this.$refs.saturationCursor as HTMLElement;
saturationCursor.style.transform = `translate(${saturationPositionX}px, ${saturationPositionY}px)`;
this.pickerHSVA.s = saturationPositionX / this.pickerSaturationRect.width;
this.pickerHSVA.v = (1 - saturationPositionY / this.pickerSaturationRect.height) * 255;
},
setHueCursorPosition(y: number) {
const huePosition = clamp(y, 0, this.pickerHueRect.height);
const hueCursor = this.$refs.hueCursor as HTMLElement;
hueCursor.style.transform = `translateY(${huePosition}px)`;
this.pickerHSVA.h = clamp(1 - huePosition / this.pickerHueRect.height);
},
setOpacityCursorPosition(y: number) {
const opacityPosition = clamp(y, 0, this.pickerOpacityRect.height);
const opacityCursor = this.$refs.opacityCursor as HTMLElement;
opacityCursor.style.transform = `translateY(${opacityPosition}px)`;
this.pickerHSVA.a = clamp(1 - opacityPosition / this.pickerOpacityRect.height);
},
updateHue() {
const hsva = hsvaToRgba({ h: this.pickerHSVA.h, s: 1, v: 255, a: 1 });
const rgba = hsvaToRgba(this.pickerHSVA);
this.$el.style.setProperty("--saturation-picker-hue", `rgb(${hsva.r}, ${hsva.g}, ${hsva.b})`);
this.$el.style.setProperty("--opacity-picker-color", `rgb(${rgba.r}, ${rgba.g}, ${rgba.b})`);
},
updateColor() {
if (this.state !== "Idle") return;
this.pickerHSVA = rgbaToHsva(this.color);
this.updateRects();
this.setSaturationCursorPosition(this.pickerHSVA.s * this.pickerSaturationRect.width, (1 - this.pickerHSVA.v / 255) * this.pickerSaturationRect.height);
this.setOpacityCursorPosition((1 - this.pickerHSVA.a) * this.pickerOpacityRect.height);
this.setHueCursorPosition((1 - this.pickerHSVA.h) * this.pickerHueRect.height);
this.updateHue();
},
}, },
components: { components: {
FloatingMenu,
LayoutCol, LayoutCol,
LayoutRow, LayoutRow,
}, },
@@ -1,6 +1,6 @@
<template> <template>
<FloatingMenu :open="true" class="dialog-modal" :type="'Dialog'" :direction="'Center'" data-dialog-modal> <FloatingMenu :open="true" class="dialog-modal" :type="'Dialog'" :direction="'Center'" data-dialog-modal>
<LayoutRow ref="main"> <LayoutRow>
<LayoutCol class="icon-column"> <LayoutCol class="icon-column">
<!-- `dialog.state.icon` class exists to provide special sizing in CSS to specific icons --> <!-- `dialog.state.icon` class exists to provide special sizing in CSS to specific icons -->
<IconLabel :icon="dialog.state.icon" :class="dialog.state.icon.toLowerCase()" /> <IconLabel :icon="dialog.state.icon" :class="dialog.state.icon.toLowerCase()" />
@@ -68,7 +68,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import FloatingMenu from "@/components/floating-menus/FloatingMenu.vue"; import FloatingMenu from "@/components/layout/FloatingMenu.vue";
import LayoutCol from "@/components/layout/LayoutCol.vue"; import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
import TextButton from "@/components/widgets/buttons/TextButton.vue"; import TextButton from "@/components/widgets/buttons/TextButton.vue";
@@ -85,7 +85,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import FloatingMenu from "@/components/floating-menus/FloatingMenu.vue"; import FloatingMenu from "@/components/layout/FloatingMenu.vue";
// Should be equal to the width and height of the canvas in the CSS above // Should be equal to the width and height of the canvas in the CSS above
const ZOOM_WINDOW_DIMENSIONS_EXPANDED = 110; const ZOOM_WINDOW_DIMENSIONS_EXPANDED = 110;
@@ -163,7 +163,7 @@ import { defineComponent, type PropType } from "vue";
import { type MenuListEntry } from "@/wasm-communication/messages"; import { type MenuListEntry } from "@/wasm-communication/messages";
import FloatingMenu, { type MenuDirection } from "@/components/floating-menus/FloatingMenu.vue"; import FloatingMenu, { type MenuDirection } from "@/components/layout/FloatingMenu.vue";
import LayoutCol from "@/components/layout/LayoutCol.vue"; import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue"; import IconLabel from "@/components/widgets/labels/IconLabel.vue";
@@ -51,7 +51,7 @@ import { defineComponent, type PropType } from "vue";
import { type IconName } from "@/utility-functions/icons"; import { type IconName } from "@/utility-functions/icons";
import FloatingMenu from "@/components/floating-menus/FloatingMenu.vue"; import FloatingMenu from "@/components/layout/FloatingMenu.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
import IconButton from "@/components/widgets/buttons/IconButton.vue"; import IconButton from "@/components/widgets/buttons/IconButton.vue";
@@ -5,9 +5,7 @@
<Separator :type="'Related'" /> <Separator :type="'Related'" />
<LayoutRow class="swatch"> <LayoutRow class="swatch">
<button class="swatch-button" :class="{ 'disabled-swatch': !value }" :style="`--swatch-color: #${value}`" @click="() => $emit('update:open', true)"></button> <button class="swatch-button" :class="{ 'disabled-swatch': !value }" :style="`--swatch-color: #${value}`" @click="() => $emit('update:open', true)"></button>
<FloatingMenu v-model:open="isOpen" :type="'Popover'" :direction="'Bottom'"> <ColorPicker v-model:open="isOpen" :color="color" @update:color="(color: Color) => colorPickerUpdated(color)" :direction="'Bottom'" />
<ColorPicker @update:color="(color: RGBA) => colorPickerUpdated(color)" :color="color" />
</FloatingMenu>
</LayoutRow> </LayoutRow>
</LayoutRow> </LayoutRow>
</template> </template>
@@ -70,10 +68,9 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import { type RGBA } from "@/wasm-communication/messages"; import { Color } from "@/wasm-communication/messages";
import ColorPicker from "@/components/floating-menus/ColorPicker.vue"; import ColorPicker from "@/components/floating-menus/ColorPicker.vue";
import FloatingMenu from "@/components/floating-menus/FloatingMenu.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
import OptionalInput from "@/components/widgets/inputs/OptionalInput.vue"; import OptionalInput from "@/components/widgets/inputs/OptionalInput.vue";
import TextInput from "@/components/widgets/inputs/TextInput.vue"; import TextInput from "@/components/widgets/inputs/TextInput.vue";
@@ -98,16 +95,18 @@ export default defineComponent({
}; };
}, },
computed: { computed: {
color() { color(): Color {
if (!this.value) return { r: 0, g: 0, b: 0, a: 1 }; // TODO: Validate length of value, and allow shorthand versions like single-digit or three-digit hex codes
if (!this.value) return new Color(0, 0, 0, 1);
const r = parseInt(this.value.slice(0, 2), 16); const r = parseInt(this.value.slice(0, 2), 16) / 255;
const g = parseInt(this.value.slice(2, 4), 16); const g = parseInt(this.value.slice(2, 4), 16) / 255;
const b = parseInt(this.value.slice(4, 6), 16); const b = parseInt(this.value.slice(4, 6), 16) / 255;
const a = parseInt(this.value.slice(6, 8), 16); const a = parseInt(this.value.slice(6, 8), 16) / 255;
return { r, g, b, a: a / 255 };
return new Color(r, g, b, a);
}, },
displayValue() { displayValue(): string {
if (!this.value) return ""; if (!this.value) return "";
const value = this.value.toLowerCase(); const value = this.value.toLowerCase();
@@ -125,10 +124,12 @@ export default defineComponent({
}, },
}, },
methods: { methods: {
colorPickerUpdated(color: RGBA) { colorPickerUpdated(color: Color) {
const twoDigitHex = (value: number): string => value.toString(16).padStart(2, "0"); const twoDigitHex = (value: number): string =>
const alphaU8Scale = Math.floor(color.a * 255); Math.floor(value * 255)
const newValue = `${twoDigitHex(color.r)}${twoDigitHex(color.g)}${twoDigitHex(color.b)}${twoDigitHex(alphaU8Scale)}`; .toString(16)
.padStart(2, "0");
const newValue = `${twoDigitHex(color.red)}${twoDigitHex(color.green)}${twoDigitHex(color.blue)}${twoDigitHex(color.alpha)}`;
this.$emit("update:value", newValue); this.$emit("update:value", newValue);
}, },
textInputUpdated(newValue: string) { textInputUpdated(newValue: string) {
@@ -160,7 +161,6 @@ export default defineComponent({
}, },
components: { components: {
ColorPicker, ColorPicker,
FloatingMenu,
LayoutRow, LayoutRow,
OptionalInput, OptionalInput,
Separator, Separator,
@@ -73,8 +73,8 @@ import { defineComponent, nextTick, type PropType } from "vue";
import { type MenuListEntry } from "@/wasm-communication/messages"; import { type MenuListEntry } from "@/wasm-communication/messages";
import type FloatingMenu from "@/components/floating-menus/FloatingMenu.vue";
import MenuList from "@/components/floating-menus/MenuList.vue"; import MenuList from "@/components/floating-menus/MenuList.vue";
import type FloatingMenu from "@/components/layout/FloatingMenu.vue";
import type LayoutCol from "@/components/layout/LayoutCol.vue"; import type LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue"; import IconLabel from "@/components/widgets/labels/IconLabel.vue";
@@ -2,15 +2,11 @@
<LayoutCol class="swatch-pair"> <LayoutCol class="swatch-pair">
<LayoutRow class="secondary swatch"> <LayoutRow class="secondary swatch">
<button @click="() => clickSecondarySwatch()" :style="`--swatch-color: ${secondary.toRgbaCSS()}`" data-hover-menu-spawner></button> <button @click="() => clickSecondarySwatch()" :style="`--swatch-color: ${secondary.toRgbaCSS()}`" data-hover-menu-spawner></button>
<FloatingMenu :type="'Popover'" :direction="'Right'" v-model:open="secondaryOpen"> <ColorPicker v-model:open="secondaryOpen" :color="secondary" @update:color="(color: Color) => secondaryColorChanged(color)" :direction="'Right'" />
<ColorPicker @update:color="(color: RGBA) => secondaryColorChanged(color)" :color="secondary.toRgba()" />
</FloatingMenu>
</LayoutRow> </LayoutRow>
<LayoutRow class="primary swatch"> <LayoutRow class="primary swatch">
<button @click="() => clickPrimarySwatch()" :style="`--swatch-color: ${primary.toRgbaCSS()}`" data-hover-menu-spawner></button> <button @click="() => clickPrimarySwatch()" :style="`--swatch-color: ${primary.toRgbaCSS()}`" data-hover-menu-spawner></button>
<FloatingMenu :type="'Popover'" :direction="'Right'" v-model:open="primaryOpen"> <ColorPicker v-model:open="primaryOpen" :color="primary" @update:color="(color: Color) => primaryColorChanged(color)" :direction="'Right'" />
<ColorPicker @update:color="(color: RGBA) => primaryColorChanged(color)" :color="primary.toRgba()" />
</FloatingMenu>
</LayoutRow> </LayoutRow>
</LayoutCol> </LayoutCol>
</template> </template>
@@ -68,11 +64,9 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import { rgbaToDecimalRgba } from "@/utility-functions/color"; import { type Color } from "@/wasm-communication/messages";
import { type RGBA, type Color } from "@/wasm-communication/messages";
import ColorPicker from "@/components/floating-menus/ColorPicker.vue"; import ColorPicker from "@/components/floating-menus/ColorPicker.vue";
import FloatingMenu from "@/components/floating-menus/FloatingMenu.vue";
import LayoutCol from "@/components/layout/LayoutCol.vue"; import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
@@ -97,18 +91,15 @@ export default defineComponent({
this.primaryOpen = false; this.primaryOpen = false;
this.secondaryOpen = true; this.secondaryOpen = true;
}, },
primaryColorChanged(color: RGBA) { primaryColorChanged(color: Color) {
const newColor = rgbaToDecimalRgba(color); this.editor.instance.updatePrimaryColor(color.red, color.green, color.blue, color.alpha);
this.editor.instance.updatePrimaryColor(newColor.r, newColor.g, newColor.b, newColor.a);
}, },
secondaryColorChanged(color: RGBA) { secondaryColorChanged(color: Color) {
const newColor = rgbaToDecimalRgba(color); this.editor.instance.updateSecondaryColor(color.red, color.green, color.blue, color.alpha);
this.editor.instance.updateSecondaryColor(newColor.r, newColor.g, newColor.b, newColor.a);
}, },
}, },
components: { components: {
ColorPicker, ColorPicker,
FloatingMenu,
LayoutCol, LayoutCol,
LayoutRow, LayoutRow,
}, },
-58
View File
@@ -1,58 +0,0 @@
import { type HSVA, type RGBA } from "@/wasm-communication/messages";
export function hsvaToRgba(hsva: HSVA): RGBA {
const { h, s, v, a } = hsva;
const hue = h * 6;
const hueIntegerPart = Math.floor(hue);
const hueFractionalPart = hue - hueIntegerPart;
const hueIntegerMod6 = hueIntegerPart % 6;
const p = v * (1 - s);
const q = v * (1 - hueFractionalPart * s);
const t = v * (1 - (1 - hueFractionalPart) * s);
const r = Math.round([v, q, p, p, t, v][hueIntegerMod6]);
const g = Math.round([t, v, v, q, p, p][hueIntegerMod6]);
const b = Math.round([p, p, t, v, v, q][hueIntegerMod6]);
return { r, g, b, a };
}
export function rgbaToHsva(rgba: RGBA): HSVA {
const { r, g, b, a } = rgba;
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) {
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 };
}
export function rgbaToDecimalRgba(rgba: RGBA): RGBA {
const r = rgba.r / 255;
const g = rgba.g / 255;
const b = rgba.b / 255;
return { r, g, b, a: rgba.a };
}
+70 -15
View File
@@ -100,13 +100,6 @@ export type ActionKeys = { keys: KeysGroup };
export type MouseMotion = string; export type MouseMotion = string;
export type RGBA = {
r: number;
g: number;
b: number;
a: number;
};
export type HSVA = { export type HSVA = {
h: number; h: number;
s: number; s: number;
@@ -114,26 +107,88 @@ export type HSVA = {
a: number; a: number;
}; };
const To255Scale = Transform(({ value }: { value: number }) => value * 255); // All channels range from 0 to 1
export class Color { export class Color {
@To255Scale constructor();
constructor(hsva: HSVA);
constructor(red: number, green: number, blue: number, alpha: number);
constructor(hsvaOrRed?: HSVA | number, green?: number, blue?: number, alpha?: number) {
// Empty constructor
if (hsvaOrRed === undefined) {
this.red = 0;
this.green = 0;
this.blue = 0;
this.alpha = 0;
}
// HSVA constructor
else if (typeof hsvaOrRed !== "number" && green === undefined && blue === undefined && alpha === undefined) {
const { h, s, v } = hsvaOrRed;
const convert = (n: number): number => {
const k = (n + h * 6) % 6;
return v - v * s * Math.max(Math.min(...[k, 4 - k, 1]), 0);
};
this.red = convert(5);
this.green = convert(3);
this.blue = convert(1);
this.alpha = hsvaOrRed.a;
}
// RGBA constructor
else if (typeof hsvaOrRed === "number" && typeof green === "number" && typeof blue === "number" && typeof alpha === "number") {
this.red = hsvaOrRed;
this.green = green;
this.blue = blue;
this.alpha = alpha;
}
}
readonly red!: number; readonly red!: number;
@To255Scale
readonly green!: number; readonly green!: number;
@To255Scale
readonly blue!: number; readonly blue!: number;
readonly alpha!: number; readonly alpha!: number;
toRgba(): RGBA { toRgbCSS(): string {
return { r: this.red, g: this.green, b: this.blue, a: this.alpha }; return `rgb(${this.red * 255}, ${this.green * 255}, ${this.blue * 255})`;
} }
toRgbaCSS(): string { toRgbaCSS(): string {
const { r, g, b, a } = this.toRgba(); return `rgba(${this.red * 255}, ${this.green * 255}, ${this.blue * 255}, ${this.alpha})`;
return `rgba(${r}, ${g}, ${b}, ${a})`; }
toHSVA(): HSVA {
const { red: r, green: g, blue: b, alpha: a } = this;
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) {
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 };
} }
} }
+2 -2
View File
@@ -353,7 +353,7 @@ impl JsEditorHandle {
Ok(()) Ok(())
} }
/// Update primary color /// Update primary color with values on a scale from 0 to 1.
#[wasm_bindgen(js_name = updatePrimaryColor)] #[wasm_bindgen(js_name = updatePrimaryColor)]
pub fn update_primary_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> { pub fn update_primary_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> {
let primary_color = match Color::from_rgbaf32(red, green, blue, alpha) { let primary_color = match Color::from_rgbaf32(red, green, blue, alpha) {
@@ -367,7 +367,7 @@ impl JsEditorHandle {
Ok(()) Ok(())
} }
/// Update secondary color /// Update secondary color with values on a scale from 0 to 1.
#[wasm_bindgen(js_name = updateSecondaryColor)] #[wasm_bindgen(js_name = updateSecondaryColor)]
pub fn update_secondary_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> { pub fn update_secondary_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> {
let secondary_color = match Color::from_rgbaf32(red, green, blue, alpha) { let secondary_color = match Color::from_rgbaf32(red, green, blue, alpha) {