From e38926004f0aa4f0d623a11d93fd4ae740fb6257 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 14 Sep 2026 19:55:38 -0700 Subject: [PATCH] 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 --- .../color_picker_message_handler.rs | 1 + .../utility_types/widgets/input_widgets.rs | 6 +- .../widgets/inputs/SpectrumInput.svelte | 68 ++++++++++++++----- 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/editor/src/messages/color_picker/color_picker_message_handler.rs b/editor/src/messages/color_picker/color_picker_message_handler.rs index 03d055a31b..208fa10e9a 100644 --- a/editor/src/messages/color_picker/color_picker_message_handler.rs +++ b/editor/src/messages/color_picker/color_picker_message_handler.rs @@ -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(), diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs index e0d1731f4c..f0d20adbdf 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -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. diff --git a/frontend/src/components/widgets/inputs/SpectrumInput.svelte b/frontend/src/components/widgets/inputs/SpectrumInput.svelte index 8416e06e01..653fba1c73 100644 --- a/frontend/src/components/widgets/inputs/SpectrumInput.svelte +++ b/frontend/src/components/widgets/inputs/SpectrumInput.svelte @@ -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} = 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