|
36 | 36 | let activeMarkerIsMidpointRestore = false; |
37 | 37 | // Tracks whether a midpoint drag has actually moved by at least one frame, to distinguish click-to-select from drag. |
38 | 38 | 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; |
39 | 46 |
|
40 | 47 | function emit(intent: SpectrumInputUpdate) { |
41 | 48 | dispatch("update", intent); |
|
68 | 75 | activeMarkerIsMidpointRestore = activeMarkerIsMidpoint; |
69 | 76 | dragRestorePosition = markers[index].position; |
70 | 77 | 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; |
71 | 81 | setActive(index, false); |
72 | 82 | addEvents(); |
73 | 83 | return; |
|
118 | 128 | activeMarkerIsMidpointRestore = activeMarkerIsMidpoint; |
119 | 129 | dragRestorePosition = position; |
120 | 130 | 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. |
122 | 135 | activeMarkerIndex = insertIndex; |
123 | 136 | activeMarkerIsMidpoint = false; |
124 | 137 | addEvents(); |
|
134 | 147 | else if (allowDelete) emit({ DeleteMarker: { index: activeMarkerIndex } }); |
135 | 148 | } |
136 | 149 |
|
| 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 | +
|
137 | 205 | function moveActiveMarker(e: PointerEvent) { |
138 | 206 | if (disabled || activeMarkerIndex === undefined) return; |
139 | 207 | 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; |
141 | 222 | return; |
142 | 223 | } |
143 | 224 |
|
|
152 | 233 | function moveActiveMidpoint(e: PointerEvent) { |
153 | 234 | if (disabled || activeMarkerIndex === undefined) return; |
154 | 235 | if (e.buttons === 0) { |
155 | | - stopDrag(); |
| 236 | + endDrag(); |
156 | 237 | return; |
157 | 238 | } |
158 | 239 |
|
|
173 | 254 | function abortDrag() { |
174 | 255 | if (disabled || activeMarkerIndex === undefined) return; |
175 | 256 |
|
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 | +
|
177 | 260 | 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 } }); |
179 | 266 | } 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 } }); |
182 | 270 | } |
183 | 271 |
|
184 | 272 | setActive(activeMarkerIndexRestore, activeMarkerIsMidpointRestore); |
185 | 273 | stopDrag(); |
186 | 274 | } |
187 | 275 |
|
| 276 | + function endDrag() { |
| 277 | + if (!duplicateRequested && duplicateActive) reconcileDuplicate(); |
| 278 | + stopDrag(); |
| 279 | + } |
| 280 | +
|
188 | 281 | function stopDrag() { |
189 | 282 | removeEvents(); |
190 | 283 | dragRestorePosition = undefined; |
191 | 284 | dragInsertedMarker = false; |
192 | 285 | activeMarkerIndexRestore = undefined; |
193 | 286 | activeMarkerIsMidpointRestore = false; |
194 | 287 | midpointDragged = false; |
| 288 | + duplicateRequested = false; |
| 289 | + duplicateActive = false; |
| 290 | + skipNextMove = false; |
195 | 291 | dispatch("dragging", false); |
196 | 292 | } |
197 | 293 |
|
|
201 | 297 | } |
202 | 298 |
|
203 | 299 | function onPointerUp() { |
204 | | - stopDrag(); |
| 300 | + endDrag(); |
205 | 301 | } |
206 | 302 |
|
207 | 303 | function onMouseDown(e: MouseEvent) { |
|
214 | 310 | const element = markerTrackElement?.div(); |
215 | 311 | if (element) preventEscapeClosingParentFloatingMenu(element); |
216 | 312 | 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; |
217 | 330 | } |
218 | 331 | } |
219 | 332 |
|
|
222 | 335 | document.addEventListener("pointerup", onPointerUp); |
223 | 336 | document.addEventListener("mousedown", onMouseDown); |
224 | 337 | document.addEventListener("keydown", onKeyDown); |
| 338 | + document.addEventListener("keyup", onKeyUp); |
225 | 339 | } |
226 | 340 |
|
227 | 341 | function removeEvents() { |
228 | 342 | document.removeEventListener("pointermove", onPointerMove); |
229 | 343 | document.removeEventListener("pointerup", onPointerUp); |
230 | 344 | document.removeEventListener("mousedown", onMouseDown); |
231 | 345 | document.removeEventListener("keydown", onKeyDown); |
| 346 | + document.removeEventListener("keyup", onKeyUp); |
232 | 347 | } |
233 | 348 |
|
234 | 349 | // Map midpoint pairs to absolute track positions for rendering the diamond markers. |
|
0 commit comments