-
-
Notifications
You must be signed in to change notification settings - Fork 578
/
Copy pathNumberInput.svelte
924 lines (773 loc) · 37.7 KB
/
NumberInput.svelte
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
<script lang="ts">
import { createEventDispatcher, onMount, onDestroy } from "svelte";
import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS } from "@graphite/io-managers/input";
import { type NumberInputMode, type NumberInputIncrementBehavior } from "@graphite/messages";
import { evaluateMathExpression } from "@graphite-frontend/wasm/pkg/graphite_wasm.js";
import { preventEscapeClosingParentFloatingMenu } from "@graphite/components/layout/FloatingMenu.svelte";
import FieldInput from "@graphite/components/widgets/inputs/FieldInput.svelte";
const BUTTONS_LEFT = 0b0000_0001;
const BUTTONS_RIGHT = 0b0000_0010;
const BUTTON_LEFT = 0;
const BUTTON_RIGHT = 2;
const dispatch = createEventDispatcher<{ value: number | undefined; startHistoryTransaction: undefined }>();
// Label
export let label: string | undefined = undefined;
export let tooltip: string | undefined = undefined;
// Disabled
export let disabled = false;
// Value
// When `value` is not provided (i.e. it's `undefined`), a dash is displayed.
export let value: number | undefined = undefined; // NOTE: Do not update this directly, do so by calling `updateValue()` instead.
export let min: number | undefined = undefined;
export let max: number | undefined = undefined;
export let isInteger = false;
// Number presentation
export let displayDecimalPlaces = 2;
export let unit = "";
export let unitIsHiddenWhenEditing = true;
// Mode behavior
// "Increment" shows arrows and allows dragging left/right to change the value.
// "Range" shows a range slider between some minimum and maximum value.
export let mode: NumberInputMode = "Increment";
// When `mode` is "Increment", `step` is the multiplier or addend used with `incrementBehavior`.
// When `mode` is "Range", `step` is the range slider's snapping increment if `isInteger` is `true`.
export let step = 1;
// `incrementBehavior` is only applicable with a `mode` of "Increment".
// "Add"/"Multiply": The value is added or multiplied by `step`.
// "None": the increment arrows are not shown.
// "Callback": the functions `incrementCallbackIncrease` and `incrementCallbackDecrease` call custom behavior.
export let incrementBehavior: NumberInputIncrementBehavior = "Add";
// `rangeMin` and `rangeMax` are only applicable with a `mode` of "Range".
// They set the lower and upper values of the slider to drag between.
export let rangeMin = 0;
export let rangeMax = 1;
// Styling
export let minWidth = 0;
// Callbacks
export let incrementCallbackIncrease: (() => void) | undefined = undefined;
export let incrementCallbackDecrease: (() => void) | undefined = undefined;
let self: FieldInput | undefined;
let inputRangeElement: HTMLInputElement | undefined;
let text = displayText(value, unit);
let editing = false;
let isDragging = false;
let pressingArrow = false;
let repeatTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
// Stays in sync with a binding to the actual input range slider element.
let rangeSliderValue = value !== undefined ? value : 0;
// Value used to render the position of the fake slider when applicable, and length of the progress colored region to the slider's left.
// This is the same as `rangeSliderValue` except in the "Deciding" state, when it has the previous location before the user's mousedown.
let rangeSliderValueAsRendered = value !== undefined ? value : 0;
// Keeps track of the state of the slider drag as the user transitions through steps of the input process.
// - "Ready": no interaction is happening.
// - "Deciding": the user has pressed down the mouse and might next decide to either drag left/right or release without dragging.
// - "Dragging": the user is dragging the slider left/right.
// - "Aborted": the user has right clicked or pressed Escape to abort the drag, but hasn't yet released all mouse buttons.
let rangeSliderClickDragState: "Ready" | "Deciding" | "Dragging" | "Aborted" = "Ready";
// Stores the initial value upon beginning to drag so it can be restored upon aborting. Set to `undefined` when not dragging.
let initialValueBeforeDragging: number | undefined = undefined;
// Stores the total value change during the process of dragging the slider. Set to 0 when not dragging.
let cumulativeDragDelta = 0;
// Track whether the Ctrl key is currently held down.
let ctrlKeyDown = false;
$: watchValue(value, unit);
$: sliderStepValue = isInteger ? (step === undefined ? 1 : step) : "any";
$: styles = {
...(minWidth > 0 ? { "min-width": `${minWidth}px` } : {}),
...(mode === "Range" ? { "--progress-factor": Math.min(Math.max((rangeSliderValueAsRendered - rangeMin) / (rangeMax - rangeMin), 0), 1) } : {}),
};
// Keep track of the Ctrl key being held down.
const trackCtrl = (e: KeyboardEvent | MouseEvent) => (ctrlKeyDown = e.ctrlKey);
onMount(() => {
addEventListener("keydown", trackCtrl);
addEventListener("keyup", trackCtrl);
addEventListener("mousemove", trackCtrl);
});
onDestroy(() => {
removeEventListener("keydown", trackCtrl);
removeEventListener("keyup", trackCtrl);
removeEventListener("mousemove", trackCtrl);
});
// ===============================
// TRACKING AND UPDATING THE VALUE
// ===============================
// Called only when `value` is changed from outside this component.
function watchValue(value: number | undefined, unit: string) {
// Don't update if the slider is currently being dragged (we don't want the backend fighting with the user's drag)
if (rangeSliderClickDragState === "Dragging") return;
// Draw a dash if the value is undefined
if (value === undefined) {
text = "-";
return;
}
// Update the range slider with the new value
rangeSliderValue = value;
rangeSliderValueAsRendered = value;
// The simple `clamp()` function can't be used here since `undefined` values need to be boundless
let sanitized = value;
if (typeof min === "number") sanitized = Math.max(sanitized, min);
if (typeof max === "number") sanitized = Math.min(sanitized, max);
text = displayText(sanitized, unit);
}
// Called internally to update the value indirectly by informing the parent component of the new value,
// so it can update the prop for this component, finally yielding the value change.
function updateValue(newValue: number | undefined): number | undefined {
// Check if the new value is valid, otherwise we use the old value (rounded if it's an integer)
const oldValue = value !== undefined && isInteger ? Math.round(value) : value;
let newValueValidated = newValue !== undefined ? newValue : oldValue;
if (newValueValidated !== undefined) {
if (typeof min === "number" && !Number.isNaN(min)) newValueValidated = Math.max(newValueValidated, min);
if (typeof max === "number" && !Number.isNaN(max)) newValueValidated = Math.min(newValueValidated, max);
if (isInteger) newValueValidated = Math.round(newValueValidated);
rangeSliderValue = newValueValidated;
rangeSliderValueAsRendered = newValueValidated;
}
text = displayText(newValueValidated, unit);
if (newValue !== undefined) dispatch("value", newValueValidated);
// For any caller that needs to know what the value was changed to, we return it here
return newValueValidated;
}
// ================
// HELPER FUNCTIONS
// ================
// Calculates the string to display when the field is not being edited.
function displayText(displayValue: number | undefined, unit: string): string {
if (displayValue === undefined) return "-";
const roundingPower = 10 ** Math.max(displayDecimalPlaces, 0);
const unitlessDisplayValue = Math.round(displayValue * roundingPower) / roundingPower;
return `${unitlessDisplayValue}${unPluralize(unit, displayValue)}`;
}
// Removes the trailing "s" from a unit if the quantity is 1.
function unPluralize(unit: string, quantity: number): string {
if (quantity !== 1 || !unit.endsWith("s")) return unit;
return unit.slice(0, -1);
}
// ===========================
// ALL MODES: TEXT VALUE ENTRY
// ===========================
function onTextFocused() {
// The degree of precision allowed in the number that's shown when editing the number field, where additional precision is removed to round out floating point errors.
const MAX_PRECISION = 12;
const noFloatingImprecisionValue = value === undefined ? undefined : Number(value.toPrecision(MAX_PRECISION));
if (value === undefined) text = "";
else if (unitIsHiddenWhenEditing) text = `${noFloatingImprecisionValue}`;
else text = `${noFloatingImprecisionValue}${unPluralize(unit, value)}`;
editing = true;
self?.selectAllText(text);
// Workaround for weird behavior in Firefox: <https://github.com/GraphiteEditor/Graphite/issues/2215>
if (isDragging) self?.unFocus();
}
// Called only when `value` is changed from the <input> element via user input and committed, either with the
// enter key (via the `change` event) or when the <input> element is unfocused (with the `blur` event binding).
function onTextChanged() {
// The `unFocus()` call at the bottom of this function and in `onTextChangeCanceled()` causes this function to be run again, so this check skips a second run.
if (!editing) return;
// Insert a leading zero before all decimal points lacking a preceding digit, since the library doesn't realize that "point" means "zero point".
const textWithLeadingZeroes = text.replaceAll(/(?<=^|[^0-9])\./g, "0."); // Match any "." that is preceded by the start of the string (^) or a non-digit character ([^0-9])
let newValue = evaluateMathExpression(textWithLeadingZeroes);
if (newValue !== undefined && isNaN(newValue)) newValue = undefined; // Rejects `sqrt(-1)`
if (newValue !== undefined) {
const oldValue = value !== undefined && isInteger ? Math.round(value) : value;
if (newValue !== oldValue) dispatch("startHistoryTransaction");
}
updateValue(newValue);
editing = false;
self?.unFocus();
}
function onTextChangeCanceled() {
updateValue(undefined);
const valueOrZero = value !== undefined ? value : 0;
rangeSliderValue = valueOrZero;
rangeSliderValueAsRendered = valueOrZero;
editing = false;
self?.unFocus();
}
// =============================
// INCREMENT MODE: ARROW BUTTONS
// =============================
function onIncrementPointerDown(e: PointerEvent, direction: "Decrease" | "Increase") {
if (value === undefined || e.button !== BUTTON_LEFT) return;
const actions: Record<NumberInputIncrementBehavior, () => void> = {
Add: () => {
const directionAddend = direction === "Increase" ? step : -step;
const newValue = value !== undefined ? value + directionAddend : undefined;
updateValue(newValue);
},
Multiply: () => {
const directionMultiplier = direction === "Increase" ? step : 1 / step;
const newValue = value !== undefined ? value * directionMultiplier : undefined;
updateValue(newValue);
},
Callback: () => {
if (direction === "Increase") incrementCallbackIncrease?.();
if (direction === "Decrease") incrementCallbackDecrease?.();
},
None: () => {},
};
const sendAction = () => {
if (!pressingArrow) return;
actions[incrementBehavior]();
if (afterInitialDelay) repeatTimeout = setTimeout(sendAction, PRESS_REPEAT_INTERVAL_MS);
afterInitialDelay = true;
};
pressingArrow = true;
initialValueBeforeDragging = value;
let afterInitialDelay = false;
sendAction();
repeatTimeout = setTimeout(sendAction, PRESS_REPEAT_DELAY_MS);
addEventListener("keydown", incrementPressAbort);
}
function onIncrementPointerUp() {
pressingArrow = false;
clearTimeout(repeatTimeout);
}
function incrementPressAbort(e: KeyboardEvent | MouseEvent) {
// Only abort if the user right clicks or presses Escape
if (e instanceof KeyboardEvent && e.key !== "Escape") return;
if (e instanceof MouseEvent && e.button !== BUTTON_RIGHT) return;
const element = self?.element() || undefined;
if (element) preventEscapeClosingParentFloatingMenu(element);
pressingArrow = false;
clearTimeout(repeatTimeout);
updateValue(initialValueBeforeDragging);
removeEventListener("keydown", onIncrementPointerUp);
}
// =======================================
// INCREMENT MODE: DRAGGING LEFT AND RIGHT
// =======================================
// TODO: Prevent right clicking the input field from focusing it (i.e. entering its text editing state).
// TODO: `preventDefault()` doesn't work. Relevant StackOverflow question without any working answers:
// TODO: <https://stackoverflow.com/questions/60746390/react-prevent-right-click-from-focusing-an-otherwise-focusable-element>
// TODO: Another potential solution is to somehow track if the user right clicked, then use the "focus" event handler to immediately return
// TODO: focus to the previously focused element with `e.relatedTarget.focus();`. But a "FocusEvent" doesn't include mouse button click info.
// TODO: Alternatively, we could stick an element in front of the input field that blocks clicks on the underlying input field. Then it could
// TODO: call `.focus()` on the input field when left clicked and then hide itself so it doesn't block the input field while being edited.
function onDragPointerDown(e: PointerEvent) {
// Only drag the number with left click (and when it's valid to do so)
if (e.button !== BUTTON_LEFT || mode !== "Increment" || value === undefined || disabled || editing) return;
// Remove the text entry cursor from any other selected text field
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
// Don't drag the text value from is input element
e.preventDefault();
// Now we need to wait and see if the user follows this up with a mousemove or mouseup.
// For some reason, both events can get fired before their event listeners are removed, so we need to guard against both running.
let alreadyActedGuard = false;
// If it's a mousemove, we'll enter the dragging state and begin dragging.
const onMove = () => {
if (alreadyActedGuard) return;
alreadyActedGuard = true;
isDragging = true;
beginDrag(e);
removeEventListener("pointermove", onMove);
};
// If it's a mouseup, we'll begin editing the text field.
const onUp = () => {
if (alreadyActedGuard) return;
alreadyActedGuard = true;
isDragging = false;
self?.focus();
removeEventListener("pointerup", onUp);
};
addEventListener("pointermove", onMove);
addEventListener("pointerup", onUp);
}
function beginDrag(e: PointerEvent) {
// Get the click target
const target = e.target || undefined;
if (!(target instanceof HTMLElement)) return;
// Enter dragging state
target.requestPointerLock();
initialValueBeforeDragging = value;
cumulativeDragDelta = 0;
// Tell the backend that we are beginning a transaction for the history system
startDragging();
// We ignore the first event invocation's `e.movementX` value because it's unreliable.
// In both Chrome and Firefox (tested on Windows 10), the first `e.movementX` value is occasionally a very large number
// (around positive 1000, even if movement was in the negative direction). This seems to happen more often if the movement is rapid.
let ignoredFirstMovement = false;
const pointerUp = () => {
// Confirm on release by setting the reset value to the current value, so once the pointer lock ends,
// the value is set to itself instead of the initial (abort) value in the "pointerlockchange" event handler function.
initialValueBeforeDragging = value;
cumulativeDragDelta = 0;
document.exitPointerLock();
};
const pointerMove = (e: PointerEvent) => {
// Abort the drag if right click is down. This works here because a "pointermove" event is fired when right clicking even if the cursor didn't move.
if (e.buttons & BUTTONS_RIGHT) {
document.exitPointerLock();
return;
}
// If no buttons are down, we are stuck in the drag state after having released the mouse, so we should exit.
// For some reason on firefox in wayland the button is -1 and the buttons is 0.
if (e.buttons === 0 && e.button !== -1) {
document.exitPointerLock();
return;
}
// Calculate and then update the dragged value offset, slowed down by 10x when Shift is held.
if (ignoredFirstMovement && initialValueBeforeDragging !== undefined) {
const CHANGE_PER_DRAG_PX = 0.1;
const CHANGE_PER_DRAG_PX_SLOW = CHANGE_PER_DRAG_PX / 10;
const dragDelta = e.movementX * (e.shiftKey ? CHANGE_PER_DRAG_PX_SLOW : CHANGE_PER_DRAG_PX);
cumulativeDragDelta += dragDelta;
const combined = initialValueBeforeDragging + cumulativeDragDelta;
const combineSnapped = e.ctrlKey ? Math.round(combined) : combined;
const newValue = updateValue(combineSnapped);
// If the value was altered within the `updateValue()` call, we need to rectify the cumulative drag delta to account for the change.
if (newValue !== undefined) cumulativeDragDelta -= combineSnapped - newValue;
}
ignoredFirstMovement = true;
};
const pointerLockChange = () => {
// Do nothing if we just entered, rather than exited, pointer lock.
if (document.pointerLockElement) return;
// Reset the value to the initial value if the drag was aborted, or to the current value if it was just confirmed by changing the initial value to the current value.
updateValue(initialValueBeforeDragging);
initialValueBeforeDragging = undefined;
cumulativeDragDelta = 0;
// Clean up the event listeners.
removeEventListener("pointerup", pointerUp);
removeEventListener("pointermove", pointerMove);
document.removeEventListener("pointerlockchange", pointerLockChange);
};
addEventListener("pointerup", pointerUp);
addEventListener("pointermove", pointerMove);
document.addEventListener("pointerlockchange", pointerLockChange);
}
// ===============================
// RANGE MODE: DRAGGING THE SLIDER
// ===============================
// Called by the range slider's "input" event which fires continuously while the user is dragging the slider.
// It also fires once when the user clicks the slider, causing its position to jump to the clicked X position.
// Firefox also likes to fire this event more liberally, and it likes to make this event happen after most others,
// which is why the logic for this feature has to be pretty complicated to ensure robustness even across unexpected event ordering.
//
// Summary:
// - Do nothing if the user is still dragging with left click after aborting with right click or escape
// - If this is the first "input" event upon mousedown, manage the state so we end up waiting for the user to decide on a subsequent action:
// - Right clicking or pressing Escape means we abort from the "Deciding" state and wait for the user to release all mouse buttons before respecting any further input
// - Releasing the click without dragging means we focus the text input element to edit the number field
// - Dragging the slider means we commit to dragging the slider, so we begin watching for an abort from that state (and continue onto the next bullet point below)
// - If the user has committed to dragging the slider, so we update this widget's value.
function onSliderInput() {
// Exit early if the slider is disabled by having been aborted by the user.
if (rangeSliderClickDragState === "Aborted") {
// If we've just aborted the drag by right clicking, but the user hasn't yet released the left mouse button, Firefox treats
// some subsequent interactions with the slider (like that right mouse button release, or maybe mouse movement in some cases)
// as input changes to the slider position. Thus, until we leave the "Aborted" state by releasing all mouse buttons, we have
// to set the slider position back to the currently intended value to fight against Firefox's attempts to let the user move it.
updateValue(rangeSliderValueAsRendered);
// Now we exit early because we're ignoring further user input until the user releases all mouse buttons, which gets us back to the "Ready" state.
return;
}
// Keep only 4 digits after the decimal point.
const ROUNDING_EXPONENT = 4;
const ROUNDING_MAGNITUDE = 10 ** ROUNDING_EXPONENT;
const roundedValue = Math.round(rangeSliderValue * ROUNDING_MAGNITUDE) / ROUNDING_MAGNITUDE;
// Exit if this is an extraneous event invocation that occurred after mouseup, which happens in Firefox.
if (value !== undefined && Math.abs(value - roundedValue) < 1 / ROUNDING_MAGNITUDE) {
return;
}
// Snap the slider value to the nearest integer if the Ctrl key is held, or the widget is set to integer mode.
const snappedValue = ctrlKeyDown || isInteger ? Math.round(roundedValue) : roundedValue;
// The first "input" event upon mousedown means we transition to a "Deciding" state, allowing us to wait for the
// next event to determine if the user is dragging (to slide the slider) or releasing (to edit the numerical text field).
if (rangeSliderClickDragState === "Ready") {
// We're in the "Deciding" state now, which means we're waiting for the user to either drag or release the slider.
rangeSliderClickDragState = "Deciding";
// We want to render the fake slider thumb at the old position, which is still the number held by `value`.
rangeSliderValueAsRendered = value || 0;
// We also store this initial value so we can restore it if the user aborts the drag.
initialValueBeforeDragging = value;
// We want to allow the user to right click to abort from this "Deciding" state so the slider isn't stuck waiting for either a drag or release.
addEventListener("mousedown", sliderAbortFromMousedown);
addEventListener("keydown", sliderAbortFromMousedown);
// Exit early because we don't want to use the value set by where on the track the user pressed.
return;
}
// Now that we've past the point of entering the "Deciding" state in this subsequent invocation, we know that the user has
// either dragged or released the mouse so we can stop watching for a right click to abort from that short point in the process.
removeEventListener("mousedown", sliderAbortFromMousedown);
removeEventListener("keydown", sliderAbortFromMousedown);
// If the subsequent event upon entering the "Deciding" state is this slider "input" event caused by the user dragging it, that means the user has
// committed to dragging the slider (instead of alternatively deciding on releasing it to edit the text field, or aborting with Escape/right click).
if (rangeSliderClickDragState === "Deciding") {
// We're dragging now, so that's the new state.
rangeSliderClickDragState = "Dragging";
// Tell the backend that we are beginning a transaction for the history system
startDragging();
// We want to begin watching for an abort while dragging the slider.
addEventListener("pointermove", sliderAbortFromDragging);
addEventListener("keydown", sliderAbortFromDragging);
// Since we've committed to dragging the slider, we want to use the new slider value. Continue to the logic below.
}
// If we're in a dragging state, we want to use the new slider value.
rangeSliderValueAsRendered = snappedValue;
updateValue(snappedValue);
}
// This handles the user releasing all mouse buttons after clicking (and potentially dragging) the slider.
// If the slider wasn't dragged, we focus the text input element to begin editing the number field.
// Then, regardless of the above, we clean up the state and event listeners.
// This is called by the range slider's "pointerup" event bound in the HTML template.
function onSliderPointerUp() {
// User clicked but didn't drag, so we focus the text input element.
if (rangeSliderClickDragState === "Deciding") {
const inputElement = self?.element();
if (!inputElement) return;
// Set the slider position back to the original position to undo the user moving it.
rangeSliderValue = rangeSliderValueAsRendered;
// Begin editing the number text field.
inputElement.focus();
// In the next step, we'll switch back to the neutral state so that after the user is done editing the text field, the process can begin anew.
}
// Since the user decided to release the slider, we reset to the neutral state so the user can begin the process anew.
// But if the slider was aborted, we don't want to reset the state because we're still waiting for the user to release all mouse buttons.
if (rangeSliderClickDragState !== "Aborted") {
rangeSliderClickDragState = "Ready";
}
// Clean up the event listeners that were for tracking an abort while dragging the slider, now that we're no longer dragging it.
removeEventListener("mousedown", sliderAbortFromMousedown);
removeEventListener("keydown", sliderAbortFromMousedown);
removeEventListener("pointermove", sliderAbortFromDragging);
removeEventListener("keydown", sliderAbortFromDragging);
}
function startDragging() {
// This event is sent to the backend so it knows to start a transaction for the history system. See discussion for some explanation:
// <https://github.com/GraphiteEditor/Graphite/pull/1584#discussion_r1477592483>
dispatch("startHistoryTransaction");
}
// We want to let the user abort while dragging the slider by right clicking or pressing Escape.
// This function also helps recover and clean up if the window loses focus while dragging the slider.
// Since we reuse the function for both the "pointermove" and "keydown" events, it is split into parts that only run for a `PointerEvent` or `KeyboardEvent`.
function sliderAbortFromDragging(e: PointerEvent | KeyboardEvent) {
// Logic for aborting from pressing Escape.
if (e instanceof KeyboardEvent) {
// Detect if the user pressed Escape and abort the slider drag.
if (e.key === "Escape") sliderAbort(true);
}
// Logic for aborting from a right click.
// Detect if a right click has occurred and abort the slider drag.
// This handler's "pointermove" event will be fired upon right click even if the cursor didn't move, which is why it's okay to check in this event handler.
if (e instanceof PointerEvent && e.buttons & BUTTONS_RIGHT) {
// Call the abort helper function
sliderAbort(false);
}
// Recovery from the window losing focus while dragging the slider.
// If somehow the user moved the pointer while not left click-dragging the slider, we know that we were stuck in the "Deciding" state, so we recover and clean up.
// This could happen while dragging the slider and using a hotkey to tab away to another window or browser tab, then returning to the stuck state.
if (e instanceof PointerEvent && !(e.target === inputRangeElement && e.buttons & BUTTONS_LEFT)) {
// Switch back to the neutral state.
rangeSliderClickDragState = "Ready";
// Remove the "pointermove" and "keydown" event listeners that are for tracking an abort while
// dragging the slider, now that we're no longer dragging it due to the loss of window focus.
removeEventListener("pointermove", sliderAbortFromDragging);
removeEventListener("keydown", sliderAbortFromDragging);
}
}
// We want to let the user abort immediately after clicking the slider, but not yet deciding to drag or release.
// During this momentary step, the slider hasn't moved yet but we want to allow aborting from this limbo state.
function sliderAbortFromMousedown(e: MouseEvent | KeyboardEvent) {
// Logic for aborting from a right click or pressing Escape.
const abortWithEscape = e instanceof KeyboardEvent && e.key === "Escape";
const abortWithRightClick = e instanceof MouseEvent && e.button === BUTTON_RIGHT;
// Call the abort helper function
if (abortWithEscape || abortWithRightClick) sliderAbort(abortWithEscape);
// Clean up these event listeners because they were for getting us into this function and now we're done with them.
removeEventListener("mousedown", sliderAbortFromMousedown);
removeEventListener("keydown", sliderAbortFromMousedown);
}
// Helper function that performs the state management and cleanup for aborting the slider drag.
function sliderAbort(abortWithEscape: boolean) {
const element = self?.element() || undefined;
if (abortWithEscape && element) preventEscapeClosingParentFloatingMenu(element);
// End the user's drag by instantaneously disabling and re-enabling the range input element
if (inputRangeElement) inputRangeElement.disabled = true;
setTimeout(() => {
if (inputRangeElement) inputRangeElement.disabled = false;
}, 0);
// Set the value back to the original value before the user began dragging.
if (initialValueBeforeDragging !== undefined) {
rangeSliderValueAsRendered = initialValueBeforeDragging;
updateValue(initialValueBeforeDragging);
}
// Set the state to "Aborted" so we can ignore further user input until the user releases all mouse buttons.
rangeSliderClickDragState = "Aborted";
// Detect when all mouse buttons are released so we can exit the "Aborted" state and return to the "Ready" state.
// (The "pointerup" event is defined as firing only upon all mouse buttons being released, which is what we need here.)
const sliderResetAbort = () => {
// Switch back to the neutral state so the user can begin the process anew.
// We do this inside setTimeout() to delay this until after Firefox has fired its extraneous "input" event after this "pointerup" event.
//
// A delay of 0 seems to be sufficient, but if the bug persists, we can try increasing the delay. The bug is reproduced in Firefox by
// dragging the slider, hitting Escape, then releasing the mouse button. This results in being transferred by `onSliderInput()` to the
// "Deciding" state when we should remain in the "Ready" state as set here. (For debugging, this can be visualized in CSS by
// recoloring the fake slider handle, which is shown in the "Deciding" state.)
setTimeout(() => (rangeSliderClickDragState = "Ready"), 0);
// Clean up the event listener that was used to call this function.
removeEventListener("pointerup", sliderResetAbort);
};
addEventListener("pointerup", sliderResetAbort);
// Clean up the event listeners that were for tracking an abort while dragging the slider, now that we're no longer dragging it.
removeEventListener("pointermove", sliderAbortFromDragging);
removeEventListener("keydown", sliderAbortFromDragging);
}
</script>
<FieldInput
class={"number-input"}
classes={{
increment: mode === "Increment",
range: mode === "Range",
}}
value={text}
on:value={({ detail }) => (text = detail)}
on:textFocused={onTextFocused}
on:textChanged={onTextChanged}
on:textChangeCanceled={onTextChangeCanceled}
on:pointerdown={onDragPointerDown}
{label}
{disabled}
{tooltip}
{styles}
hideContextMenu={true}
spellcheck={false}
bind:this={self}
>
{#if value !== undefined}
{#if mode === "Increment" && incrementBehavior !== "None"}
<button
class="arrow left"
on:pointerdown={(e) => onIncrementPointerDown(e, "Decrease")}
on:mousedown={incrementPressAbort}
on:pointerup={onIncrementPointerUp}
on:pointerleave={onIncrementPointerUp}
tabindex="-1"
></button>
<button
class="arrow right"
on:pointerdown={(e) => onIncrementPointerDown(e, "Increase")}
on:mousedown={incrementPressAbort}
on:pointerup={onIncrementPointerUp}
on:pointerleave={onIncrementPointerUp}
tabindex="-1"
></button>
{/if}
{#if mode === "Range"}
<input
type="range"
tabindex="-1"
class="slider"
class:hidden={rangeSliderClickDragState === "Deciding"}
{disabled}
min={rangeMin}
max={rangeMax}
step={sliderStepValue}
bind:value={rangeSliderValue}
on:input={onSliderInput}
on:pointerup={onSliderPointerUp}
on:contextmenu|preventDefault
on:wheel={(e) => /* Stops slider eating the scroll event in Firefox */ e.target instanceof HTMLInputElement && e.target.blur()}
bind:this={inputRangeElement}
/>
{#if rangeSliderClickDragState === "Deciding"}
<div class="fake-slider-thumb" />
{/if}
<div class="slider-progress" />
{/if}
{/if}
</FieldInput>
<style lang="scss" global>
.number-input {
input {
text-align: center;
}
&.increment {
// Widen the label and input margins from the edges by an extra 8px to make room for the increment arrows
label {
margin-left: 8px;
}
// Keep the right-aligned input element from overlapping the increment arrow on the right
input[type="text"]:not(:focus).has-label {
margin-right: 8px;
}
// Hide the increment arrows when entering text, disabled, or not hovered
input[type="text"]:focus ~ .arrow,
&.disabled .arrow,
&:not(:hover) .arrow {
display: none;
}
// Show the left-right arrow cursor when hovered over the draggable area
&:not(.disabled) input[type="text"]:not(:focus),
&:not(.disabled) label {
cursor: ew-resize;
}
// Style the decrement/increment arrows
.arrow {
position: absolute;
top: 0;
margin: 0;
padding: 9px 0;
border: none;
border-radius: 2px;
background: rgba(var(--color-1-nearblack-rgb), 0.5);
// An outline can appear when pressing the arrow button with left click then hitting Escape, so this stops that from showing
outline: none;
// TODO: This is a quick, imperfect way to make the arrow buttons appear like they're behind the text (without messing with the element click targets if we used z-index).
// TODO: But it doesn't preserve the exact hover color due to the blending. Improve this by using a separate element for displaying the arrow and listening for pointer events.
mix-blend-mode: screen;
&.right {
right: 0;
padding-left: 7px;
padding-right: 6px;
&::before {
content: "";
display: block;
width: 0;
height: 0;
border-style: solid;
border-width: 3px 0 3px 3px;
border-color: transparent transparent transparent var(--color-e-nearwhite);
}
}
&.left {
left: 0;
padding-left: 6px;
padding-right: 7px;
&::after {
content: "";
display: block;
width: 0;
height: 0;
border-style: solid;
border-width: 3px 3px 3px 0;
border-color: transparent var(--color-e-nearwhite) transparent transparent;
}
}
&:hover {
background: var(--color-4-dimgray);
&::before {
border-color: transparent transparent transparent var(--color-f-white);
}
&::after {
border-color: transparent var(--color-f-white) transparent transparent;
}
}
}
}
&.range {
position: relative;
input[type="text"],
label {
z-index: 1;
}
input[type="text"]:focus ~ .slider,
input[type="text"]:focus ~ .fake-slider-thumb,
input[type="text"]:focus ~ .slider-progress {
display: none;
}
.slider {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
-webkit-appearance: none; // Required until Safari 15.4 (Graphite supports 15.0+)
appearance: none;
background: none;
cursor: default;
// Except when disabled, the range slider goes above the label and input so it's interactable.
// Then we use the blend mode to make it appear behind which works since the text is almost white and background almost black.
// When disabled, the blend mode trick doesn't work with the grayer colors. But we don't need it to be interactable, so it can actually go behind properly.
z-index: 2;
mix-blend-mode: screen;
&.hidden {
opacity: 0;
}
&:disabled {
mix-blend-mode: normal;
z-index: 0;
}
&:hover ~ .slider-progress::before {
background: var(--color-3-darkgray);
}
// Chromium and Safari
&::-webkit-slider-thumb {
-webkit-appearance: none; // Required until Safari 15.4 (Graphite supports 15.0+)
appearance: none;
border-radius: 2px;
width: 4px;
height: 22px;
background: #494949; // Becomes var(--color-5-dullgray) with screen blend mode over var(--color-1-nearblack) background
}
&:hover::-webkit-slider-thumb {
background: #5b5b5b; // Becomes var(--color-6-lowergray) with screen blend mode over var(--color-1-nearblack) background
}
&:disabled::-webkit-slider-thumb {
background: var(--color-4-dimgray);
}
// Firefox
&::-moz-range-thumb {
border: none;
border-radius: 2px;
width: 4px;
height: 22px;
background: #494949; // Becomes var(--color-5-dullgray) with screen blend mode over var(--color-1-nearblack) background
}
&:hover::-moz-range-thumb {
background: #5b5b5b; // Becomes var(--color-6-lowergray) with screen blend mode over var(--color-1-nearblack) background
}
&:disabled::-moz-range-thumb {
background: var(--color-4-dimgray);
}
&::-moz-range-track {
height: 0;
}
}
// This fake slider thumb stays in the location of the real thumb while we have to hide the real slider between mousedown and mouseup or mousemove.
// That's because the range input element moves to the pressed location immediately upon mousedown, but we don't want to show that yet.
// Instead, we want to wait until the user does something:
// - Releasing the mouse means we reset the slider to its previous location, thus canceling the slider move. In that case, we focus the text entry.
// - Moving the mouse left/right means we have begun dragging, so then we hide this fake one and continue showing the actual drag of the real slider.
.fake-slider-thumb {
position: absolute;
left: 2px;
right: 2px;
top: 0;
bottom: 0;
z-index: 2;
mix-blend-mode: screen;
pointer-events: none;
&::before {
content: "";
position: absolute;
border-radius: 2px;
margin-left: -2px;
width: 4px;
height: 22px;
top: 1px;
left: calc(var(--progress-factor) * 100%);
background: #5b5b5b; // Becomes var(--color-6-lowergray) with screen blend mode over var(--color-1-nearblack) background
}
}
.slider-progress {
position: absolute;
top: 2px;
bottom: 2px;
left: 2px;
right: 2px;
pointer-events: none;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: calc(var(--progress-factor) * 100% - 2px);
height: 100%;
background: var(--color-2-mildblack);
border-radius: 1px 0 0 1px;
}
}
}
}
</style>