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
@@ -529,6 +529,7 @@ impl ColorPickerMessageHandler {
.allow_insert(!self.disabled) .allow_insert(!self.disabled)
.allow_delete(!self.disabled) .allow_delete(!self.disabled)
.allow_reorder(true) .allow_reorder(true)
.allow_select(true)
.disabled(self.disabled) .disabled(self.disabled)
.on_update(|update: &SpectrumInputUpdate| ColorPickerMessage::GradientUpdate { update: update.clone() }.into()) .on_update(|update: &SpectrumInputUpdate| ColorPickerMessage::GradientUpdate { update: update.clone() }.into())
.widget_instance(), .widget_instance(),
@@ -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). /// 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")] #[serde(rename = "allowDelete")]
pub allow_delete: bool, 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")] #[serde(rename = "allowReorder")]
pub allow_reorder: bool, 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. /// Compact mode: 8px track height with 8px top padding, for use in rows alongside other widgets.
pub narrow: bool, 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. /// 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.
@@ -25,6 +25,7 @@
export let allowInsert = true; export let allowInsert = true;
export let allowDelete = true; export let allowDelete = true;
export let allowReorder = true; export let allowReorder = true;
export let allowSelect = false;
export let narrow = false; export let narrow = false;
export let rangeSlider = false; export let rangeSlider = false;
export let disabled = 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` // 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. // that would otherwise race the structural change before Rust has reported the dragged marker's new index.
let skipNextMove = false; 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) { function emit(intent: SpectrumInputUpdate) {
dispatch("update", intent); dispatch("update", intent);
} }
function setActive(index: number | undefined, isMidpoint: boolean) { function setActive(index: number | undefined, isMidpoint: boolean) {
dragIndex = index;
dragIsMidpoint = isMidpoint;
if (!allowSelect) return;
activeMarkerIndex = index; activeMarkerIndex = index;
activeMarkerIsMidpoint = isMidpoint; activeMarkerIsMidpoint = isMidpoint;
emit({ ActiveMarker: { 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 { function pointerPosition(e: MouseEvent, clamp = true): number | undefined {
const rect = markerTrackElement?.div()?.getBoundingClientRect(); const rect = markerTrackElement?.div()?.getBoundingClientRect();
if (!rect) return undefined; if (!rect) return undefined;
@@ -173,6 +197,8 @@
duplicateRequested = false; duplicateRequested = false;
duplicateActive = 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. // 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; activeMarkerIndex = insertIndex;
activeMarkerIsMidpoint = false; activeMarkerIsMidpoint = false;
addEvents(); addEvents();
@@ -208,7 +234,7 @@
let bestDistance = DUPLICATE_POSITION_EPSILON; let bestDistance = DUPLICATE_POSITION_EPSILON;
markers.forEach((marker, index) => { markers.forEach((marker, index) => {
if (index === activeMarkerIndex) return; if (index === dragIndex) return;
const distance = Math.abs(marker.position - startPosition); const distance = Math.abs(marker.position - startPosition);
if (distance < bestDistance) { 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. // 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. // Returns whether a structural change was emitted, so callers can skip the next move that would race it.
function reconcileDuplicate(): boolean { function reconcileDuplicate(): boolean {
if (!allowInsert || activeMarkerIndex === undefined || activeMarkerIsMidpoint) return false; if (!allowInsert || dragIndex === undefined || dragIsMidpoint) return false;
if (duplicateRequested && !duplicateActive) { if (duplicateRequested && !duplicateActive) {
// Drop a frozen copy at the drag's start position. The dragged marker stays active and becomes the duplicate being moved. // 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; if (dragRestorePosition === undefined) return false;
emit({ InsertDuplicate: { index: activeMarkerIndex, position: dragRestorePosition } }); emit({ InsertDuplicate: { index: dragIndex, position: dragRestorePosition } });
duplicateActive = true; duplicateActive = true;
return true; return true;
@@ -250,7 +276,7 @@
} }
function moveActiveMarker(e: PointerEvent) { function moveActiveMarker(e: PointerEvent) {
if (disabled || activeMarkerIndex === undefined) return; if (disabled || dragIndex === undefined) return;
if (e.buttons === 0) { if (e.buttons === 0) {
endDrag(); endDrag();
return; return;
@@ -271,15 +297,16 @@
let position = pointerPosition(e); let position = pointerPosition(e);
if (position === undefined) return; 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; dragMoved = true;
if (!dragInsertedMarker) dispatch("dragging", true); if (!dragInsertedMarker) dispatch("dragging", true);
emit({ MoveMarker: { index: activeMarkerIndex, position } }); emit({ MoveMarker: { index: dragIndex, position } });
} }
function moveActiveMidpoint(e: PointerEvent) { function moveActiveMidpoint(e: PointerEvent) {
if (disabled || activeMarkerIndex === undefined) return; if (disabled || dragIndex === undefined) return;
if (e.buttons === 0) { if (e.buttons === 0) {
endDrag(); endDrag();
return; 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. // 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. // 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); const absolute = pointerPosition(e, !isWrappedInterval);
if (absolute === undefined) return; if (absolute === undefined) return;
const left = markers[activeMarkerIndex]?.position; const left = markers[dragIndex]?.position;
const right = isWrappedInterval ? markers[0].position + 1 : markers[activeMarkerIndex + 1]?.position; const right = isWrappedInterval ? markers[0].position + 1 : markers[dragIndex + 1]?.position;
if (left === undefined || right === undefined) return; if (left === undefined || right === undefined) return;
const range = right - left; const range = right - left;
if (range <= 0) return; if (range <= 0) return;
@@ -310,13 +337,13 @@
dragMoved = true; dragMoved = true;
dispatch("dragging", true); dispatch("dragging", true);
emit({ MoveMidpoint: { index: activeMarkerIndex, position: local / range } }); emit({ MoveMidpoint: { index: dragIndex, position: local / range } });
} }
function abortDrag() { function abortDrag() {
if (disabled || activeMarkerIndex === undefined) return; if (disabled || dragIndex === undefined) return;
const dragged = activeMarkerIndex; const dragged = dragIndex;
const anchor = duplicateActive ? findDuplicateAnchorIndex() : undefined; const anchor = duplicateActive ? findDuplicateAnchorIndex() : undefined;
if (dragInsertedMarker) { if (dragInsertedMarker) {
@@ -327,7 +354,7 @@
emit({ DeleteMarker: { index: dragged } }); emit({ DeleteMarker: { index: dragged } });
} else if (dragRestorePosition !== undefined) { } else if (dragRestorePosition !== undefined) {
// Plain drag: return the marker (or midpoint) to where it began. // 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 } }); else emit({ MoveMarker: { index: dragged, position: dragRestorePosition } });
} }
@@ -349,11 +376,16 @@
duplicateRequested = false; duplicateRequested = false;
duplicateActive = false; duplicateActive = false;
skipNextMove = false; skipNextMove = false;
// Without selection nothing stays active once the drag ends
if (!allowSelect) {
dragIndex = undefined;
dragIsMidpoint = false;
}
dispatch("dragging", false); dispatch("dragging", false);
} }
function onPointerMove(e: PointerEvent) { function onPointerMove(e: PointerEvent) {
if (activeMarkerIsMidpoint) moveActiveMidpoint(e); if (dragIsMidpoint) moveActiveMidpoint(e);
else moveActiveMarker(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 // 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 // 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). // 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; duplicateRequested = true;
if (reconcileDuplicate()) skipNextMove = true; if (reconcileDuplicate()) skipNextMove = true;
} }
@@ -479,9 +511,11 @@
{#if marker.position >= 0 && marker.position <= 1} {#if marker.position >= 0 && marker.position <= 1}
<svg <svg
class="marker" class="marker"
class:active={index === activeMarkerIndex && !activeMarkerIsMidpoint} class:active={highlightedRun !== undefined && index >= highlightedRun[0] && index <= highlightedRun[1]}
style:--marker-position={marker.position} style:--marker-position={marker.position}
style:--marker-color={marker.handleColorCSS} style:--marker-color={marker.handleColorCSS}
on:pointerenter={() => markerPointerEnter(index)}
on:pointerleave={() => (hoverRun = undefined)}
on:pointerdown={(e) => markerPointerDown(e, index)} on:pointerdown={(e) => markerPointerDown(e, index)}
on:dblclick={() => markerDoubleClick(index)} on:dblclick={() => markerDoubleClick(index)}
data-gradient-marker data-gradient-marker