Add a selection mode to the spectrum widget so only the gradient editor supports having a highlighted stop (#4533)

* Add a selection mode to the spectrum widget so only the gradient editor keeps a highlighted stop

* Record the drag mode for every drag and require selection for reordering so a non-selecting widget cannot drag the wrong stop
This commit is contained in:
Keavon Chambers
2026-09-14 19:55:38 -07:00
committed by GitHub
parent e8f7366a43
commit e38926004f
3 changed files with 57 additions and 18 deletions

View File

@@ -529,6 +529,7 @@ impl ColorPickerMessageHandler {
.allow_insert(!self.disabled)
.allow_delete(!self.disabled)
.allow_reorder(true)
.allow_select(true)
.disabled(self.disabled)
.on_update(|update: &SpectrumInputUpdate| ColorPickerMessage::GradientUpdate { update: update.clone() }.into())
.widget_instance(),

View File

@@ -668,9 +668,13 @@ pub struct SpectrumInput {
/// Whether right-click or pressing Delete removes a marker. The handler still has the final say on whether the deletion goes through (e.g., enforcing a minimum count).
#[serde(rename = "allowDelete")]
pub allow_delete: bool,
/// Whether dragging a marker past another reorders them. If false, the dragged marker is clamped between its neighbors.
/// Whether dragging a marker past another reorders them, which also needs `allow_select`. Otherwise the dragged marker is clamped between its neighbors.
#[serde(rename = "allowReorder")]
pub allow_reorder: bool,
/// Whether clicking a marker selects it, keeping it highlighted and reported as the active marker until another is chosen,
/// as a gradient editor needs for the stop being edited. Otherwise the highlight only follows the pointer and the drag.
#[serde(rename = "allowSelect")]
pub allow_select: bool,
/// Compact mode: 8px track height with 8px top padding, for use in rows alongside other widgets.
pub narrow: bool,
/// Plain range-slider mode, for a number beside its number input: a flat 4px track is drawn in place of the gradient, so `track` is never shown.

View File

@@ -25,6 +25,7 @@
export let allowInsert = true;
export let allowDelete = true;
export let allowReorder = true;
export let allowSelect = false;
export let narrow = false;
export let rangeSlider = false;
export let disabled = false;
@@ -49,17 +50,40 @@
// Set when a key-triggered reconcile inserts/removes the frozen copy, so the next pointer move skips emitting a `MoveMarker`
// that would otherwise race the structural change before Rust has reported the dragged marker's new index.
let skipNextMove = false;
// The hovered marker, highlighted ahead of the drag.
let hoverRun: [number, number] | undefined = undefined;
// The marker being dragged, or the left marker of the interval when a midpoint is dragged, and which of the two it is.
// Where selecting is allowed, these follow the selection, which Rust renumbers across structural changes.
let dragIndex: number | undefined = undefined;
let dragIsMidpoint = false;
$: if (allowSelect) {
dragIndex = activeMarkerIndex;
dragIsMidpoint = activeMarkerIsMidpoint;
}
// The marker highlighted: the one hovered, else the one being dragged (or selected, where selecting is allowed).
let highlightedRun: [number, number] | undefined;
$: highlightedRun = hoverRun !== undefined ? hoverRun : typeof dragIndex === "number" && !dragIsMidpoint ? [dragIndex, dragIndex] : undefined;
function emit(intent: SpectrumInputUpdate) {
dispatch("update", intent);
}
function setActive(index: number | undefined, isMidpoint: boolean) {
dragIndex = index;
dragIsMidpoint = isMidpoint;
if (!allowSelect) return;
activeMarkerIndex = index;
activeMarkerIsMidpoint = isMidpoint;
emit({ ActiveMarker: { activeMarkerIndex: index, activeMarkerIsMidpoint: isMidpoint } });
}
// Hovering highlights what a drag would carry, except where selecting is allowed and hover keeps its lighter tint
function markerPointerEnter(index: number) {
if (allowSelect) return;
hoverRun = [index, index];
}
function pointerPosition(e: MouseEvent, clamp = true): number | undefined {
const rect = markerTrackElement?.div()?.getBoundingClientRect();
if (!rect) return undefined;
@@ -173,6 +197,8 @@
duplicateRequested = false;
duplicateActive = false;
// Don't dispatch an `ActiveMarker` here. The Rust handler already updates the active marker in response to `InsertMarker` and a duplicate `ActiveMarker` would race the layout update.
dragIndex = insertIndex;
dragIsMidpoint = false;
activeMarkerIndex = insertIndex;
activeMarkerIsMidpoint = false;
addEvents();
@@ -208,7 +234,7 @@
let bestDistance = DUPLICATE_POSITION_EPSILON;
markers.forEach((marker, index) => {
if (index === activeMarkerIndex) return;
if (index === dragIndex) return;
const distance = Math.abs(marker.position - startPosition);
if (distance < bestDistance) {
@@ -223,14 +249,14 @@
// Bring the materialized duplicate state in line with whether Alt is currently held, inserting or removing the frozen copy.
// Returns whether a structural change was emitted, so callers can skip the next move that would race it.
function reconcileDuplicate(): boolean {
if (!allowInsert || activeMarkerIndex === undefined || activeMarkerIsMidpoint) return false;
if (!allowInsert || dragIndex === undefined || dragIsMidpoint) return false;
if (duplicateRequested && !duplicateActive) {
// Drop a frozen copy at the drag's start position. The dragged marker stays active and becomes the duplicate being moved.
if (dragRestorePosition === undefined) return false;
emit({ InsertDuplicate: { index: activeMarkerIndex, position: dragRestorePosition } });
emit({ InsertDuplicate: { index: dragIndex, position: dragRestorePosition } });
duplicateActive = true;
return true;
@@ -250,7 +276,7 @@
}
function moveActiveMarker(e: PointerEvent) {
if (disabled || activeMarkerIndex === undefined) return;
if (disabled || dragIndex === undefined) return;
if (e.buttons === 0) {
endDrag();
return;
@@ -271,15 +297,16 @@
let position = pointerPosition(e);
if (position === undefined) return;
if (!allowReorder) position = clampToNeighbors(activeMarkerIndex, position);
// Without selection nothing reports the dragged marker's new index after a reorder, so it stays between its neighbors
if (!allowReorder || !allowSelect) position = clampToNeighbors(dragIndex, position);
dragMoved = true;
if (!dragInsertedMarker) dispatch("dragging", true);
emit({ MoveMarker: { index: activeMarkerIndex, position } });
emit({ MoveMarker: { index: dragIndex, position } });
}
function moveActiveMidpoint(e: PointerEvent) {
if (disabled || activeMarkerIndex === undefined) return;
if (disabled || dragIndex === undefined) return;
if (e.buttons === 0) {
endDrag();
return;
@@ -287,12 +314,12 @@
// The wrapped interval's diamond (cyclic only) belongs to the last marker and spans through the 1|0 boundary to the first.
// Its pointer ratio stays unclamped so overdragging past the strip's right or left edge keeps tracking after the 1|0 wrap.
const isWrappedInterval = trackCyclic && activeMarkerIndex === markers.length - 1;
const isWrappedInterval = trackCyclic && dragIndex === markers.length - 1;
const absolute = pointerPosition(e, !isWrappedInterval);
if (absolute === undefined) return;
const left = markers[activeMarkerIndex]?.position;
const right = isWrappedInterval ? markers[0].position + 1 : markers[activeMarkerIndex + 1]?.position;
const left = markers[dragIndex]?.position;
const right = isWrappedInterval ? markers[0].position + 1 : markers[dragIndex + 1]?.position;
if (left === undefined || right === undefined) return;
const range = right - left;
if (range <= 0) return;
@@ -310,13 +337,13 @@
dragMoved = true;
dispatch("dragging", true);
emit({ MoveMidpoint: { index: activeMarkerIndex, position: local / range } });
emit({ MoveMidpoint: { index: dragIndex, position: local / range } });
}
function abortDrag() {
if (disabled || activeMarkerIndex === undefined) return;
if (disabled || dragIndex === undefined) return;
const dragged = activeMarkerIndex;
const dragged = dragIndex;
const anchor = duplicateActive ? findDuplicateAnchorIndex() : undefined;
if (dragInsertedMarker) {
@@ -327,7 +354,7 @@
emit({ DeleteMarker: { index: dragged } });
} else if (dragRestorePosition !== undefined) {
// Plain drag: return the marker (or midpoint) to where it began.
if (activeMarkerIsMidpoint) emit({ MoveMidpoint: { index: dragged, position: dragRestorePosition } });
if (dragIsMidpoint) emit({ MoveMidpoint: { index: dragged, position: dragRestorePosition } });
else emit({ MoveMarker: { index: dragged, position: dragRestorePosition } });
}
@@ -349,11 +376,16 @@
duplicateRequested = false;
duplicateActive = false;
skipNextMove = false;
// Without selection nothing stays active once the drag ends
if (!allowSelect) {
dragIndex = undefined;
dragIsMidpoint = false;
}
dispatch("dragging", false);
}
function onPointerMove(e: PointerEvent) {
if (activeMarkerIsMidpoint) moveActiveMidpoint(e);
if (dragIsMidpoint) moveActiveMidpoint(e);
else moveActiveMarker(e);
}
@@ -377,7 +409,7 @@
// Pressing Alt mid-drag duplicates the marker, leaving a frozen copy where the drag began. Reconcile immediately for instant
// feedback, and arm a skip so the next pointer move doesn't race the just-emitted structural change. Only when dragging an
// existing stop (not one being created by this drag).
if (e.key === "Alt" && allowInsert && !activeMarkerIsMidpoint && !dragInsertedMarker && !duplicateRequested) {
if (e.key === "Alt" && allowInsert && !dragIsMidpoint && !dragInsertedMarker && !duplicateRequested) {
duplicateRequested = true;
if (reconcileDuplicate()) skipNextMove = true;
}
@@ -479,9 +511,11 @@
{#if marker.position >= 0 && marker.position <= 1}
<svg
class="marker"
class:active={index === activeMarkerIndex && !activeMarkerIsMidpoint}
class:active={highlightedRun !== undefined && index >= highlightedRun[0] && index <= highlightedRun[1]}
style:--marker-position={marker.position}
style:--marker-color={marker.handleColorCSS}
on:pointerenter={() => markerPointerEnter(index)}
on:pointerleave={() => (hoverRun = undefined)}
on:pointerdown={(e) => markerPointerDown(e, index)}
on:dblclick={() => markerDoubleClick(index)}
data-gradient-marker