mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2025-08-04 13:30:48 +00:00
Move gradient picking into the color picker (#1778)
* Gradient picker * Fix up color picker layout CSS problems * Begin hooking up SpectrumInput for gradient in the ColorPicker * Working gradient picking on the frontend only * Plumb FillColorChoice into the backend * Hook everything else up, just with a weird bug remaining * Fix some svelty reactivity issues * Add and remove stops * Cleanup * Rename type * Fill node document format upgrading * Fix lint * Polish the color picker UX and fix a bug --------- Co-authored-by: 0hypercube <0hypercube@gmail.com>
This commit is contained in:
parent
449729f1e1
commit
a9a4b5cd19
48 changed files with 1380 additions and 664 deletions
3
frontend/assets/icon-12px-solid/swap-horizontal.svg
Normal file
3
frontend/assets/icon-12px-solid/swap-horizontal.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
|
||||
<path d="M9,7l3-3L9,1v2H5v2h4V7z M0,8l3,3V9h4V7H3V5L0,8z" />
|
||||
</svg>
|
After Width: | Height: | Size: 130 B |
Before Width: | Height: | Size: 132 B After Width: | Height: | Size: 132 B |
|
@ -136,10 +136,11 @@
|
|||
|
||||
--color-transparent-checkered-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);
|
||||
--color-transparent-checkered-background-size: 16px 16px;
|
||||
--color-transparent-checkered-background-position: 0 0, 8px 8px;
|
||||
--color-transparent-checkered-background-size-mini: 8px 8px;
|
||||
--color-transparent-checkered-background-position-mini: 0 0, 4px 4px;
|
||||
--color-transparent-checkered-background-size: 16px 16px, 16px 16px, 16px 16px;
|
||||
--color-transparent-checkered-background-position: 0 0, 8px 8px, 8px 8px;
|
||||
--color-transparent-checkered-background-size-mini: 8px 8px, 8px 8px, 8px 8px;
|
||||
--color-transparent-checkered-background-position-mini: 0 0, 4px 4px, 4px 4px;
|
||||
--color-transparent-checkered-background-repeat: repeat, repeat, repeat;
|
||||
|
||||
--background-inactive-stripes: repeating-linear-gradient(
|
||||
-45deg,
|
||||
|
|
|
@ -3,14 +3,15 @@
|
|||
|
||||
import { clamp } from "@graphite/utility-functions/math";
|
||||
import type { Editor } from "@graphite/wasm-communication/editor";
|
||||
import { type HSV, type RGB } from "@graphite/wasm-communication/messages";
|
||||
import { Color } from "@graphite/wasm-communication/messages";
|
||||
import { type HSV, type RGB, type FillChoice } from "@graphite/wasm-communication/messages";
|
||||
import { Color, Gradient } from "@graphite/wasm-communication/messages";
|
||||
|
||||
import FloatingMenu, { type MenuDirection } from "@graphite/components/layout/FloatingMenu.svelte";
|
||||
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
|
||||
import IconButton from "@graphite/components/widgets/buttons/IconButton.svelte";
|
||||
import NumberInput from "@graphite/components/widgets/inputs/NumberInput.svelte";
|
||||
import SpectrumInput from "@graphite/components/widgets/inputs/SpectrumInput.svelte";
|
||||
import TextInput from "@graphite/components/widgets/inputs/TextInput.svelte";
|
||||
import Separator from "@graphite/components/widgets/labels/Separator.svelte";
|
||||
import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
|
||||
|
@ -31,40 +32,47 @@
|
|||
|
||||
const editor = getContext<Editor>("editor");
|
||||
|
||||
const dispatch = createEventDispatcher<{ color: Color; startHistoryTransaction: undefined }>();
|
||||
const dispatch = createEventDispatcher<{ colorOrGradient: FillChoice; startHistoryTransaction: undefined }>();
|
||||
|
||||
export let color: Color;
|
||||
export let colorOrGradient: FillChoice;
|
||||
export let allowNone = false;
|
||||
// export let allowTransparency = false; // TODO: Implement
|
||||
export let direction: MenuDirection = "Bottom";
|
||||
// TODO: See if this should be made to follow the pattern of DropdownInput.svelte so this could be removed
|
||||
export let open: boolean;
|
||||
|
||||
const hsvaOrNone = color.toHSVA();
|
||||
const hsvaOrNone = colorOrGradient instanceof Color ? colorOrGradient.toHSVA() : colorOrGradient.firstColor()?.toHSVA();
|
||||
const hsva = hsvaOrNone || { h: 0, s: 0, v: 0, a: 1 };
|
||||
|
||||
// Gradient color stops
|
||||
$: gradient = colorOrGradient instanceof Gradient ? colorOrGradient : undefined;
|
||||
let activeIndex = 0 as number | undefined;
|
||||
$: selectedGradientColour = (activeIndex !== undefined && gradient?.atIndex(activeIndex)?.color) || (Color.fromCSS("black") as Color);
|
||||
// Currently viewed color
|
||||
$: color = colorOrGradient instanceof Color ? colorOrGradient : selectedGradientColour;
|
||||
// New color components
|
||||
let hue = hsva.h;
|
||||
let saturation = hsva.s;
|
||||
let value = hsva.v;
|
||||
let alpha = hsva.a;
|
||||
let isNone = hsvaOrNone === undefined;
|
||||
// Initial color components
|
||||
let initialHue = hsva.h;
|
||||
let initialSaturation = hsva.s;
|
||||
let initialValue = hsva.v;
|
||||
let initialAlpha = hsva.a;
|
||||
let initialIsNone = hsvaOrNone === undefined;
|
||||
// Old color components
|
||||
let oldHue = hsva.h;
|
||||
let oldSaturation = hsva.s;
|
||||
let oldValue = hsva.v;
|
||||
let oldAlpha = hsva.a;
|
||||
let oldIsNone = hsvaOrNone === undefined;
|
||||
// Transient state
|
||||
let draggingPickerTrack: HTMLDivElement | undefined = undefined;
|
||||
let strayCloses = true;
|
||||
|
||||
let hexCodeInputWidget: TextInput | undefined;
|
||||
let gradientSpectrumInputWidget: SpectrumInput | undefined;
|
||||
|
||||
$: watchOpen(open);
|
||||
$: watchColor(color);
|
||||
|
||||
$: initialColor = generateColor(initialHue, initialSaturation, initialValue, initialAlpha, initialIsNone);
|
||||
$: oldColor = generateColor(oldHue, oldSaturation, oldValue, oldAlpha, oldIsNone);
|
||||
$: newColor = generateColor(hue, saturation, value, alpha, isNone);
|
||||
$: rgbChannels = Object.entries(newColor.toRgb255() || { r: undefined, g: undefined, b: undefined }) as [keyof RGB, number | undefined][];
|
||||
$: hsvChannels = Object.entries(!isNone ? { h: hue * 360, s: saturation * 100, v: value * 100 } : { h: undefined, s: undefined, v: undefined }) as [keyof HSV, number | undefined][];
|
||||
|
@ -79,7 +87,7 @@
|
|||
if (open) {
|
||||
setTimeout(() => hexCodeInputWidget?.focus(), 0);
|
||||
} else {
|
||||
setInitialHSVA(hue, saturation, value, alpha, isNone);
|
||||
setOldHSVA(hue, saturation, value, alpha, isNone);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -163,11 +171,18 @@
|
|||
|
||||
function setColor(color?: Color) {
|
||||
const colorToEmit = color || new Color({ h: hue, s: saturation, v: value, a: alpha });
|
||||
dispatch("color", colorToEmit);
|
||||
|
||||
const stop = gradientSpectrumInputWidget && activeIndex !== undefined && gradient?.atIndex(activeIndex);
|
||||
if (stop && gradientSpectrumInputWidget instanceof SpectrumInput) {
|
||||
stop.color = colorToEmit;
|
||||
gradient = gradient;
|
||||
}
|
||||
|
||||
dispatch("colorOrGradient", gradient || colorToEmit);
|
||||
}
|
||||
|
||||
function swapNewWithInitial() {
|
||||
const initial = initialColor;
|
||||
function swapNewWithOld() {
|
||||
const old = oldColor;
|
||||
|
||||
const tempHue = hue;
|
||||
const tempSaturation = saturation;
|
||||
|
@ -175,10 +190,10 @@
|
|||
const tempAlpha = alpha;
|
||||
const tempIsNone = isNone;
|
||||
|
||||
setNewHSVA(initialHue, initialSaturation, initialValue, initialAlpha, initialIsNone);
|
||||
setInitialHSVA(tempHue, tempSaturation, tempValue, tempAlpha, tempIsNone);
|
||||
setNewHSVA(oldHue, oldSaturation, oldValue, oldAlpha, oldIsNone);
|
||||
setOldHSVA(tempHue, tempSaturation, tempValue, tempAlpha, tempIsNone);
|
||||
|
||||
setColor(initial);
|
||||
setColor(old);
|
||||
}
|
||||
|
||||
function setColorCode(colorCode: string) {
|
||||
|
@ -241,12 +256,12 @@
|
|||
isNone = none;
|
||||
}
|
||||
|
||||
function setInitialHSVA(h: number, s: number, v: number, a: number, none: boolean) {
|
||||
initialHue = h;
|
||||
initialSaturation = s;
|
||||
initialValue = v;
|
||||
initialAlpha = a;
|
||||
initialIsNone = none;
|
||||
function setOldHSVA(h: number, s: number, v: number, a: number, none: boolean) {
|
||||
oldHue = h;
|
||||
oldSaturation = s;
|
||||
oldValue = v;
|
||||
oldAlpha = a;
|
||||
oldIsNone = none;
|
||||
}
|
||||
|
||||
async function activateEyedropperSample() {
|
||||
|
@ -267,6 +282,18 @@
|
|||
}
|
||||
}
|
||||
|
||||
function gradientActiveMarkerIndexChange({ detail: index }: CustomEvent<number | undefined>) {
|
||||
activeIndex = index;
|
||||
const color = index === undefined ? undefined : gradient?.colorAtIndex(index);
|
||||
const hsva = color?.toHSVA();
|
||||
if (!color || !hsva) return;
|
||||
|
||||
setColor(color);
|
||||
|
||||
setNewHSVA(hsva.h, hsva.s, hsva.v, hsva.a, color.none);
|
||||
setOldHSVA(hsva.h, hsva.s, hsva.v, hsva.a, color.none);
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
removeEvents();
|
||||
});
|
||||
|
@ -277,42 +304,83 @@
|
|||
styles={{
|
||||
"--new-color": newColor.toHexOptionalAlpha(),
|
||||
"--new-color-contrasting": newColor.contrastingColor(),
|
||||
"--initial-color": initialColor.toHexOptionalAlpha(),
|
||||
"--initial-color-contrasting": initialColor.contrastingColor(),
|
||||
"--old-color": oldColor.toHexOptionalAlpha(),
|
||||
"--old-color-contrasting": oldColor.contrastingColor(),
|
||||
"--hue-color": opaqueHueColor.toRgbCSS(),
|
||||
"--hue-color-contrasting": opaqueHueColor.contrastingColor(),
|
||||
"--opaque-color": (newColor.opaque() || new Color(0, 0, 0, 1)).toHexNoAlpha(),
|
||||
"--opaque-color-contrasting": (newColor.opaque() || new Color(0, 0, 0, 1)).contrastingColor(),
|
||||
}}
|
||||
>
|
||||
<LayoutCol class="saturation-value-picker" on:pointerdown={onPointerDown} data-saturation-value-picker>
|
||||
{#if !isNone}
|
||||
<div class="selection-circle" style:top={`${(1 - value) * 100}%`} style:left={`${saturation * 100}%`} />
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
<LayoutCol class="hue-picker" on:pointerdown={onPointerDown} data-hue-picker>
|
||||
{#if !isNone}
|
||||
<div class="selection-needle" style:top={`${(1 - hue) * 100}%`} />
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
<LayoutCol class="alpha-picker" on:pointerdown={onPointerDown} data-alpha-picker>
|
||||
{#if !isNone}
|
||||
<div class="selection-needle" style:top={`${(1 - alpha) * 100}%`} />
|
||||
<LayoutCol class="pickers-and-gradient">
|
||||
<LayoutRow class="pickers">
|
||||
<LayoutCol class="saturation-value-picker" on:pointerdown={onPointerDown} data-saturation-value-picker>
|
||||
{#if !isNone}
|
||||
<div class="selection-circle" style:top={`${(1 - value) * 100}%`} style:left={`${saturation * 100}%`} />
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
<LayoutCol class="hue-picker" on:pointerdown={onPointerDown} data-hue-picker>
|
||||
{#if !isNone}
|
||||
<div class="selection-needle" style:top={`${(1 - hue) * 100}%`} />
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
<LayoutCol class="alpha-picker" on:pointerdown={onPointerDown} data-alpha-picker>
|
||||
{#if !isNone}
|
||||
<div class="selection-needle" style:top={`${(1 - alpha) * 100}%`} />
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
</LayoutRow>
|
||||
{#if gradient}
|
||||
<LayoutRow class="gradient">
|
||||
<SpectrumInput
|
||||
{gradient}
|
||||
on:gradient={() => {
|
||||
gradient = gradient;
|
||||
if (gradient) dispatch("colorOrGradient", gradient);
|
||||
}}
|
||||
on:activeMarkerIndexChange={gradientActiveMarkerIndexChange}
|
||||
activeMarkerIndex={activeIndex}
|
||||
bind:this={gradientSpectrumInputWidget}
|
||||
/>
|
||||
{#if gradientSpectrumInputWidget && activeIndex !== undefined}
|
||||
<NumberInput
|
||||
value={(gradient.positionAtIndex(activeIndex) || 0) * 100}
|
||||
on:value={({ detail }) => {
|
||||
if (gradientSpectrumInputWidget && activeIndex !== undefined && detail !== undefined) gradientSpectrumInputWidget.setPosition(activeIndex, detail / 100);
|
||||
}}
|
||||
displayDecimalPlaces={0}
|
||||
min={0}
|
||||
max={100}
|
||||
unit="%"
|
||||
/>
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
<LayoutCol class="details">
|
||||
<LayoutRow class="choice-preview" on:click={swapNewWithInitial} tooltip="Comparison views of the present color choice (left) and the color before any change (right). Click to swap sides.">
|
||||
<LayoutRow
|
||||
class="choice-preview"
|
||||
tooltip={!newColor.equals(oldColor) ? "Comparison between the present color choice (left) and the color before any change was made (right)" : "The present color choice"}
|
||||
>
|
||||
{#if !newColor.equals(oldColor)}
|
||||
<div class="swap-button-background"></div>
|
||||
<IconButton class="swap-button" icon="SwapHorizontal" size={16} action={swapNewWithOld} tooltip="Swap" />
|
||||
{/if}
|
||||
<LayoutCol class="new-color" classes={{ none: isNone }}>
|
||||
<TextLabel>New</TextLabel>
|
||||
</LayoutCol>
|
||||
<LayoutCol class="initial-color" classes={{ none: initialIsNone }}>
|
||||
<TextLabel>Initial</TextLabel>
|
||||
{#if !newColor.equals(oldColor)}
|
||||
<TextLabel>New</TextLabel>
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
{#if !newColor.equals(oldColor)}
|
||||
<LayoutCol class="old-color" classes={{ none: oldIsNone }}>
|
||||
<TextLabel>Old</TextLabel>
|
||||
</LayoutCol>
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
<!-- <DropdownInput entries={[[{ label: "sRGB" }]]} selectedIndex={0} disabled={true} tooltip="Color model, color space, and HDR (coming soon)" /> -->
|
||||
<LayoutRow>
|
||||
<TextLabel tooltip={"Color code in hexadecimal format. 6 digits if opaque, 8 with alpha.\nAccepts input of CSS color values including named colors."}>Hex</TextLabel>
|
||||
<Separator />
|
||||
<Separator type="Related" />
|
||||
<LayoutRow>
|
||||
<TextInput
|
||||
value={newColor.toHexOptionalAlpha() || "-"}
|
||||
|
@ -328,7 +396,7 @@
|
|||
</LayoutRow>
|
||||
<LayoutRow>
|
||||
<TextLabel tooltip="Red/Green/Blue channels of the color, integers 0–255">RGB</TextLabel>
|
||||
<Separator />
|
||||
<Separator type="Related" />
|
||||
<LayoutRow>
|
||||
{#each rgbChannels as [channel, strength], index}
|
||||
{#if index > 0}
|
||||
|
@ -345,7 +413,7 @@
|
|||
}}
|
||||
min={0}
|
||||
max={255}
|
||||
minWidth={56}
|
||||
minWidth={1}
|
||||
tooltip={`${{ r: "Red", g: "Green", b: "Blue" }[channel]} channel, integers 0–255`}
|
||||
/>
|
||||
{/each}
|
||||
|
@ -355,7 +423,7 @@
|
|||
<TextLabel tooltip={"Hue/Saturation/Value, also known as Hue/Saturation/Brightness (HSB).\nNot to be confused with Hue/Saturation/Lightness (HSL), a different color model."}>
|
||||
HSV
|
||||
</TextLabel>
|
||||
<Separator />
|
||||
<Separator type="Related" />
|
||||
<LayoutRow>
|
||||
{#each hsvChannels as [channel, strength], index}
|
||||
{#if index > 0}
|
||||
|
@ -373,7 +441,8 @@
|
|||
min={0}
|
||||
max={channel === "h" ? 360 : 100}
|
||||
unit={channel === "h" ? "°" : "%"}
|
||||
minWidth={56}
|
||||
minWidth={1}
|
||||
displayDecimalPlaces={1}
|
||||
tooltip={{
|
||||
h: `Hue component, the shade along the spectrum of the rainbow`,
|
||||
s: `Saturation component, the vividness from grayscale to full color`,
|
||||
|
@ -383,42 +452,45 @@
|
|||
{/each}
|
||||
</LayoutRow>
|
||||
</LayoutRow>
|
||||
<NumberInput
|
||||
label="Alpha"
|
||||
value={!isNone ? alpha * 100 : undefined}
|
||||
on:value={({ detail }) => {
|
||||
if (detail !== undefined) alpha = detail / 100;
|
||||
setColorAlphaPercent(detail);
|
||||
}}
|
||||
on:startHistoryTransaction={() => {
|
||||
dispatch("startHistoryTransaction");
|
||||
}}
|
||||
min={0}
|
||||
max={100}
|
||||
rangeMin={0}
|
||||
rangeMax={100}
|
||||
unit="%"
|
||||
mode="Range"
|
||||
displayDecimalPlaces={1}
|
||||
tooltip={`Scale from transparent (0%) to opaque (100%) for the color's alpha channel`}
|
||||
/>
|
||||
<LayoutRow>
|
||||
<TextLabel tooltip="Scale from transparent (0%) to opaque (100%) for the color's alpha channel">Alpha</TextLabel>
|
||||
<Separator type="Related" />
|
||||
<NumberInput
|
||||
value={!isNone ? alpha * 100 : undefined}
|
||||
on:value={({ detail }) => {
|
||||
if (detail !== undefined) alpha = detail / 100;
|
||||
setColorAlphaPercent(detail);
|
||||
}}
|
||||
on:startHistoryTransaction={() => {
|
||||
dispatch("startHistoryTransaction");
|
||||
}}
|
||||
min={0}
|
||||
max={100}
|
||||
rangeMin={0}
|
||||
rangeMax={100}
|
||||
unit="%"
|
||||
mode="Range"
|
||||
displayDecimalPlaces={1}
|
||||
tooltip={`Scale from transparent (0%) to opaque (100%) for the color's alpha channel`}
|
||||
/>
|
||||
</LayoutRow>
|
||||
<LayoutRow class="leftover-space" />
|
||||
<LayoutRow>
|
||||
{#if allowNone}
|
||||
<button class="preset-color none" on:click={() => setColorPreset("none")} title="Set No Color" tabindex="0" />
|
||||
{#if allowNone && !gradient}
|
||||
<button class="preset-color none" on:click={() => setColorPreset("none")} title="Set to no color" tabindex="0" />
|
||||
<Separator type="Related" />
|
||||
{/if}
|
||||
<button class="preset-color black" on:click={() => setColorPreset("black")} title="Set Black" tabindex="0" />
|
||||
<button class="preset-color black" on:click={() => setColorPreset("black")} title="Set to black" tabindex="0" />
|
||||
<Separator type="Related" />
|
||||
<button class="preset-color white" on:click={() => setColorPreset("white")} title="Set White" tabindex="0" />
|
||||
<button class="preset-color white" on:click={() => setColorPreset("white")} title="Set to white" tabindex="0" />
|
||||
<Separator type="Related" />
|
||||
<button class="preset-color pure" on:click={setColorPresetSubtile} tabindex="-1">
|
||||
<div data-pure-tile="red" style="--pure-color: #ff0000; --pure-color-gray: #4c4c4c" title="Set Red" />
|
||||
<div data-pure-tile="yellow" style="--pure-color: #ffff00; --pure-color-gray: #e3e3e3" title="Set Yellow" />
|
||||
<div data-pure-tile="green" style="--pure-color: #00ff00; --pure-color-gray: #969696" title="Set Green" />
|
||||
<div data-pure-tile="cyan" style="--pure-color: #00ffff; --pure-color-gray: #b2b2b2" title="Set Cyan" />
|
||||
<div data-pure-tile="blue" style="--pure-color: #0000ff; --pure-color-gray: #1c1c1c" title="Set Blue" />
|
||||
<div data-pure-tile="magenta" style="--pure-color: #ff00ff; --pure-color-gray: #696969" title="Set Magenta" />
|
||||
<div data-pure-tile="red" style="--pure-color: #ff0000; --pure-color-gray: #4c4c4c" title="Set to red" />
|
||||
<div data-pure-tile="yellow" style="--pure-color: #ffff00; --pure-color-gray: #e3e3e3" title="Set to yellow" />
|
||||
<div data-pure-tile="green" style="--pure-color: #00ff00; --pure-color-gray: #969696" title="Set to green" />
|
||||
<div data-pure-tile="cyan" style="--pure-color: #00ffff; --pure-color-gray: #b2b2b2" title="Set to cyan" />
|
||||
<div data-pure-tile="blue" style="--pure-color: #0000ff; --pure-color-gray: #1c1c1c" title="Set to blue" />
|
||||
<div data-pure-tile="magenta" style="--pure-color: #ff00ff; --pure-color-gray: #696969" title="Set to magenta" />
|
||||
</button>
|
||||
<Separator type="Related" />
|
||||
<IconButton icon="Eyedropper" size={24} action={activateEyedropperSample} tooltip="Sample a pixel color from the document" />
|
||||
|
@ -429,109 +501,125 @@
|
|||
|
||||
<style lang="scss" global>
|
||||
.color-picker {
|
||||
.saturation-value-picker {
|
||||
width: 256px;
|
||||
background-blend-mode: multiply;
|
||||
background: linear-gradient(to bottom, #ffffff, #000000), linear-gradient(to right, #ffffff, var(--hue-color));
|
||||
position: relative;
|
||||
}
|
||||
.pickers-and-gradient {
|
||||
.pickers {
|
||||
.saturation-value-picker {
|
||||
width: 256px;
|
||||
background-blend-mode: multiply;
|
||||
background: linear-gradient(to bottom, #ffffff, #000000), linear-gradient(to right, #ffffff, var(--hue-color));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.saturation-value-picker,
|
||||
.hue-picker,
|
||||
.alpha-picker {
|
||||
height: 256px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.saturation-value-picker,
|
||||
.hue-picker,
|
||||
.alpha-picker {
|
||||
height: 256px;
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hue-picker,
|
||||
.alpha-picker {
|
||||
width: 24px;
|
||||
margin-left: 8px;
|
||||
position: relative;
|
||||
}
|
||||
.hue-picker,
|
||||
.alpha-picker {
|
||||
width: 24px;
|
||||
margin-left: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hue-picker {
|
||||
background-blend-mode: screen;
|
||||
background:
|
||||
// Reds
|
||||
linear-gradient(to top, #ff0000ff 16.666%, #ff000000 33.333%, #ff000000 66.666%, #ff0000ff 83.333%),
|
||||
// Greens
|
||||
linear-gradient(to top, #00ff0000 0%, #00ff00ff 16.666%, #00ff00ff 50%, #00ff0000 66.666%),
|
||||
// Blues
|
||||
linear-gradient(to top, #0000ff00 33.333%, #0000ffff 50%, #0000ffff 83.333%, #0000ff00 100%);
|
||||
--selection-needle-color: var(--hue-color-contrasting);
|
||||
}
|
||||
.hue-picker {
|
||||
--selection-needle-color: var(--hue-color-contrasting);
|
||||
background-blend-mode: screen;
|
||||
background:
|
||||
// Reds
|
||||
linear-gradient(to top, #ff0000ff calc(100% / 6), #ff000000 calc(200% / 6), #ff000000 calc(400% / 6), #ff0000ff calc(500% / 6)),
|
||||
// Greens
|
||||
linear-gradient(to top, #00ff0000 0%, #00ff00ff calc(100% / 6), #00ff00ff 50%, #00ff0000 calc(400% / 6)),
|
||||
// Blues
|
||||
linear-gradient(to top, #0000ff00 calc(200% / 6), #0000ffff 50%, #0000ffff calc(500% / 6), #0000ff00 100%);
|
||||
}
|
||||
|
||||
.alpha-picker {
|
||||
background: linear-gradient(to bottom, var(--opaque-color), transparent);
|
||||
--selection-needle-color: var(--new-color-contrasting);
|
||||
.alpha-picker {
|
||||
--selection-needle-color: var(--new-color-contrasting);
|
||||
background-image: linear-gradient(to bottom, var(--opaque-color), transparent), var(--color-transparent-checkered-background);
|
||||
background-size:
|
||||
100% 100%,
|
||||
var(--color-transparent-checkered-background-size);
|
||||
background-position:
|
||||
0 0,
|
||||
var(--color-transparent-checkered-background-position);
|
||||
background-repeat: no-repeat, var(--color-transparent-checkered-background-repeat);
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
position: relative;
|
||||
background: var(--color-transparent-checkered-background);
|
||||
background-size: var(--color-transparent-checkered-background-size);
|
||||
background-position: var(--color-transparent-checkered-background-position);
|
||||
}
|
||||
}
|
||||
.selection-circle {
|
||||
position: absolute;
|
||||
left: 0%;
|
||||
top: 0%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
|
||||
.selection-circle {
|
||||
position: absolute;
|
||||
left: 0%;
|
||||
top: 0%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: relative;
|
||||
left: -6px;
|
||||
top: -6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--opaque-color-contrasting);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: relative;
|
||||
left: -6px;
|
||||
top: -6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--opaque-color-contrasting);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
.selection-needle {
|
||||
position: absolute;
|
||||
top: 0%;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
|
||||
.selection-needle {
|
||||
position: absolute;
|
||||
top: 0%;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 0;
|
||||
border-style: solid;
|
||||
border-width: 4px 0 4px 4px;
|
||||
border-color: transparent transparent transparent var(--selection-needle-color);
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 0;
|
||||
border-style: solid;
|
||||
border-width: 4px 0 4px 4px;
|
||||
border-color: transparent transparent transparent var(--selection-needle-color);
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: 0;
|
||||
border-style: solid;
|
||||
border-width: 4px 4px 4px 0;
|
||||
border-color: transparent var(--selection-needle-color) transparent transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: 0;
|
||||
border-style: solid;
|
||||
border-width: 4px 4px 4px 0;
|
||||
border-color: transparent var(--selection-needle-color) transparent transparent;
|
||||
.gradient {
|
||||
margin-top: 16px;
|
||||
|
||||
.spectrum-input {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.number-input {
|
||||
margin-left: 8px;
|
||||
min-width: 0;
|
||||
width: calc(24px + 8px + 24px);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.details {
|
||||
margin-left: 16px;
|
||||
width: 208px;
|
||||
width: 200px;
|
||||
gap: 8px;
|
||||
|
||||
> .layout-row {
|
||||
|
@ -539,8 +627,9 @@
|
|||
flex: 0 0 auto;
|
||||
|
||||
> .text-label {
|
||||
width: 24px;
|
||||
flex: 0 0 auto;
|
||||
// TODO: Use a table or grid layout for this width to match the widest label. Hard-coding it won't work when we add translation/localization.
|
||||
flex: 0 0 34px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
&.leftover-space {
|
||||
|
@ -550,15 +639,58 @@
|
|||
|
||||
.choice-preview {
|
||||
flex: 0 0 auto;
|
||||
width: 208px;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--color-1-nearblack);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background-image: var(--color-transparent-checkered-background);
|
||||
background-size: var(--color-transparent-checkered-background-size);
|
||||
background-position: var(--color-transparent-checkered-background-position);
|
||||
background-repeat: var(--color-transparent-checkered-background-repeat);
|
||||
|
||||
.swap-button-background {
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
mix-blend-mode: multiply;
|
||||
opacity: 0.25;
|
||||
border-radius: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
background: var(--new-color-contrasting);
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: 0;
|
||||
background: var(--old-color-contrasting);
|
||||
}
|
||||
}
|
||||
|
||||
.swap-button {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
.new-color {
|
||||
background: linear-gradient(var(--new-color), var(--new-color)), var(--color-transparent-checkered-background);
|
||||
background: var(--new-color);
|
||||
|
||||
.text-label {
|
||||
text-align: left;
|
||||
|
@ -567,22 +699,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
.initial-color {
|
||||
background: linear-gradient(var(--initial-color), var(--initial-color)), var(--color-transparent-checkered-background);
|
||||
.old-color {
|
||||
background: var(--old-color);
|
||||
|
||||
.text-label {
|
||||
text-align: right;
|
||||
margin: 2px 8px;
|
||||
color: var(--initial-color-contrasting);
|
||||
color: var(--old-color-contrasting);
|
||||
}
|
||||
}
|
||||
|
||||
.new-color,
|
||||
.initial-color {
|
||||
.old-color {
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background-size: var(--color-transparent-checkered-background-size);
|
||||
background-position: var(--color-transparent-checkered-background-position);
|
||||
|
||||
&.none {
|
||||
background: var(--color-none);
|
||||
|
@ -614,8 +744,8 @@
|
|||
margin: 0;
|
||||
padding: 0;
|
||||
border-radius: 2px;
|
||||
width: calc(48px + (48px + 4px) / 2);
|
||||
height: 24px;
|
||||
flex: 1 1 100%;
|
||||
|
||||
&.none {
|
||||
background: var(--color-none);
|
||||
|
@ -643,6 +773,7 @@
|
|||
width: 24px;
|
||||
font-size: 0;
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
|
||||
div {
|
||||
display: inline-block;
|
||||
|
@ -654,8 +785,7 @@
|
|||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover div,
|
||||
&:focus div {
|
||||
&:hover div {
|
||||
background: var(--pure-color);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -523,7 +523,7 @@
|
|||
|
||||
.entry-icon,
|
||||
.no-icon {
|
||||
margin-left: 4px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.user-input-label {
|
||||
|
|
|
@ -551,9 +551,10 @@
|
|||
margin-left: 4px;
|
||||
border-radius: 2px;
|
||||
flex: 0 0 auto;
|
||||
background: var(--color-transparent-checkered-background);
|
||||
background-image: var(--color-transparent-checkered-background);
|
||||
background-size: var(--color-transparent-checkered-background-size-mini);
|
||||
background-position: var(--color-transparent-checkered-background-position-mini);
|
||||
background-repeat: var(--color-transparent-checkered-background-repeat);
|
||||
|
||||
&:first-child {
|
||||
margin-left: 20px;
|
||||
|
|
|
@ -1428,9 +1428,10 @@
|
|||
|
||||
&::before {
|
||||
content: "";
|
||||
background: var(--color-transparent-checkered-background);
|
||||
background-image: var(--color-transparent-checkered-background);
|
||||
background-size: var(--color-transparent-checkered-background-size);
|
||||
background-position: var(--color-transparent-checkered-background-position);
|
||||
background-repeat: var(--color-transparent-checkered-background-repeat);
|
||||
}
|
||||
|
||||
&::before,
|
||||
|
|
|
@ -1,33 +1,36 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import type { Color } from "@graphite/wasm-communication/messages";
|
||||
import type { FillChoice } from "@graphite/wasm-communication/messages";
|
||||
import { Color, Gradient } from "@graphite/wasm-communication/messages";
|
||||
|
||||
import ColorPicker from "@graphite/components/floating-menus/ColorPicker.svelte";
|
||||
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
|
||||
import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
|
||||
|
||||
const dispatch = createEventDispatcher<{ value: Color; startHistoryTransaction: undefined }>();
|
||||
const dispatch = createEventDispatcher<{ value: FillChoice; startHistoryTransaction: undefined }>();
|
||||
|
||||
let open = false;
|
||||
|
||||
export let value: Color;
|
||||
export let value: FillChoice;
|
||||
export let disabled = false;
|
||||
export let allowNone = false;
|
||||
// export let allowTransparency = false; // TODO: Implement
|
||||
export let tooltip: string | undefined = undefined;
|
||||
|
||||
$: chosenGradient = value instanceof Gradient ? value.toLinearGradientCSS() : `linear-gradient(${value.toHexOptionalAlpha()}, ${value.toHexOptionalAlpha()})`;
|
||||
</script>
|
||||
|
||||
<LayoutCol class="color-button" classes={{ disabled, none: value.none, open }} {tooltip}>
|
||||
<button {disabled} style:--chosen-color={value.toHexOptionalAlpha()} on:click={() => (open = true)} tabindex="0" data-floating-menu-spawner></button>
|
||||
{#if disabled && !value.none}
|
||||
<LayoutCol class="color-button" classes={{ disabled, none: value instanceof Color ? value.none : false, open }} {tooltip}>
|
||||
<button {disabled} style:--chosen-gradient={chosenGradient} on:click={() => (open = true)} tabindex="0" data-floating-menu-spawner></button>
|
||||
{#if disabled && value instanceof Color && !value.none}
|
||||
<TextLabel>sRGB</TextLabel>
|
||||
{/if}
|
||||
<ColorPicker
|
||||
{open}
|
||||
on:open={({ detail }) => (open = detail)}
|
||||
color={value}
|
||||
on:color={({ detail }) => {
|
||||
colorOrGradient={value}
|
||||
on:colorOrGradient={({ detail }) => {
|
||||
value = detail;
|
||||
dispatch("value", detail);
|
||||
}}
|
||||
|
@ -71,9 +74,14 @@
|
|||
margin-top: 2px;
|
||||
width: calc(100% - 4px);
|
||||
height: calc(100% - 4px);
|
||||
background: linear-gradient(var(--chosen-color), var(--chosen-color)), var(--color-transparent-checkered-background);
|
||||
background-size: var(--color-transparent-checkered-background-size);
|
||||
background-position: var(--color-transparent-checkered-background-position);
|
||||
background-image: var(--chosen-gradient), var(--color-transparent-checkered-background);
|
||||
background-size:
|
||||
100% 100%,
|
||||
var(--color-transparent-checkered-background-size);
|
||||
background-position:
|
||||
0 0,
|
||||
var(--color-transparent-checkered-background-position);
|
||||
background-repeat: no-repeat, var(--color-transparent-checkered-background-repeat);
|
||||
}
|
||||
|
||||
&.none {
|
||||
|
|
|
@ -5,7 +5,6 @@
|
|||
import MenuList from "@graphite/components/floating-menus/MenuList.svelte";
|
||||
import ConditionalWrapper from "@graphite/components/layout/ConditionalWrapper.svelte";
|
||||
import IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte";
|
||||
import Separator from "@graphite/components/widgets/labels/Separator.svelte";
|
||||
import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
|
||||
|
||||
let self: MenuList;
|
||||
|
@ -74,9 +73,6 @@
|
|||
<IconLabel icon={hoverIcon} />
|
||||
{/if}
|
||||
{/if}
|
||||
{#if icon && label}
|
||||
<Separator type={flush ? "Unrelated" : "Related"} />
|
||||
{/if}
|
||||
{#if label}
|
||||
<TextLabel>{label}</TextLabel>
|
||||
{/if}
|
||||
|
@ -161,22 +157,26 @@
|
|||
}
|
||||
}
|
||||
|
||||
.widget-span.row > & + .text-button,
|
||||
.layout-row > & + .text-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.widget-span.column > & + .text-button,
|
||||
.layout-column > & + .text-button {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.icon-label {
|
||||
fill: var(--button-text-color);
|
||||
|
||||
+ .text-label {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.text-label {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Custom styling for when multiple TextButton widgets are used next to one another in a row or column
|
||||
.widget-span.row > & + .text-button,
|
||||
.layout-row > & + .text-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
.widget-span.column > & + .text-button,
|
||||
.layout-column > & + .text-button {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -128,8 +128,7 @@
|
|||
}
|
||||
|
||||
.dropdown-icon {
|
||||
margin: 4px;
|
||||
margin-left: 8px;
|
||||
margin: 4px 8px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
& + .dropdown-label {
|
||||
|
|
|
@ -266,6 +266,9 @@
|
|||
// Only drag the number with left click (and when it's valid to do so)
|
||||
if (e.button !== BUTTON_LEFT || mode !== "Increment" || value === undefined || disabled || editing) return;
|
||||
|
||||
// Remove the text entry cursor from any other selected text field
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
|
||||
|
||||
// Don't drag the text value from is input element
|
||||
e.preventDefault();
|
||||
|
||||
|
|
254
frontend/src/components/widgets/inputs/SpectrumInput.svelte
Normal file
254
frontend/src/components/widgets/inputs/SpectrumInput.svelte
Normal file
|
@ -0,0 +1,254 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher, onDestroy } from "svelte";
|
||||
|
||||
import { Color, type Gradient } from "@graphite/wasm-communication/messages";
|
||||
|
||||
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
|
||||
|
||||
const dispatch = createEventDispatcher<{ activeMarkerIndexChange: number | undefined; gradient: Gradient }>();
|
||||
|
||||
export let gradient: Gradient;
|
||||
export let activeMarkerIndex = 0 as number | undefined;
|
||||
// export let disabled = false;
|
||||
// export let tooltip: string | undefined = undefined;
|
||||
|
||||
let markerTrack: LayoutRow | undefined;
|
||||
|
||||
function markerPointerDown(e: PointerEvent, index: number) {
|
||||
// Left-click to select and begin potentially dragging
|
||||
if (e.button === 0) {
|
||||
activeMarkerIndex = index;
|
||||
dispatch("activeMarkerIndexChange", index);
|
||||
addEvents();
|
||||
return;
|
||||
}
|
||||
|
||||
// Right-click to delete
|
||||
if (e.button === 2) {
|
||||
deleteStopByIndex(index);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function markerPosition(e: MouseEvent): number | undefined {
|
||||
const markerTrackRect = markerTrack?.div()?.getBoundingClientRect();
|
||||
if (!markerTrackRect) return;
|
||||
|
||||
const ratio = (e.clientX - markerTrackRect.left) / markerTrackRect.width;
|
||||
|
||||
return Math.max(0, Math.min(1, ratio));
|
||||
}
|
||||
|
||||
function insertStop(e: MouseEvent) {
|
||||
let position = markerPosition(e);
|
||||
if (position === undefined) return;
|
||||
|
||||
let before = gradient.stops.findLast((value) => value.position < position);
|
||||
let after = gradient.stops.find((value) => value.position > position);
|
||||
|
||||
let color = Color.fromCSS("black") as Color;
|
||||
if (before && after) {
|
||||
let t = (position - before.position) / (after.position - before.position);
|
||||
color = before.color.lerp(after.color, t);
|
||||
} else if (before) {
|
||||
color = before.color;
|
||||
} else if (after) {
|
||||
color = after.color;
|
||||
}
|
||||
|
||||
let index = gradient.stops.findIndex((value) => value.position > position);
|
||||
if (index === -1) index = gradient.stops.length;
|
||||
|
||||
gradient.stops.splice(index, 0, { position, color });
|
||||
activeMarkerIndex = index;
|
||||
|
||||
dispatch("activeMarkerIndexChange", index);
|
||||
dispatch("gradient", gradient);
|
||||
}
|
||||
|
||||
function deleteStop(e: KeyboardEvent) {
|
||||
if (e.key.toLowerCase() !== "delete" && e.key.toLowerCase() !== "backspace") return;
|
||||
if (activeMarkerIndex === undefined) return;
|
||||
deleteStopByIndex(activeMarkerIndex);
|
||||
}
|
||||
|
||||
function deleteStopByIndex(index: number) {
|
||||
if (gradient.stops.length <= 2) return;
|
||||
|
||||
gradient.stops.splice(index, 1);
|
||||
if (gradient.stops.length === 0) {
|
||||
activeMarkerIndex = undefined;
|
||||
} else {
|
||||
activeMarkerIndex = Math.max(0, Math.min(gradient.stops.length - 1, index));
|
||||
}
|
||||
|
||||
dispatch("activeMarkerIndexChange", activeMarkerIndex);
|
||||
dispatch("gradient", gradient);
|
||||
}
|
||||
|
||||
function moveMarker(e: PointerEvent, index: number) {
|
||||
// Just in case the mouseup event is lost
|
||||
if (e.buttons === 0) removeEvents();
|
||||
|
||||
let position = markerPosition(e);
|
||||
if (position === undefined) return;
|
||||
setPosition(index, position);
|
||||
}
|
||||
|
||||
export function setPosition(index: number, position: number) {
|
||||
const active = gradient.stops[index];
|
||||
active.position = position;
|
||||
gradient.stops.sort((a, b) => a.position - b.position);
|
||||
if (gradient.stops.indexOf(active) !== activeMarkerIndex) {
|
||||
activeMarkerIndex = gradient.stops.indexOf(active);
|
||||
dispatch("activeMarkerIndexChange", gradient.stops.indexOf(active));
|
||||
}
|
||||
dispatch("gradient", gradient);
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (activeMarkerIndex !== undefined) {
|
||||
moveMarker(e, activeMarkerIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
removeEvents();
|
||||
}
|
||||
|
||||
function addEvents() {
|
||||
document.addEventListener("pointermove", onPointerMove);
|
||||
document.addEventListener("pointerup", onPointerUp);
|
||||
}
|
||||
|
||||
function removeEvents() {
|
||||
document.removeEventListener("pointermove", onPointerMove);
|
||||
document.removeEventListener("pointerup", onPointerUp);
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", deleteStop);
|
||||
onDestroy(() => {
|
||||
removeEvents();
|
||||
document.removeEventListener("keydown", deleteStop);
|
||||
});
|
||||
|
||||
// # Backend -> Frontend
|
||||
// Populate(gradient, { position, color }[], active) // The only way indexes get changed. Frontend drops marker if it's being dragged.
|
||||
// UpdateGradient(gradient)
|
||||
// UpdateMarkers({ index, position, color }[])
|
||||
|
||||
// # Frontend -> Backend
|
||||
// SendNewActive(index)
|
||||
// SendPositions({ index, position }[])
|
||||
// AddMarker(position)
|
||||
// RemoveMarkers(index[])
|
||||
// ResetMarkerToDefault(index)
|
||||
|
||||
// // We need a way to encode constraints on some markers, like locking them in place or preventing reordering
|
||||
// // We need a way to encode the allowability of adding new markers between certain markers, or preventing the deletion of certain markers
|
||||
// // We need the ability to multi-select markers and move them all at once
|
||||
</script>
|
||||
|
||||
<LayoutCol
|
||||
class="spectrum-input"
|
||||
styles={{
|
||||
"--gradient-start": gradient.firstColor()?.toHexOptionalAlpha() || "black",
|
||||
"--gradient-end": gradient.lastColor()?.toHexOptionalAlpha() || "black",
|
||||
"--gradient-stops": gradient.toLinearGradientCSS(),
|
||||
}}
|
||||
>
|
||||
<LayoutRow class="gradient-strip" on:click={insertStop}></LayoutRow>
|
||||
<LayoutRow class="marker-track" bind:this={markerTrack}>
|
||||
{#each gradient.stops as marker, index}
|
||||
<svg
|
||||
style:--marker-position={marker.position}
|
||||
style:--marker-color={marker.color.toRgbCSS()}
|
||||
class="marker"
|
||||
class:active={index === activeMarkerIndex}
|
||||
data-gradient-marker
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 12 12"
|
||||
>
|
||||
<path
|
||||
on:pointerdown={(e) => markerPointerDown(e, index)}
|
||||
d="M10,11.5H2c-0.8,0-1.5-0.7-1.5-1.5V6.8c0-0.4,0.2-0.8,0.4-1.1L6,0.7l5.1,5.1c0.3,0.3,0.4,0.7,0.4,1.1V10C11.5,10.8,10.8,11.5,10,11.5z"
|
||||
/>
|
||||
<path
|
||||
on:pointerdown={(e) => markerPointerDown(e, index)}
|
||||
d="M6,1.4L1.3,6.1C1.1,6.3,1,6.6,1,6.8V10c0,0.6,0.4,1,1,1h8c0.6,0,1-0.4,1-1V6.8c0-0.3-0.1-0.5-0.3-0.7L6,1.4M6,0l5.4,5.4C11.8,5.8,12,6.3,12,6.8V10c0,1.1-0.9,2-2,2H2c-1.1,0-2-0.9-2-2V6.8c0-0.5,0.2-1,0.6-1.4L6,0z"
|
||||
/>
|
||||
</svg>
|
||||
{/each}
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss" global>
|
||||
.spectrum-input {
|
||||
--marker-half-width: 6px;
|
||||
|
||||
.gradient-strip {
|
||||
flex: 0 0 auto;
|
||||
height: 16px;
|
||||
background-image:
|
||||
var(--gradient-stops),
|
||||
// Solid start/end colors on either side so the gradient begins at the center of a marker
|
||||
linear-gradient(var(--gradient-start), var(--gradient-start)),
|
||||
linear-gradient(var(--gradient-end), var(--gradient-end)),
|
||||
var(--color-transparent-checkered-background);
|
||||
background-size:
|
||||
calc(100% - 2 * var(--marker-half-width)) 100%,
|
||||
// TODO: Find a solution that avoids visual artifacts where these end colors meet the gradient that appear when viewing with a non-integer zoom or display scaling factor
|
||||
var(--marker-half-width) 100%,
|
||||
var(--marker-half-width) 100%,
|
||||
var(--color-transparent-checkered-background-size);
|
||||
background-position:
|
||||
var(--marker-half-width) 0,
|
||||
left 0,
|
||||
right 0,
|
||||
var(--color-transparent-checkered-background-position);
|
||||
background-repeat: no-repeat, no-repeat, no-repeat, var(--color-transparent-checkered-background-repeat);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.marker-track {
|
||||
margin-top: calc(24px - 16px - 12px);
|
||||
margin-left: var(--marker-half-width);
|
||||
width: calc(100% - 2 * var(--marker-half-width));
|
||||
position: relative;
|
||||
|
||||
.marker {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
left: calc(var(--marker-position) * 100%);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
|
||||
// Inner fill
|
||||
path:first-child {
|
||||
fill: var(--marker-color);
|
||||
}
|
||||
|
||||
// Outer border
|
||||
path:last-child {
|
||||
fill: var(--color-5-dullgray);
|
||||
}
|
||||
|
||||
&:not(.active) path:first-child:hover + path:last-child,
|
||||
&:not(.active) path:last-child:hover {
|
||||
fill: var(--color-6-lowergray);
|
||||
}
|
||||
|
||||
// Outer border when active
|
||||
&.active path:last-child {
|
||||
fill: var(--color-e-nearwhite);
|
||||
}
|
||||
|
||||
&.active path:first-child:hover + path:last-child,
|
||||
&.active path:last-child:hover {
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -2,7 +2,7 @@
|
|||
import { getContext } from "svelte";
|
||||
|
||||
import type { Editor } from "@graphite/wasm-communication/editor";
|
||||
import type { Color } from "@graphite/wasm-communication/messages";
|
||||
import { Color } from "@graphite/wasm-communication/messages";
|
||||
|
||||
import ColorPicker from "@graphite/components/floating-menus/ColorPicker.svelte";
|
||||
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
|
||||
|
@ -38,11 +38,23 @@
|
|||
<LayoutCol class="working-colors-button">
|
||||
<LayoutRow class="primary swatch">
|
||||
<button on:click={clickPrimarySwatch} class:open={primaryOpen} style:--swatch-color={primary.toRgbaCSS()} data-floating-menu-spawner="no-hover-transfer" tabindex="0" />
|
||||
<ColorPicker open={primaryOpen} on:open={({ detail }) => (primaryOpen = detail)} color={primary} on:color={({ detail }) => primaryColorChanged(detail)} direction="Right" />
|
||||
<ColorPicker
|
||||
open={primaryOpen}
|
||||
on:open={({ detail }) => (primaryOpen = detail)}
|
||||
colorOrGradient={primary}
|
||||
on:colorOrGradient={({ detail }) => detail instanceof Color && primaryColorChanged(detail)}
|
||||
direction="Right"
|
||||
/>
|
||||
</LayoutRow>
|
||||
<LayoutRow class="secondary swatch">
|
||||
<button on:click={clickSecondarySwatch} class:open={secondaryOpen} style:--swatch-color={secondary.toRgbaCSS()} data-floating-menu-spawner="no-hover-transfer" tabindex="0" />
|
||||
<ColorPicker open={secondaryOpen} on:open={({ detail }) => (secondaryOpen = detail)} color={secondary} on:color={({ detail }) => secondaryColorChanged(detail)} direction="Right" />
|
||||
<ColorPicker
|
||||
open={secondaryOpen}
|
||||
on:open={({ detail }) => (secondaryOpen = detail)}
|
||||
colorOrGradient={secondary}
|
||||
on:colorOrGradient={({ detail }) => detail instanceof Color && secondaryColorChanged(detail)}
|
||||
direction="Right"
|
||||
/>
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
|
||||
|
@ -67,8 +79,13 @@
|
|||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
background: linear-gradient(var(--swatch-color), var(--swatch-color)), var(--color-transparent-checkered-background);
|
||||
background-size: var(--color-transparent-checkered-background-size);
|
||||
background-position: var(--color-transparent-checkered-background-position);
|
||||
background-size:
|
||||
100% 100%,
|
||||
var(--color-transparent-checkered-background-size);
|
||||
background-position:
|
||||
0 0,
|
||||
var(--color-transparent-checkered-background-position);
|
||||
background-repeat: no-repeat, var(--color-transparent-checkered-background-repeat);
|
||||
overflow: hidden;
|
||||
|
||||
&:hover,
|
||||
|
|
|
@ -38,7 +38,8 @@ import Link from "@graphite-frontend/assets/icon-12px-solid/link.svg";
|
|||
import Overlays from "@graphite-frontend/assets/icon-12px-solid/overlays.svg";
|
||||
import Remove from "@graphite-frontend/assets/icon-12px-solid/remove.svg";
|
||||
import Snapping from "@graphite-frontend/assets/icon-12px-solid/snapping.svg";
|
||||
import Swap from "@graphite-frontend/assets/icon-12px-solid/swap.svg";
|
||||
import SwapHorizontal from "@graphite-frontend/assets/icon-12px-solid/swap-horizontal.svg";
|
||||
import SwapVertical from "@graphite-frontend/assets/icon-12px-solid/swap-vertical.svg";
|
||||
import VerticalEllipsis from "@graphite-frontend/assets/icon-12px-solid/vertical-ellipsis.svg";
|
||||
import Warning from "@graphite-frontend/assets/icon-12px-solid/warning.svg";
|
||||
import WindowButtonWinClose from "@graphite-frontend/assets/icon-12px-solid/window-button-win-close.svg";
|
||||
|
@ -78,7 +79,8 @@ const SOLID_12PX = {
|
|||
Overlays: { svg: Overlays, size: 12 },
|
||||
Remove: { svg: Remove, size: 12 },
|
||||
Snapping: { svg: Snapping, size: 12 },
|
||||
Swap: { svg: Swap, size: 12 },
|
||||
SwapHorizontal: { svg: SwapHorizontal, size: 12 },
|
||||
SwapVertical: { svg: SwapVertical, size: 12 },
|
||||
VerticalEllipsis: { svg: VerticalEllipsis, size: 12 },
|
||||
Warning: { svg: Warning, size: 12 },
|
||||
WindowButtonWinClose: { svg: WindowButtonWinClose, size: 12 },
|
||||
|
|
|
@ -217,8 +217,62 @@ export type HSV = { h: number; s: number; v: number };
|
|||
export type RGBA = { r: number; g: number; b: number; a: number };
|
||||
export type RGB = { r: number; g: number; b: number };
|
||||
|
||||
export class Gradient {
|
||||
readonly stops!: { position: number; color: Color }[];
|
||||
|
||||
constructor(stops: { position: number; color: Color }[]) {
|
||||
this.stops = stops;
|
||||
}
|
||||
|
||||
toLinearGradientCSS(): string {
|
||||
if (this.stops.length === 1) {
|
||||
return `linear-gradient(to right, ${this.stops[0].color.toHexOptionalAlpha()} 0%, ${this.stops[0].color.toHexOptionalAlpha()} 100%)`;
|
||||
}
|
||||
const pieces = this.stops.map((stop) => `${stop.color.toHexOptionalAlpha()} ${stop.position * 100}%`);
|
||||
return `linear-gradient(to right, ${pieces.join(", ")})`;
|
||||
}
|
||||
|
||||
toLinearGradientCSSNoAlpha(): string {
|
||||
if (this.stops.length === 1) {
|
||||
return `linear-gradient(to right, ${this.stops[0].color.toHexNoAlpha()} 0%, ${this.stops[0].color.toHexNoAlpha()} 100%)`;
|
||||
}
|
||||
const pieces = this.stops.map((stop) => `${stop.color.toHexNoAlpha()} ${stop.position * 100}%`);
|
||||
return `linear-gradient(to right, ${pieces.join(", ")})`;
|
||||
}
|
||||
|
||||
firstColor(): Color | undefined {
|
||||
return this.stops[0]?.color;
|
||||
}
|
||||
|
||||
lastColor(): Color | undefined {
|
||||
return this.stops[this.stops.length - 1]?.color;
|
||||
}
|
||||
|
||||
atIndex(index: number): { position: number; color: Color } | undefined {
|
||||
return this.stops[index];
|
||||
}
|
||||
|
||||
colorAtIndex(index: number): Color | undefined {
|
||||
return this.stops[index]?.color;
|
||||
}
|
||||
|
||||
positionAtIndex(index: number): number | undefined {
|
||||
return this.stops[index]?.position;
|
||||
}
|
||||
}
|
||||
|
||||
// All channels range from 0 to 1
|
||||
export class Color {
|
||||
readonly red!: number;
|
||||
|
||||
readonly green!: number;
|
||||
|
||||
readonly blue!: number;
|
||||
|
||||
readonly alpha!: number;
|
||||
|
||||
readonly none!: boolean;
|
||||
|
||||
constructor();
|
||||
|
||||
constructor(none: "none");
|
||||
|
@ -266,16 +320,6 @@ export class Color {
|
|||
}
|
||||
}
|
||||
|
||||
readonly red!: number;
|
||||
|
||||
readonly green!: number;
|
||||
|
||||
readonly blue!: number;
|
||||
|
||||
readonly alpha!: number;
|
||||
|
||||
readonly none!: boolean;
|
||||
|
||||
static fromCSS(colorCode: string): Color | undefined {
|
||||
// Allow single-digit hex value inputs
|
||||
let colorValue = colorCode.trim();
|
||||
|
@ -313,6 +357,15 @@ export class Color {
|
|||
return new Color(r / 255, g / 255, b / 255, a / 255);
|
||||
}
|
||||
|
||||
equals(other: Color): boolean {
|
||||
if (this.none && other.none) return true;
|
||||
return Math.abs(this.red - other.red) < 1e-6 && Math.abs(this.green - other.green) < 1e-6 && Math.abs(this.blue - other.blue) < 1e-6 && Math.abs(this.alpha - other.alpha) < 1e-6;
|
||||
}
|
||||
|
||||
lerp(other: Color, t: number): Color {
|
||||
return new Color(this.red * (1 - t) + other.red * t, this.green * (1 - t) + other.green * t, this.blue * (1 - t) + other.blue * t, this.alpha * (1 - t) + other.alpha * t);
|
||||
}
|
||||
|
||||
toHexNoAlpha(): string | undefined {
|
||||
if (this.none) return undefined;
|
||||
|
||||
|
@ -350,29 +403,18 @@ export class Color {
|
|||
};
|
||||
}
|
||||
|
||||
toRgba255(): RGBA | undefined {
|
||||
if (this.none) return undefined;
|
||||
|
||||
return {
|
||||
r: Math.round(this.red * 255),
|
||||
g: Math.round(this.green * 255),
|
||||
b: Math.round(this.blue * 255),
|
||||
a: Math.round(this.alpha * 255),
|
||||
};
|
||||
}
|
||||
|
||||
toRgbCSS(): string | undefined {
|
||||
const rgba = this.toRgba255();
|
||||
if (!rgba) return undefined;
|
||||
const rgb = this.toRgb255();
|
||||
if (!rgb) return undefined;
|
||||
|
||||
return `rgb(${rgba.r}, ${rgba.g}, ${rgba.b})`;
|
||||
return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
|
||||
}
|
||||
|
||||
toRgbaCSS(): string | undefined {
|
||||
const rgba = this.toRgba255();
|
||||
if (!rgba) return undefined;
|
||||
const rgb = this.toRgb255();
|
||||
if (!rgb) return undefined;
|
||||
|
||||
return `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${rgba.a})`;
|
||||
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${this.alpha})`;
|
||||
}
|
||||
|
||||
toHSV(): HSV | undefined {
|
||||
|
@ -700,10 +742,24 @@ export class CheckboxInput extends WidgetProps {
|
|||
}
|
||||
|
||||
export class ColorButton extends WidgetProps {
|
||||
@Transform(({ value }: { value: { red: number; green: number; blue: number; alpha: number } | undefined }) =>
|
||||
value === undefined ? new Color("none") : new Color(value.red, value.green, value.blue, value.alpha),
|
||||
)
|
||||
value!: Color;
|
||||
@Transform(({ value }) => {
|
||||
const gradient = value["Gradient"];
|
||||
if (gradient) {
|
||||
const stops = gradient.map(([position, color]: [number, color: { red: number; green: number; blue: number; alpha: number }]) => ({
|
||||
position,
|
||||
color: new Color(color.red, color.green, color.blue, color.alpha),
|
||||
}));
|
||||
return new Gradient(stops);
|
||||
}
|
||||
|
||||
const solid = value["Solid"];
|
||||
if (solid) {
|
||||
return new Color(solid.red, solid.green, solid.blue, solid.alpha);
|
||||
}
|
||||
|
||||
return new Color("none");
|
||||
})
|
||||
value!: FillChoice;
|
||||
|
||||
disabled!: boolean;
|
||||
|
||||
|
@ -715,6 +771,8 @@ export class ColorButton extends WidgetProps {
|
|||
tooltip!: string | undefined;
|
||||
}
|
||||
|
||||
export type FillChoice = Color | Gradient;
|
||||
|
||||
type MenuEntryCommon = {
|
||||
label: string;
|
||||
icon?: IconName;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue