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 {
@@ -28,7 +28,7 @@
$: outlined = outlineFactor > 0.0001;
$: chosenGradient = value instanceof Gradient ? value.toLinearGradientCSS() : `linear-gradient(${value.toHexOptionalAlpha()}, ${value.toHexOptionalAlpha()})`;
$: none = value instanceof Color ? value.none : false;
$: transparency = value instanceof Gradient ? value.stops.some((stop) => stop.color.alpha < 1) : value.alpha < 1;
$: transparency = value instanceof Gradient ? value.color.some((color) => color.alpha < 1) : value.alpha < 1;
</script>
<LayoutCol class="color-button" classes={{ open, disabled, narrow, none, transparency, outlined, "direction-top": menuDirection === "Top" }} {tooltipLabel} {tooltipDescription} {tooltipShortcut}>
@@ -1,6 +1,12 @@
<script lang="ts" context="module">
export const MIN_MIDPOINT = 0.01;
export const MAX_MIDPOINT = 0.99;
</script>
<script lang="ts">
import { createEventDispatcher, onDestroy } from "svelte";
import { evaluateGradientAtPosition } from "@graphite/../wasm/pkg/graphite_wasm";
import { Color, type Gradient } from "@graphite/messages";
import { preventEscapeClosingParentFloatingMenu } from "@graphite/components/layout/FloatingMenu.svelte";
@@ -10,27 +16,44 @@
const BUTTON_LEFT = 0;
const BUTTON_RIGHT = 2;
const dispatch = createEventDispatcher<{ activeMarkerIndexChange: number | undefined; gradient: Gradient; dragging: boolean }>();
const dispatch = createEventDispatcher<{ activeMarkerIndexChange: { activeMarkerIndex: number | undefined; activeMarkerIsMidpoint: boolean }; gradient: Gradient; dragging: boolean }>();
export let gradient: Gradient;
export let disabled = false;
export let activeMarkerIndex = 0 as number | undefined;
export let activeMarkerIsMidpoint = false;
// export let disabled = false;
// export let tooltipLabel: string | undefined = undefined;
// export let tooltipDescription: string | undefined = undefined;
// export let tooltipShortcut: ActionShortcut | undefined = undefined;
/// Reference to the marker track element so we can access its div.
let markerTrack: LayoutRow | undefined = undefined;
let positionRestore: number | undefined = undefined;
/// When dragging, stores the original value of the marker or midpoint being dragged, so we can restore it if the drag is cancelled.
let dragRestore: number | undefined = undefined;
/// When dragging, indicates whether this maker was inserted during the drag, so we know whether to remove it again if the drag is cancelled.
let deletionRestore: boolean | undefined = undefined;
/// When dragging, stores the previous active marker (or its midpoint) index, so we can restore active selection to that one if the drag is cancelled on a different marker.
let activeMarkerIndexRestore: number | undefined = undefined;
/// When dragging, stores whether the previously active drag item was a midpoint (matching the index kept in `activeMarkerIndexRestore`), so we can restore its active selection if cancelled.
let activeMarkerIsMidpointRestore = false;
/// When dragging a midpoint, tracks whether the midpoint has actually moved by at least a pixel, to tell between a click-then-click-and-drag (just a drag) or a double-click (reset the midpoint).
let midpointDragged = false;
function markerPointerDown(e: PointerEvent, index: number) {
if (disabled) return;
// Left-click to select and begin potentially dragging
if (e.button === BUTTON_LEFT) {
// Set restore values at this time, so later the user can cancel the drag and restore to these values
activeMarkerIndexRestore = activeMarkerIndex;
activeMarkerIsMidpointRestore = activeMarkerIsMidpoint;
// Update the parent component with the newly activated marker or midpoint drag item
activeMarkerIndex = index;
dispatch("activeMarkerIndexChange", index);
activeMarkerIsMidpoint = false;
dispatch("activeMarkerIndexChange", { activeMarkerIndex, activeMarkerIsMidpoint });
addEvents();
return;
}
@@ -51,37 +74,68 @@
return Math.max(0, Math.min(1, ratio));
}
function insertStop(e: MouseEvent) {
function midpointPointerDown(e: PointerEvent, index: number) {
if (disabled) return;
if (e.button !== BUTTON_LEFT) return;
// Since we just pressed the mouse button down, the midpoint has not been dragged by any distance
midpointDragged = false;
// Set restore values at this time, so later the user can cancel the drag and restore to these values
activeMarkerIndexRestore = activeMarkerIndex;
activeMarkerIsMidpointRestore = activeMarkerIsMidpoint;
// Update the parent component with the newly activated marker or midpoint drag item
activeMarkerIndex = index;
activeMarkerIsMidpoint = true;
dispatch("activeMarkerIndexChange", { activeMarkerIndex, activeMarkerIsMidpoint });
addEvents();
}
function resetMidpoint(index: number) {
if (disabled || midpointDragged) return;
gradient.midpoint[index] = 0.5;
dispatch("gradient", gradient);
}
function insertStop(e: MouseEvent) {
if (disabled) return;
if (e.button !== BUTTON_LEFT) return;
// Determine the position along the gradient (0-1) based on the click position in the marker track
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);
// Determine which index the new stop should be inserted at based on its position
let index = gradient.position.findIndex((item) => item > position);
if (index === -1) index = gradient.position.length;
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;
}
// Determine the color of the new stop by evaluating the gradient at the position of the new stop
type ReturnedColor = { red: number; green: number; blue: number; alpha: number };
const evaluated = evaluateGradientAtPosition(position, new Float64Array(gradient.position), new Float64Array(gradient.midpoint), gradient.color) as ReturnedColor;
const color = new Color(evaluated.red, evaluated.green, evaluated.blue, evaluated.alpha);
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;
deletionRestore = true;
dispatch("activeMarkerIndexChange", index);
// Insert the new stop into the gradient
gradient.position.splice(index, 0, position);
// Duplicate the midpoint ratio position of the interval we're inserting into, so both new intervals have the same midpoint position ratio
gradient.midpoint.splice(index, 0, gradient.midpoint[index - 1] || 0.5);
gradient.color.splice(index, 0, color);
dispatch("gradient", gradient);
// Set restore values at this time, so later the user can cancel the drag and restore to these values
activeMarkerIndexRestore = activeMarkerIndex;
activeMarkerIsMidpointRestore = activeMarkerIsMidpoint;
// Update the parent component with the newly activated marker or midpoint drag item
activeMarkerIndex = index;
activeMarkerIsMidpoint = false;
dispatch("activeMarkerIndexChange", { activeMarkerIndex, activeMarkerIsMidpoint });
// Since this stop insertion can happen as part of the beginning of a drag, we set this to indicate that it should be removed again if the drag is cancelled
deletionRestore = true;
addEvents();
}
@@ -90,27 +144,35 @@
if (e.key !== "Delete" && e.key !== "Backspace") return;
if (activeMarkerIndex === undefined) return;
if (gradient.position.length <= 2 && !activeMarkerIsMidpoint) return;
if (positionRestore !== undefined) stopDrag();
// Stop dragging the marker or midpoint
stopDrag();
deleteStopByIndex(activeMarkerIndex);
// Either reset the midpoint to 50% or delete the marker, based on which type is currently active
if (activeMarkerIsMidpoint) resetMidpoint(activeMarkerIndex);
else deleteStopByIndex(activeMarkerIndex);
}
function deleteStopByIndex(index: number) {
if (disabled) return;
if (gradient.stops.length <= 2) return;
if (gradient.position.length <= 2) return;
gradient.position.splice(index, 1);
gradient.midpoint.splice(index, 1);
gradient.color.splice(index, 1);
dispatch("gradient", gradient);
gradient.stops.splice(index, 1);
if (gradient.stops.length === 0) {
activeMarkerIndex = undefined;
} else {
activeMarkerIndex = Math.max(0, Math.min(gradient.stops.length - 1, index));
}
deletionRestore = undefined;
dispatch("activeMarkerIndexChange", activeMarkerIndex);
dispatch("gradient", gradient);
if (gradient.position.length === 0) {
activeMarkerIndex = undefined;
} else {
activeMarkerIndex = Math.max(0, Math.min(gradient.position.length - 1, index));
}
activeMarkerIsMidpoint = false;
dispatch("activeMarkerIndexChange", { activeMarkerIndex, activeMarkerIsMidpoint });
}
function moveMarker(e: PointerEvent, index: number) {
@@ -122,40 +184,99 @@
let position = markerPosition(e);
if (position === undefined) return;
if (positionRestore === undefined) positionRestore = position;
if (dragRestore === undefined) dragRestore = position;
if (deletionRestore === undefined) {
deletionRestore = false;
dispatch("dragging", true);
}
setPosition(index, position);
setPosition(index, position, false);
}
export function setPosition(index: number, position: number) {
function moveMidpoint(e: PointerEvent, index: number) {
if (disabled) return;
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));
// Guard in case the mouseup event is lost
if (e.buttons === 0) {
stopDrag();
return;
}
let position = markerPosition(e);
if (position === undefined) return;
if (dragRestore === undefined) {
dragRestore = gradient.midpoint[index];
midpointDragged = true;
dispatch("dragging", true);
}
const leftStop = gradient.position[index];
const rightStop = gradient.position[index + 1];
const range = rightStop - leftStop;
if (range <= 0) return;
gradient.midpoint[index] = Math.max(MIN_MIDPOINT, Math.min(MAX_MIDPOINT, (position - leftStop) / range));
dispatch("gradient", gradient);
}
export function setPosition(index: number, position: number, isMidpoint: boolean) {
if (disabled) return;
const markers = toMarkers(gradient);
const active = markers[index];
if (isMidpoint) active.midpoint = position;
else active.position = position;
markers.sort((a, b) => a.position - b.position);
if (markers.indexOf(active) !== activeMarkerIndex) {
activeMarkerIndex = markers.indexOf(active);
dispatch("activeMarkerIndexChange", { activeMarkerIndex, activeMarkerIsMidpoint });
}
gradient.position = markers.map((stop) => stop.position);
gradient.midpoint = markers.map((stop) => stop.midpoint);
gradient.color = markers.map((stop) => stop.color);
dispatch("gradient", gradient);
}
function toMarkers(gradient: Gradient): { position: number; midpoint: number; color: Color }[] {
return gradient.position.map((position, i) => ({
position,
midpoint: gradient.midpoint[i],
color: gradient.color[i],
}));
}
function toMidpoints(gradient: Gradient): number[] {
if (gradient.position.length < 2) return [];
return gradient.midpoint.slice(0, -1).map((midpoint, i) => {
const leftMarker = gradient.position[i];
const rightMarker = gradient.position[i + 1];
return leftMarker + midpoint * (rightMarker - leftMarker);
});
}
function abortDrag() {
if (disabled) return;
if (activeMarkerIndex === undefined) return;
if (deletionRestore) {
deleteStopByIndex(activeMarkerIndex);
} else if (positionRestore !== undefined) {
setPosition(activeMarkerIndex, positionRestore);
if (activeMarkerIndex !== undefined) {
if (activeMarkerIsMidpoint && dragRestore !== undefined) {
gradient.midpoint[activeMarkerIndex] = dragRestore;
dispatch("gradient", gradient);
} else {
if (deletionRestore) deleteStopByIndex(activeMarkerIndex);
else if (dragRestore !== undefined) setPosition(activeMarkerIndex, dragRestore, false);
}
}
activeMarkerIndex = activeMarkerIndexRestore;
activeMarkerIsMidpoint = activeMarkerIsMidpointRestore;
dispatch("activeMarkerIndexChange", { activeMarkerIndex, activeMarkerIsMidpoint });
stopDrag();
}
@@ -164,8 +285,11 @@
removeEvents();
positionRestore = undefined;
dragRestore = undefined;
deletionRestore = undefined;
activeMarkerIndexRestore = undefined;
activeMarkerIsMidpointRestore = false;
midpointDragged = false;
dispatch("dragging", false);
}
@@ -173,7 +297,8 @@
function onPointerMove(e: PointerEvent) {
if (disabled) return;
if (activeMarkerIndex !== undefined) moveMarker(e, activeMarkerIndex);
if (activeMarkerIsMidpoint && activeMarkerIndex !== undefined) moveMidpoint(e, activeMarkerIndex);
else if (activeMarkerIndex !== undefined) moveMarker(e, activeMarkerIndex);
}
function onPointerUp() {
@@ -249,11 +374,27 @@
}}
>
<LayoutRow class="gradient-strip" on:pointerdown={insertStop}></LayoutRow>
<LayoutRow class="midpoint-track">
{#each toMidpoints(gradient) as midpoint, index}
<svg
class="midpoint"
class:active={index === activeMarkerIndex && activeMarkerIsMidpoint}
style:--midpoint-position={midpoint}
on:pointerdown={(e) => midpointPointerDown(e, index)}
on:dblclick={() => resetMidpoint(index)}
data-gradient-midpoint
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 8 8"
>
<polygon points="0,4 4,0 8,4 4,8" />
</svg>
{/each}
</LayoutRow>
<LayoutRow class="marker-track" bind:this={markerTrack}>
{#each gradient.stops as marker, index}
{#each toMarkers(gradient) as marker, index}
<svg
class="marker"
class:active={index === activeMarkerIndex}
class:active={index === activeMarkerIndex && !activeMarkerIsMidpoint}
style:--marker-position={marker.position}
style:--marker-color={marker.color.toRgbCSS()}
on:pointerdown={(e) => markerPointerDown(e, index)}
@@ -276,6 +417,7 @@
<style lang="scss" global>
.spectrum-input {
position: relative;
--marker-half-width: 6px;
.gradient-strip {
@@ -310,6 +452,39 @@
}
}
.midpoint-track {
position: absolute;
top: 0;
left: var(--marker-half-width);
right: var(--marker-half-width);
.midpoint {
position: absolute;
margin-left: -4px;
width: 8px;
height: 8px;
bottom: 0;
left: calc(var(--midpoint-position) * 100%);
polygon {
stroke: var(--color-e-nearwhite);
fill: var(--color-2-mildblack);
}
&.active {
z-index: 1;
polygon {
fill: var(--color-e-nearwhite);
}
}
}
}
&.disabled .midpoint-track .midpoint polygon {
stroke: var(--color-4-dimgray);
}
.marker-track {
margin-top: calc(24px - 16px - 12px);
margin-left: var(--marker-half-width);
@@ -357,6 +532,8 @@
}
&.active {
z-index: 1;
.inner-fill {
filter: drop-shadow(0 0 1px var(--color-2-mildblack)) drop-shadow(0 0 1px var(--color-2-mildblack));
}