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

View File

@@ -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));
}