Skip to content

Commit a0abcbd

Browse files
committed
interaction fixes, remove stop fixes
1 parent e33f6aa commit a0abcbd

4 files changed

Lines changed: 327 additions & 41 deletions

File tree

src/components/ConicOverlay.svelte

Lines changed: 110 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
// simplified pull-away state
4343
removedStop: null,
4444
removedIndex: null,
45+
// cache the visual offset for the drag session to avoid first-move jumps
46+
visualOffsetDeg: null,
4547
}
4648
4749
function pickColor(stop, e) {
@@ -72,6 +74,38 @@
7274
return percent / 100
7375
}
7476
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+
75109
function determineAbsPosition() {
76110
let x = $conic_position.x
77111
let y = $conic_position.y
@@ -132,25 +166,44 @@
132166
}
133167
else if (isRotator) {
134168
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+
}
135180
rotateIt(isRotator)
136181
}
137182
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
140185
const idx = Number(isStop.dataset.stopIndex)
141186
$active_stop_index = idx
142187
dragulaState.target = isStop
143188
dragulaState.start.x = e.screenX
144189
dragulaState.start.y = e.screenY
145190
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
149192
}
150193
}
151194
152195
let lastActiveIndex = null
153196
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+
}
154207
if (dragulaState.moving && dragulaState.stopIndex != null) {
155208
// Capture pointer to avoid losing events during fast drags
156209
try { node.setPointerCapture(e.pointerId) } catch {}
@@ -176,18 +229,50 @@
176229
ringRadius = Math.hypot(sx - cx, sy - cy)
177230
}
178231
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+
}
182268
183269
// Compute the angle under the pointer in screen space [0,360)
184270
let deg = Math.atan2(e.clientY - cy, e.clientX - cx) * (180 / Math.PI)
185271
if (deg < 0) deg += 360
186272
187273
// Align to the visual orientation of the overlay and stops
188-
const dynamicOffset = computeVisualOffsetDeg(node)
189274
const baseOffset = normalizeDeg($conic_angle - 180)
190-
const offset = dynamicOffset ?? baseOffset
275+
const offset = (dragulaState.visualOffsetDeg ?? baseOffset)
191276
192277
const localDeg = normalizeDeg(deg - offset)
193278
// Map degrees to percent [0,100]
@@ -291,6 +376,7 @@
291376
dragulaState.start.y = null
292377
dragulaState.removedStop = null
293378
dragulaState.removedIndex = null
379+
dragulaState.visualOffsetDeg = null
294380
295381
$active_stop_index = null
296382
}
@@ -316,6 +402,11 @@
316402
if (dragulaState.stopIndex != null) {
317403
const s = $gradient_stops?.[dragulaState.stopIndex]
318404
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 {}
319410
if (s.kind === 'hint')
320411
dragulaState.angle = parseInt(s.percentage)
321412
else if (s.kind === 'stop')
@@ -344,9 +435,14 @@
344435
$active_stop_index = null
345436
}
346437
438+
function colorStopCount() {
439+
return ($gradient_stops || []).filter(s => s?.kind === 'stop').length
440+
}
441+
347442
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)))
350446
}
351447
352448
function handleKeypress(e, stop, prop) {
@@ -369,8 +465,8 @@
369465
$gradient_stops = $gradient_stops
370466
}
371467
else if (['Backspace','Delete'].includes(e.key)) {
372-
// Deletion disabled
373-
return
468+
e.preventDefault()
469+
deleteStop(stop)
374470
}
375471
}
376472

src/components/GradientStops.svelte

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@
3434
function colorAction(event, position) {
3535
switch (event.target.value) {
3636
case 'Remove':
37-
// Deletion disabled
37+
if (colorStopCount() <= 1) break
38+
$gradient_stops = updateStops(removeStop($gradient_stops, position))
3839
break
3940
case 'Reset':
4041
$gradient_stops[position].position1 = null
@@ -56,6 +57,10 @@
5657
event.target.selectedIndex = 0
5758
}
5859
60+
function colorStopCount() {
61+
return ($gradient_stops || []).filter(s => s?.kind === 'stop').length
62+
}
63+
5964
function dupeStop(pos) {
6065
const clone = {
6166
id: genId('stop'),
@@ -296,14 +301,14 @@
296301
</div>
297302
<button class="stop-actions" use:tooltip={{content: "Actions", offset: 15}}>
298303
<select tabindex="-1" onchange={(e) => colorAction(e,i)}>
299-
<option disabled selected>Color Actions</option>
304+
<option disabled selected>Color Stop Actions</option>
300305
<hr>
301306
<option>Duplicate</option>
302307
<option>Copy CSS color</option>
303308
<option>Random color</option>
304309
<hr>
305310
<option>Reset</option>
306-
<option disabled={true}>Remove</option>
311+
<option disabled={colorStopCount() <= 1}>Remove</option>
307312
</select>
308313
</button>
309314
<div class="drag-handle" use:tooltip={{content: 'Drag to reorder'}} draggable="true" ondragstart={(e) => beginDrag(e, i)} aria-label="Drag to reorder"></div>

0 commit comments

Comments
 (0)