|
42 | 42 | // simplified pull-away state |
43 | 43 | removedStop: null, |
44 | 44 | removedIndex: null, |
| 45 | + // cache the visual offset for the drag session to avoid first-move jumps |
| 46 | + visualOffsetDeg: null, |
45 | 47 | } |
46 | 48 |
|
47 | 49 | function pickColor(stop, e) { |
|
72 | 74 | return percent / 100 |
73 | 75 | } |
74 | 76 |
|
| 77 | + // Map exact percent positions to named keywords |
| 78 | + function nearestNamedPosName(x, y) { |
| 79 | + const ex = x, ey = y |
| 80 | + const is = (a,b) => a === b |
| 81 | + if (is(ex,50) && is(ey,50)) return 'center' |
| 82 | + if (is(ey,0)) { |
| 83 | + if (is(ex,0)) return 'top left' |
| 84 | + if (is(ex,50)) return 'top' |
| 85 | + if (is(ex,100)) return 'top right' |
| 86 | + } |
| 87 | + if (is(ey,100)) { |
| 88 | + if (is(ex,0)) return 'bottom left' |
| 89 | + if (is(ex,50)) return 'bottom' |
| 90 | + if (is(ex,100)) return 'bottom right' |
| 91 | + } |
| 92 | + if (is(ex,0) && is(ey,50)) return 'left' |
| 93 | + if (is(ex,100) && is(ey,50)) return 'right' |
| 94 | + return null |
| 95 | + } |
| 96 | +
|
| 97 | + // When sliders set an exact named position, reflect it in the named store so UI updates |
| 98 | + $effect(() => { |
| 99 | + if (dragulaState.moving || dragulaState.rotating) return |
| 100 | + const x = Number($conic_position.x) |
| 101 | + const y = Number($conic_position.y) |
| 102 | + if (Number.isNaN(x) || Number.isNaN(y)) return |
| 103 | + const name = nearestNamedPosName(x, y) |
| 104 | + if (name && $conic_named_position !== name) { |
| 105 | + $conic_named_position = name |
| 106 | + } |
| 107 | + }) |
| 108 | +
|
75 | 109 | function determineAbsPosition() { |
76 | 110 | let x = $conic_position.x |
77 | 111 | let y = $conic_position.y |
|
132 | 166 | } |
133 | 167 | else if (isRotator) { |
134 | 168 | try { node.setPointerCapture(e.pointerId) } catch {} |
| 169 | + // Initialize rotation anchor from current pointer so the first delta starts from here |
| 170 | + const previewRect = isRotator.closest('.preview')?.getBoundingClientRect() |
| 171 | + if (previewRect) { |
| 172 | + dragulaState.centerX = previewRect.left + previewRect.width / 2 |
| 173 | + dragulaState.centerY = previewRect.top + previewRect.height / 2 |
| 174 | + const deltaX = e.clientX - dragulaState.centerX |
| 175 | + const deltaY = e.clientY - dragulaState.centerY |
| 176 | + let currentAngle = Math.atan2(deltaY, deltaX) * (180 / Math.PI) |
| 177 | + if (currentAngle < 0) currentAngle += 360 |
| 178 | + dragulaState.lastAngle = currentAngle |
| 179 | + } |
135 | 180 | rotateIt(isRotator) |
136 | 181 | } |
137 | 182 | else if (isStop) { |
138 | | - e.preventDefault() |
139 | | - e.stopPropagation() |
| 183 | + // If clicking the color swatch, let the click go through (no drag) |
| 184 | + if (e.target.closest('.stop-color')) return |
140 | 185 | const idx = Number(isStop.dataset.stopIndex) |
141 | 186 | $active_stop_index = idx |
142 | 187 | dragulaState.target = isStop |
143 | 188 | dragulaState.start.x = e.screenX |
144 | 189 | dragulaState.start.y = e.screenY |
145 | 190 | dragulaState.stopIndex = idx |
146 | | - try { isStop.setPointerCapture(e.pointerId) } catch {} |
147 | | - try { node.setPointerCapture(e.pointerId) } catch {} |
148 | | - dragIt(isStop) |
| 191 | + // Do not start dragging yet; wait for movement threshold in pointermove |
149 | 192 | } |
150 | 193 | } |
151 | 194 |
|
152 | 195 | let lastActiveIndex = null |
153 | 196 | const onPointerMove = (e) => { |
| 197 | + // Arm drag on small movement to preserve click/dblclick behavior |
| 198 | + if (!dragulaState.moving && dragulaState.stopIndex != null) { |
| 199 | + const dx = (e.screenX ?? 0) - (dragulaState.start.x ?? 0) |
| 200 | + const dy = (e.screenY ?? 0) - (dragulaState.start.y ?? 0) |
| 201 | + if (Math.hypot(dx, dy) > 3) { |
| 202 | + dragulaState.moving = true |
| 203 | + try { node.setPointerCapture(e.pointerId) } catch {} |
| 204 | + dragIt(dragulaState.target) |
| 205 | + } |
| 206 | + } |
154 | 207 | if (dragulaState.moving && dragulaState.stopIndex != null) { |
155 | 208 | // Capture pointer to avoid losing events during fast drags |
156 | 209 | try { node.setPointerCapture(e.pointerId) } catch {} |
|
176 | 229 | ringRadius = Math.hypot(sx - cx, sy - cy) |
177 | 230 | } |
178 | 231 | const radialDelta = Math.abs(dist - ringRadius) |
179 | | - const armThresh = 75 |
180 | | -
|
181 | | - // Pull-away removal disabled |
| 232 | + const removeArm = 28 |
| 233 | + const insertArm = 18 |
| 234 | +
|
| 235 | + // Drag-away to remove / return to reinsert |
| 236 | + if (radialDelta > removeArm && dragulaState.stopIndex != null && !dragulaState.removedStop) { |
| 237 | + const colorCount = ($gradient_stops || []).filter(s => s?.kind === 'stop').length |
| 238 | + if (colorCount > 1) { |
| 239 | + dragulaState.removedStop = { ...( $gradient_stops[dragulaState.stopIndex] ) } |
| 240 | + dragulaState.removedIndex = dragulaState.stopIndex |
| 241 | + $gradient_stops = updateStops(removeStop($gradient_stops, dragulaState.stopIndex)) |
| 242 | + dragulaState.stopIndex = null |
| 243 | + } |
| 244 | + } else if (radialDelta <= insertArm && dragulaState.removedStop && dragulaState.stopIndex == null) { |
| 245 | + const percent = Math.max(0, Math.min(100, Math.round(dragulaState.angle ?? 0))) |
| 246 | + const colors = $gradient_stops.filter(s => s.kind === 'stop') |
| 247 | + let k = colors.findIndex(s => parseFloat(s.position1) > percent) |
| 248 | + if (k === -1) k = colors.length |
| 249 | + const arrIdx = k * 2 |
| 250 | + const newStop = { |
| 251 | + kind: 'stop', |
| 252 | + color: dragulaState.removedStop.color, |
| 253 | + auto: percent, |
| 254 | + position1: percent, |
| 255 | + position2: percent, |
| 256 | + _manual: true, |
| 257 | + } |
| 258 | + if (k === colors.length) { |
| 259 | + $gradient_stops.splice(arrIdx, 0, {kind: 'hint', percentage: null}, newStop) |
| 260 | + } else { |
| 261 | + $gradient_stops.splice(arrIdx, 0, newStop, {kind: 'hint', percentage: null}) |
| 262 | + } |
| 263 | + $gradient_stops = updateStops($gradient_stops) |
| 264 | + dragulaState.stopIndex = arrIdx |
| 265 | + dragulaState.removedStop = null |
| 266 | + dragulaState.removedIndex = null |
| 267 | + } |
182 | 268 |
|
183 | 269 | // Compute the angle under the pointer in screen space [0,360) |
184 | 270 | let deg = Math.atan2(e.clientY - cy, e.clientX - cx) * (180 / Math.PI) |
185 | 271 | if (deg < 0) deg += 360 |
186 | 272 |
|
187 | 273 | // Align to the visual orientation of the overlay and stops |
188 | | - const dynamicOffset = computeVisualOffsetDeg(node) |
189 | 274 | const baseOffset = normalizeDeg($conic_angle - 180) |
190 | | - const offset = dynamicOffset ?? baseOffset |
| 275 | + const offset = (dragulaState.visualOffsetDeg ?? baseOffset) |
191 | 276 |
|
192 | 277 | const localDeg = normalizeDeg(deg - offset) |
193 | 278 | // Map degrees to percent [0,100] |
|
291 | 376 | dragulaState.start.y = null |
292 | 377 | dragulaState.removedStop = null |
293 | 378 | dragulaState.removedIndex = null |
| 379 | + dragulaState.visualOffsetDeg = null |
294 | 380 |
|
295 | 381 | $active_stop_index = null |
296 | 382 | } |
|
316 | 402 | if (dragulaState.stopIndex != null) { |
317 | 403 | const s = $gradient_stops?.[dragulaState.stopIndex] |
318 | 404 | if (!s) return |
| 405 | + // Cache visual offset once at drag start to keep hint/angle stable on first move |
| 406 | + try { |
| 407 | + const root = node.closest('.conic-overlay') |
| 408 | + dragulaState.visualOffsetDeg = computeVisualOffsetDeg(root) |
| 409 | + } catch {} |
319 | 410 | if (s.kind === 'hint') |
320 | 411 | dragulaState.angle = parseInt(s.percentage) |
321 | 412 | else if (s.kind === 'stop') |
|
344 | 435 | $active_stop_index = null |
345 | 436 | } |
346 | 437 |
|
| 438 | + function colorStopCount() { |
| 439 | + return ($gradient_stops || []).filter(s => s?.kind === 'stop').length |
| 440 | + } |
| 441 | +
|
347 | 442 | function deleteStop(stop) { |
348 | | - // Deletion disabled |
349 | | - return |
| 443 | + // Do not allow removing the last remaining color stop |
| 444 | + if (colorStopCount() <= 1) return |
| 445 | + $gradient_stops = updateStops(removeStop($gradient_stops, $gradient_stops.indexOf(stop))) |
350 | 446 | } |
351 | 447 |
|
352 | 448 | function handleKeypress(e, stop, prop) { |
|
369 | 465 | $gradient_stops = $gradient_stops |
370 | 466 | } |
371 | 467 | else if (['Backspace','Delete'].includes(e.key)) { |
372 | | - // Deletion disabled |
373 | | - return |
| 468 | + e.preventDefault() |
| 469 | + deleteStop(stop) |
374 | 470 | } |
375 | 471 | } |
376 | 472 |
|
|
0 commit comments