Skip to content

Commit 244b257

Browse files
authored
Add color picker gradient color stop duplication by holding Alt (#4291)
* Add color picker gradient color stop duplication by holding Alt * Fix bugs * Bug fix
1 parent a979ccd commit 244b257

5 files changed

Lines changed: 171 additions & 8 deletions

File tree

editor/src/messages/color_picker/color_picker_message_handler.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,31 @@ impl ColorPickerMessageHandler {
339339
self.snapshot_old();
340340
}
341341
}
342+
SpectrumInputUpdate::InsertDuplicate { index, position } => {
343+
let source = index as usize;
344+
let Some(insert_index) = gradient.duplicate_stop(source, position) else { return };
345+
// The dragged stop (the duplication source) stays active. Its index shifts up if the frozen copy landed at or before it.
346+
let dragged_index = if insert_index <= source { source + 1 } else { source };
347+
self.active_marker_index = Some(dragged_index as u32);
348+
self.active_marker_is_midpoint = false;
349+
}
350+
SpectrumInputUpdate::RemoveDuplicate { index } => {
351+
let anchor = index as usize;
352+
if anchor >= gradient.position.len() || gradient.position.len() <= 2 {
353+
return;
354+
}
355+
// Never remove the active (dragged) stop itself, this should only ever target the frozen copy.
356+
if self.active_marker_index == Some(anchor as u32) {
357+
return;
358+
}
359+
gradient.remove(anchor);
360+
// Keep the dragged stop active. Its index shifts down if the removed copy came before it.
361+
if let Some(active) = self.active_marker_index
362+
&& (anchor as u32) < active
363+
{
364+
self.active_marker_index = Some(active - 1);
365+
}
366+
}
342367
SpectrumInputUpdate::DeleteMarker { index } => {
343368
// Enforce minimum stop count. The gradient editor needs at least 2 stops to remain meaningful.
344369
if gradient.position.len() <= 2 || (index as usize) >= gradient.position.len() {

editor/src/messages/layout/utility_types/widgets/input_widgets.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,16 @@ pub enum SpectrumInputUpdate {
612612
DeleteMarker {
613613
index: u32,
614614
},
615+
/// Insert a copy (same color and midpoint) of the marker at `index` at `position`, keeping the marker at `index` active.
616+
InsertDuplicate {
617+
index: u32,
618+
position: f64,
619+
},
620+
/// Remove the marker at `index` while keeping the currently active marker active (renumbered to account for the removal).
621+
/// Used to un-duplicate when Alt is released mid-drag, deleting the frozen copy left by [`InsertDuplicate`](Self::InsertDuplicate).
622+
RemoveDuplicate {
623+
index: u32,
624+
},
615625
/// Emitted when the user double-clicks a marker. The consumer decides what (if anything) to reset the marker to.
616626
ResetMarker {
617627
index: u32,

editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1785,6 +1785,7 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphMessageContext<'a>> for NodeG
17851785
responses.add(OverlaysMessage::Draw);
17861786
}
17871787
if is_stroke_node || is_fill_node || is_shape_generator_node || is_text_node {
1788+
responses.add(SelectToolMessage::SelectionChanged);
17881789
responses.add(PenToolMessage::SelectionChanged);
17891790
responses.add(FreehandToolMessage::SelectionChanged);
17901791
responses.add(SplineToolMessage::SelectionChanged);

frontend/src/components/widgets/inputs/SpectrumInput.svelte

Lines changed: 123 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@
3636
let activeMarkerIsMidpointRestore = false;
3737
// Tracks whether a midpoint drag has actually moved by at least one frame, to distinguish click-to-select from drag.
3838
let midpointDragged = false;
39+
// Mirrors whether Alt is currently held during the drag (the desired state).
40+
let duplicateRequested = false;
41+
// Mirrors whether a frozen copy currently exists in the gradient (the materialized state).
42+
let duplicateActive = false;
43+
// Set when a key-triggered reconcile inserts/removes the frozen copy, so the next pointer move skips emitting a `MoveMarker`
44+
// that would otherwise race the structural change before Rust has reported the dragged marker's new index.
45+
let skipNextMove = false;
3946
4047
function emit(intent: SpectrumInputUpdate) {
4148
dispatch("update", intent);
@@ -68,6 +75,9 @@
6875
activeMarkerIsMidpointRestore = activeMarkerIsMidpoint;
6976
dragRestorePosition = markers[index].position;
7077
dragInsertedMarker = false;
78+
// Only offer duplication where new stops are allowed. Don't materialize yet: wait for the first move so an Alt-click without a drag leaves no stray copy.
79+
duplicateRequested = allowInsert && e.altKey;
80+
duplicateActive = false;
7181
setActive(index, false);
7282
addEvents();
7383
return;
@@ -118,7 +128,10 @@
118128
activeMarkerIsMidpointRestore = activeMarkerIsMidpoint;
119129
dragRestorePosition = position;
120130
dragInsertedMarker = true;
121-
// 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.
131+
// A stop being created by this drag can't be duplicated; duplication is only for dragging an existing stop.
132+
duplicateRequested = false;
133+
duplicateActive = false;
134+
// 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.
122135
activeMarkerIndex = insertIndex;
123136
activeMarkerIsMidpoint = false;
124137
addEvents();
@@ -134,10 +147,78 @@
134147
else if (allowDelete) emit({ DeleteMarker: { index: activeMarkerIndex } });
135148
}
136149
150+
// Locate the frozen copy left by a duplicate: the non-dragged marker sitting (within epsilon) at the drag's start position.
151+
// Returns undefined if no marker is close enough, so a stale `markers` prop can never delete an unrelated stop.
152+
function findDuplicateAnchorIndex(): number | undefined {
153+
// The frozen copy is inserted at exactly the drag's start position and round-trips through Rust losslessly, so it matches within this tolerance
154+
const DUPLICATE_POSITION_EPSILON = 1e-6;
155+
156+
if (dragRestorePosition === undefined) return undefined;
157+
158+
const startPosition = dragRestorePosition;
159+
160+
let best: number | undefined = undefined;
161+
let bestDistance = DUPLICATE_POSITION_EPSILON;
162+
163+
markers.forEach((marker, index) => {
164+
if (index === activeMarkerIndex) return;
165+
166+
const distance = Math.abs(marker.position - startPosition);
167+
if (distance < bestDistance) {
168+
bestDistance = distance;
169+
best = index;
170+
}
171+
});
172+
173+
return best;
174+
}
175+
176+
// Bring the materialized duplicate state in line with whether Alt is currently held, inserting or removing the frozen copy.
177+
// Returns whether a structural change was emitted, so callers can skip the next move that would race it.
178+
function reconcileDuplicate(): boolean {
179+
if (!allowInsert || activeMarkerIndex === undefined || activeMarkerIsMidpoint) return false;
180+
181+
if (duplicateRequested && !duplicateActive) {
182+
// Drop a frozen copy at the drag's start position. The dragged marker stays active and becomes the duplicate being moved.
183+
184+
if (dragRestorePosition === undefined) return false;
185+
186+
emit({ InsertDuplicate: { index: activeMarkerIndex, position: dragRestorePosition } });
187+
duplicateActive = true;
188+
189+
return true;
190+
} else if (!duplicateRequested && duplicateActive) {
191+
// Remove the frozen copy so only the dragged marker remains, as if it had been dragged all along.
192+
193+
const anchor = findDuplicateAnchorIndex();
194+
if (anchor === undefined) return false;
195+
196+
emit({ RemoveDuplicate: { index: anchor } });
197+
duplicateActive = false;
198+
199+
return true;
200+
}
201+
202+
return false;
203+
}
204+
137205
function moveActiveMarker(e: PointerEvent) {
138206
if (disabled || activeMarkerIndex === undefined) return;
139207
if (e.buttons === 0) {
140-
stopDrag();
208+
endDrag();
209+
return;
210+
}
211+
212+
// Materialize/remove the frozen copy if Alt's state changed without a key event reconciling it first (e.g. Alt held at drag start).
213+
// Skip this frame's move. The next move runs once Rust has reported the dragged marker's new index.
214+
if (duplicateRequested !== duplicateActive) {
215+
reconcileDuplicate();
216+
return;
217+
}
218+
219+
// A key event (Alt press/release) already reconciled. Skip the one move that would race that structural change.
220+
if (skipNextMove) {
221+
skipNextMove = false;
141222
return;
142223
}
143224
@@ -152,7 +233,7 @@
152233
function moveActiveMidpoint(e: PointerEvent) {
153234
if (disabled || activeMarkerIndex === undefined) return;
154235
if (e.buttons === 0) {
155-
stopDrag();
236+
endDrag();
156237
return;
157238
}
158239
@@ -173,25 +254,40 @@
173254
function abortDrag() {
174255
if (disabled || activeMarkerIndex === undefined) return;
175256
176-
// Restore the dragged value, or delete the marker if it was inserted as part of this drag.
257+
const dragged = activeMarkerIndex;
258+
const anchor = duplicateActive ? findDuplicateAnchorIndex() : undefined;
259+
177260
if (dragInsertedMarker) {
178-
emit({ DeleteMarker: { index: activeMarkerIndex } });
261+
// The dragged marker was created by this drag, so delete it.
262+
emit({ DeleteMarker: { index: dragged } });
263+
} else if (anchor !== undefined) {
264+
// A duplicated pre-existing marker: the frozen copy already sits at the start position, so deleting the dragged copy restores the original.
265+
emit({ DeleteMarker: { index: dragged } });
179266
} else if (dragRestorePosition !== undefined) {
180-
if (activeMarkerIsMidpoint) emit({ MoveMidpoint: { index: activeMarkerIndex, position: dragRestorePosition } });
181-
else emit({ MoveMarker: { index: activeMarkerIndex, position: dragRestorePosition } });
267+
// Plain drag: return the marker (or midpoint) to where it began.
268+
if (activeMarkerIsMidpoint) emit({ MoveMidpoint: { index: dragged, position: dragRestorePosition } });
269+
else emit({ MoveMarker: { index: dragged, position: dragRestorePosition } });
182270
}
183271
184272
setActive(activeMarkerIndexRestore, activeMarkerIsMidpointRestore);
185273
stopDrag();
186274
}
187275
276+
function endDrag() {
277+
if (!duplicateRequested && duplicateActive) reconcileDuplicate();
278+
stopDrag();
279+
}
280+
188281
function stopDrag() {
189282
removeEvents();
190283
dragRestorePosition = undefined;
191284
dragInsertedMarker = false;
192285
activeMarkerIndexRestore = undefined;
193286
activeMarkerIsMidpointRestore = false;
194287
midpointDragged = false;
288+
duplicateRequested = false;
289+
duplicateActive = false;
290+
skipNextMove = false;
195291
dispatch("dragging", false);
196292
}
197293
@@ -201,7 +297,7 @@
201297
}
202298
203299
function onPointerUp() {
204-
stopDrag();
300+
endDrag();
205301
}
206302
207303
function onMouseDown(e: MouseEvent) {
@@ -214,6 +310,23 @@
214310
const element = markerTrackElement?.div();
215311
if (element) preventEscapeClosingParentFloatingMenu(element);
216312
abortDrag();
313+
return;
314+
}
315+
316+
// Pressing Alt mid-drag duplicates the marker, leaving a frozen copy where the drag began. Reconcile immediately for instant
317+
// feedback, and arm a skip so the next pointer move doesn't race the just-emitted structural change. Only when dragging an
318+
// existing stop (not one being created by this drag).
319+
if (e.key === "Alt" && allowInsert && !activeMarkerIsMidpoint && !dragInsertedMarker && !duplicateRequested) {
320+
duplicateRequested = true;
321+
if (reconcileDuplicate()) skipNextMove = true;
322+
}
323+
}
324+
325+
function onKeyUp(e: KeyboardEvent) {
326+
// Releasing Alt mid-drag removes the frozen copy immediately, as if the marker had been dragged all along (likewise armed to skip the next move).
327+
if (e.key === "Alt" && duplicateRequested) {
328+
duplicateRequested = false;
329+
if (reconcileDuplicate()) skipNextMove = true;
217330
}
218331
}
219332
@@ -222,13 +335,15 @@
222335
document.addEventListener("pointerup", onPointerUp);
223336
document.addEventListener("mousedown", onMouseDown);
224337
document.addEventListener("keydown", onKeyDown);
338+
document.addEventListener("keyup", onKeyUp);
225339
}
226340
227341
function removeEvents() {
228342
document.removeEventListener("pointermove", onPointerMove);
229343
document.removeEventListener("pointerup", onPointerUp);
230344
document.removeEventListener("mousedown", onMouseDown);
231345
document.removeEventListener("keydown", onKeyDown);
346+
document.removeEventListener("keyup", onKeyUp);
232347
}
233348
234349
// Map midpoint pairs to absolute track positions for rendering the diamond markers.

node-graph/libraries/vector-types/src/gradient.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,18 @@ impl GradientStops {
274274
index
275275
}
276276

277+
/// Insert a copy of the stop at `source_index` (same color and midpoint) at `position`, keeping the stops sorted by position.
278+
/// Returns the index where the copy was inserted, or `None` if `source_index` is out of range.
279+
pub fn duplicate_stop(&mut self, source_index: usize, position: f64) -> Option<usize> {
280+
let color = *self.color.get(source_index)?;
281+
let midpoint = *self.midpoint.get(source_index)?;
282+
let index = self.position.iter().position(|p| *p > position).unwrap_or(self.position.len());
283+
self.position.insert(index, position);
284+
self.midpoint.insert(index, midpoint);
285+
self.color.insert(index, color);
286+
Some(index)
287+
}
288+
277289
/// Reset the midpoint for the interval starting at `index` to its default `0.5`.
278290
pub fn reset_midpoint(&mut self, index: usize) {
279291
if let Some(midpoint) = self.midpoint.get_mut(index) {

0 commit comments

Comments
 (0)