mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-30 22:58:12 +08:00
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:
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user