Add support for gradients with midpoints and add draggable diamonds to the color picker dialog (#3813)

* Refactor GradientStops to use struct-of-arrays and include midpoint

* Implement interaction and rendering

* Make color picker saturation-value color picking snap to original position and show both axis lines

Make color picker saturation-value color picking snap to original position and show both axis lines

* Add graphite:midpoint attribute to SVG exports

* Add graphite:midpoint parsing to SVG importer
This commit is contained in:
Keavon Chambers
2026-02-23 19:21:51 -08:00
committed by GitHub
parent a1c1039ea1
commit 691d965bcf
21 changed files with 842 additions and 322 deletions
@@ -13,7 +13,7 @@
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 SpectrumInput, { MAX_MIDPOINT, MIN_MIDPOINT } 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";
@@ -57,7 +57,8 @@
// Gradient color stops
$: gradient = colorOrGradient instanceof Gradient ? colorOrGradient : undefined;
let activeIndex = 0 as number | undefined;
$: selectedGradientColor = (activeIndex !== undefined && gradient?.atIndex(activeIndex)?.color) || (Color.fromCSS("black") as Color);
let activeIndexIsMidpoint = false;
$: selectedGradientColor = (activeIndex !== undefined && gradient?.color[activeIndex]) || (Color.fromCSS("black") as Color);
// Currently viewed color
$: color = colorOrGradient instanceof Color ? colorOrGradient : selectedGradientColor;
// New color components
@@ -191,8 +192,8 @@
alignedAxis = undefined;
} else if (!shiftPressed && draggingPickerTrack) {
shiftPressed = true;
saturationStartOfAxisAlign = saturation;
valueStartOfAxisAlign = value;
saturationStartOfAxisAlign = saturationBeforeDrag;
valueStartOfAxisAlign = valueBeforeDrag;
}
}
@@ -286,8 +287,9 @@
function setColor(color?: Color) {
const colorToEmit = color || new Color({ h: hue, s: saturation, v: value, a: alpha });
const stop = gradientSpectrumInputWidget && activeIndex !== undefined && gradient?.atIndex(activeIndex);
if (stop) stop.color = colorToEmit;
if (gradientSpectrumInputWidget && activeIndex !== undefined && gradient?.position[activeIndex] !== undefined && colorOrGradient instanceof Gradient) {
colorOrGradient.color[activeIndex] = colorToEmit;
}
dispatch("colorOrGradient", gradient || colorToEmit);
}
@@ -397,9 +399,11 @@
}
}
function gradientActiveMarkerIndexChange({ detail: index }: CustomEvent<number | undefined>) {
activeIndex = index;
const color = index === undefined ? undefined : gradient?.colorAtIndex(index);
function gradientActiveMarkerIndexChange({ detail: { activeMarkerIndex, activeMarkerIsMidpoint } }: CustomEvent<{ activeMarkerIndex: number | undefined; activeMarkerIsMidpoint: boolean }>) {
activeIndex = activeMarkerIndex;
activeIndexIsMidpoint = activeMarkerIsMidpoint;
const color = activeMarkerIndex === undefined ? undefined : gradient?.color[activeMarkerIndex];
const hsva = color?.toHSVA();
if (!color || !hsva) return;
@@ -440,17 +444,24 @@
on:pointerdown={onPointerDown}
data-saturation-value-picker
>
{#if !isNone}
<div class="selection-circle" style:top={`${(1 - value) * 100}%`} style:left={`${saturation * 100}%`}></div>
{/if}
{#if alignedAxis}
<div
class="selection-circle-alignment"
class:saturation={alignedAxis === "saturation"}
class:value={alignedAxis === "value"}
style:top={`${(1 - value) * 100}%`}
style:left={`${saturation * 100}%`}
class="selection-circle-axis-snap-line"
style:width={alignedAxis === "value" ? "100%" : undefined}
style:height={alignedAxis === "saturation" ? "100%" : undefined}
style:top={alignedAxis === "value" ? `${(1 - value) * 100}%` : undefined}
style:left={alignedAxis === "saturation" ? `${saturation * 100}%` : undefined}
></div>
<div
class="selection-circle-axis-snap-line"
style:width={alignedAxis === "saturation" ? "100%" : undefined}
style:height={alignedAxis === "value" ? "100%" : undefined}
style:top={alignedAxis === "saturation" ? `${(1 - valueBeforeDrag) * 100}%` : undefined}
style:left={alignedAxis === "value" ? `${saturationBeforeDrag * 100}%` : undefined}
></div>
{/if}
{#if !isNone}
<div class="selection-circle" style:top={`${(1 - value) * 100}%`} style:left={`${saturation * 100}%`}></div>
{/if}
</LayoutCol>
<LayoutCol
@@ -484,19 +495,22 @@
on:gradient={() => dispatch("colorOrGradient", gradient)}
on:activeMarkerIndexChange={gradientActiveMarkerIndexChange}
activeMarkerIndex={activeIndex}
activeMarkerIsMidpoint={activeIndexIsMidpoint}
on:dragging={({ detail }) => (gradientSpectrumDragging = detail)}
bind:this={gradientSpectrumInputWidget}
/>
{#if gradientSpectrumInputWidget && activeIndex !== undefined}
<NumberInput
value={(gradient.positionAtIndex(activeIndex) || 0) * 100}
value={(activeIndexIsMidpoint ? gradient.midpoint[activeIndex] : gradient.position[activeIndex] || 0) * 100}
{disabled}
on:value={({ detail }) => {
if (gradientSpectrumInputWidget && activeIndex !== undefined && detail !== undefined) gradientSpectrumInputWidget.setPosition(activeIndex, detail / 100);
on:value={({ detail: position }) => {
if (gradientSpectrumInputWidget && activeIndex !== undefined && position !== undefined) {
gradientSpectrumInputWidget.setPosition(activeIndex, position / 100, activeIndexIsMidpoint);
}
}}
displayDecimalPlaces={0}
min={0}
max={100}
min={activeIndexIsMidpoint ? MIN_MIDPOINT * 100 : 0}
max={activeIndexIsMidpoint ? MAX_MIDPOINT * 100 : 100}
unit="%"
/>
{/if}
@@ -744,12 +758,12 @@
}
.selection-circle {
pointer-events: none;
position: absolute;
left: 0;
top: 0;
width: 0;
height: 0;
pointer-events: none;
&::after {
content: "";
@@ -761,56 +775,31 @@
height: calc(var(--picker-circle-radius) * 2 + 1px);
border-radius: 50%;
border: 2px solid var(--opaque-color-contrasting);
background: var(--opaque-color);
box-sizing: border-box;
}
}
.selection-circle-alignment {
position: absolute;
.selection-circle-axis-snap-line {
pointer-events: none;
position: absolute;
width: 1px;
height: 1px;
top: 0;
left: 0;
background: var(--opaque-color-contrasting);
&.saturation::before,
&.saturation::after,
&.value::before,
&.value::after {
content: "";
position: absolute;
background: var(--opaque-color-contrasting);
width: 1px;
height: 1px;
}
&.saturation {
&::before {
height: var(--picker-size);
margin-top: calc(-1 * var(--picker-size) - var(--picker-circle-radius));
}
&::after {
height: var(--picker-size);
margin-top: var(--picker-circle-radius);
}
}
&.value {
&::before {
width: var(--picker-size);
margin-left: var(--picker-circle-radius);
}
&::after {
width: var(--picker-size);
margin-left: calc(-1 * var(--picker-size) - var(--picker-circle-radius));
}
+ .selection-circle-axis-snap-line {
opacity: 0.25;
}
}
.selection-needle {
pointer-events: none;
position: absolute;
top: 0;
width: 100%;
height: 0;
pointer-events: none;
&::before {
content: "";
@@ -881,13 +870,13 @@
&.outlined::after {
content: "";
pointer-events: none;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
box-shadow: inset 0 0 0 1px rgba(var(--color-0-black-rgb), var(--outline-amount));
pointer-events: none;
}
&.transparency {