-
Notifications
You must be signed in to change notification settings - Fork 11
/
Copy pathutils_ui.js
2025 lines (1758 loc) · 80.3 KB
/
utils_ui.js
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
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
Ethereal Farm
Copyright (C) 2020-2024 Lode Vandevenne
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// This text centering method is simple because it involves only one HTML
// element and allows changing the text at any time without updating this,
// but it only supports single-line text. The div must already have its final
// height and shouldn't change.
// TODO: the "shoudln't change" part makes this a no-go for resizing window size; remove this function and use only centerText2.
function centerText(div, opt_clientHeight, opt_vertical_only) {
var divheight = opt_clientHeight || div.clientHeight;
// the next 3 properties are to center text horizontally and vertically
if(!opt_vertical_only) div.style.textAlign = 'center';
div.style.verticalAlign = 'middle';
div.style.lineHeight = divheight + 'px';
div.textEl = div; // for correspondence with centerText2
}
// centers text and supports multiline, using the css table method.
// this involves creating a child element in the div. It will set a new field
// on your dif names textEl, that is the one you must set innerText or
// innerHTML to. Other than that fact, this one is the most versatile.
// opt_align_hor: if not given, centers. Otherwise 0=left, 1=center, 2=right
// opt_align_ver: if not given, centers. Otherwise 0=top, 1=center, 2=bottom
function centerText2(div, opt_align_hor, opt_align_ver) {
if(opt_align_hor == undefined) opt_align_hor = 1;
if(opt_align_ver == undefined) opt_align_ver = 1;
div.innerHTML = '';
var table = util.makeElement('div', div);
table.style.display = 'table';
table.style.width = '100%';
table.style.height = '100%';
var cell = util.makeElement('div', table);
cell.style.display = 'table-cell';
cell.style.verticalAlign = (opt_align_ver == 1) ? 'middle' : (opt_align_ver == 0 ? 'top' : 'bottom');
cell.style.textAlign = (opt_align_hor == 1) ? 'center' : (opt_align_hor == 0 ? 'left' : 'right');
//cell.style.width = '100%';
//cell.style.height = '100%';
div.textEl = cell;
}
// for some reason the unicode gear character (⚙) does not properly center when using centerText2, only for some fonts
// the additional rule in this function appears to fix it for now
// TODO: find more reliable solution and merge centerText2 and centerText2_unicode again
function centerText2_unicode(div) {
centerText2(div);
div.textEl.style.lineHeight = '100%';
}
// This text centering method requires you to have a parent and child element,
// both already existing in that form, and with the child element already
// having content filled in. This call will then center the child.
// This supports multiline text. But it involves multiple elements and requires
// calling again if the text changes.
function centerContent(parent, child) {
var cw = child.clientWidth;
var ch = child.clientHeight;
var pw = parent.clientWidth;
var ph = parent.clientHeight;
child.style.left = Math.floor((pw - cw) / 2) + 'px';
child.style.top = Math.floor((ph - ch) / 2) + 'px';
}
// returns ratio width/height of the main div
function getMainFlexRatio() {
if(!mainFlex || !mainFlex.div) return 1;
return mainFlex.div.clientWidth / mainFlex.div.clientHeight;
}
function setAriaLabel(div, label) {
div.setAttribute('aria-label', label);
}
function setAriaRole(div, role) {
div.setAttribute('role', role);
}
// deprecated: use registerAction instead.
// opt_label is an optional textual name for image-icon-buttons
// opt_immediate = make the button respond immediately on mousedown, rather than only on mouseup
// opt_noenterkey = do not make it activate on enter key (e.g. for the close button of dialogs, which is selected by default normally but shouldn't make the dialog close on pressing enter)
// TODO: work in progress refactoring: use registerAction for everything
function addButtonAction(div, fun, opt_label, opt_immediate, opt_noenterkey) {
//var div = makeDiv('0', '0', '100%', '100%', div);
if(opt_immediate && !isTouchDevice()) {
// TODO: verify this works on all devices (screen readers, mobile where for some reason isTouchDevice doesn't detect it, etc...)
util.addEvent(div, 'mousedown', fun);
} else {
util.addEvent(div, 'click', fun);
}
if(!opt_noenterkey) {
util.addEvent(div, 'keypress', function(e) {
if(e.key == 'Enter') fun(e);
e.preventDefault();
});
}
div.tabIndex = 0;
setAriaRole(div, 'button');
//div.setAttribute('aria-pressed', 'false'); // TODO: is this needed? maybe it's only for toggle buttons? which addButtonAction is not.
if(opt_label) setAriaLabel(div, opt_label);
}
/*
registers click action(s), or tooltip, or both.
this replaces (and deprecates) addAction
this can support getting tooltips and shift+click or ctrl+click on mobile UI's too, which is why it has to be all together
div: the div to add the actions and/or tooltip to
fun: function called for actions, receives 3 boolean parameters: shift, ctrl (does not receive the JS event) and longclick_extra. Or give undefined to use this e.g. for tooltip only.
label: aria/mobile label for the action (must be short enough to fit a button in long-press context menu), or undefined if fun is undefined
params: optional, object with following named parameters, all optional:
params.label_shift: label for the action when shift is pressed. In addition, this also implies a shift action is available and should be displayed in mobile UI
params.label_ctrl: label for the action when shift is pressed. In addition, this also implies a ctrl action is available and should be displayed in mobile UI
params.label_ctrl_shift: label for the action when shift and ctrl are pressed. In addition, this also implies a ctrl action is available and should be displayed in mobile UI
params.label_longclick_extra: label for extra action that only shows up on longclick
params.tooltip: function or string for tooltip. If it's text, it's shown as-is. If function, the function should return text (for tooltips with dynamic content)
params.tooltip_poll: if true, will make the tooltip dynamically update by calling fun again
params.immediate: make the button respond immediately on mousedown, rather than only on mouseup. Not compatible with mobile.
params.noenterkey: do not make it activate on enter key (e.g. for the close button of dialogs, which is selected by default normally but shouldn't make the dialog close on pressing enter)
params.isdraggable: indicate there is some dragging action available from the div, that works on PC but not on mobile. Therefore, disable the long touch event on non-touch devices here since it may interfere with the dragging.
*/
function registerAction(div, fun, label, params) {
if(!params) params = {};
if(fun) {
// can also give undefined for e
var fun2 = function(e) {
var shift = e && eventHasShiftKey(e);
var ctrl = e && eventHasCtrlKey(e);
fun(shift, ctrl);
};
if(params.immediate && !isTouchDevice()) {
// TODO: verify this works on all devices (screen readers, mobile where for some reason isTouchDevice doesn't detect it, etc...)
util.setEvent(div, 'mousedown', fun2, 'action');
} else {
util.setEvent(div, 'click', fun2, 'action');
}
if(!params.noenterkey) {
util.setEvent(div, 'keypress', function(e) {
if(e.key == 'Enter') fun2(undefined);
e.preventDefault();
}, 'action');
}
div.tabIndex = 0;
setAriaRole(div, 'button');
//div.setAttribute('aria-pressed', 'false'); // TODO: is this needed? maybe it's only for toggle buttons? which addButtonAction is not.
if(label) setAriaLabel(div, label);
}
if(params.tooltip) {
registerTooltip_(div, params.tooltip, params.tooltip_poll, !params.fun);
}
if(params.tooltip || params.label_shift || params.label_ctrl) {
// even though it's intended for mobile, enable the long touch also on desktop for testing it or trying it out. But don't do that for draggable things like the fruits since the long touch interferes too much with it
if(isTouchDevice() || !params.isdraggable) {
addLongTouchEvent(div, function(e) {
makeLongTouchContextDialog(div, fun, label, params);
});
}
}
}
// prevent opening multiple: some scrolling bug on mobile may accidently open tons of them, at least ensure it's only one in that case
var longTouchContextDialog_open = false;
// gets the same params as registerAction
function makeLongTouchContextDialog(div, fun, label, params) {
if(longTouchContextDialog_open) return;
longTouchContextDialog_open = true;
var dialog = createDialog({
title:'Long press context menu',
bgstyle:'efDialogLongPress',
scrollable:true,
onclose: function() { longTouchContextDialog_open = false; }
});
var content = dialog.content;
var texth = 0.05;
var y = 0;
var addButton = function() {
var h = 0.07;
var flex = new Flex(content, 0.05, y, 0.95, y + h);
y += h * 1.2;
flex.div.className = 'efButton';
styleButton0(flex.div);
centerText2(flex.div);
return flex;
};
var flex = new Flex(content, 0.05, y, 0.95, y + texth);
flex.div.innerText = 'Long click allows using shift/ctrl key actions and tooltips on touch interfaces.';
y += 0.05;
y += 0.05;
if(fun) {
var button = addButton();
registerAction(button.div, function() {
closeTopDialog(); // close this context menu
fun(false, false, false);
}, label, {});
button.div.textEl.innerText = 'main: ' + upper(label);
}
if(params.label_shift) {
var button = addButton();
registerAction(button.div, function() {
closeTopDialog(); // close this context menu
fun(true, false, false);
}, label, {});
button.div.textEl.innerText = 'shift: ' + upper(params.label_shift);
}
if(params.label_ctrl) {
var button = addButton();
registerAction(button.div, function() {
closeTopDialog(); // close this context menu
fun(false, true, false);
}, label, {});
button.div.textEl.innerText = 'ctrl: ' + upper(params.label_ctrl);
}
if(params.label_ctrl_shift) {
var button = addButton();
registerAction(button.div, function() {
closeTopDialog(); // close this context menu
fun(true, true, false);
}, label, {});
button.div.textEl.innerText = 'shift+ctrl: ' + upper(params.label_ctrl_shift);
}
if(params.label_longclick_extra) {
var button = addButton();
registerAction(button.div, function() {
closeTopDialog(); // close this context menu
fun(false, false, true);
}, label, {});
button.div.textEl.innerText = 'other: ' + upper(params.label_longclick_extra);
}
y += 0.05;
if(params.tooltip) {
var tooltip = (typeof params.tooltip == 'string') ? params.tooltip : (params.tooltip());
if(tooltip) {
var textflex = new Flex(content, 0.05, y, 0.95, y + 0.05);
textflex.div.innerText = 'Tooltip:';
flex = new Flex(content, 0.05, y + 0.05, 0.95, 'a');
flex.div.innerHTML = tooltip;
applyTooltipStyle(flex.div, state.tooltipstyle);
}
}
}
// because touch events have their x and y position inside touches or targetTouches
function getEventXY(e) {
var x = e.clientX;
var y = e.clientY;
if(!x && e.targetTouches && e.targetTouches[0]) {
x = e.targetTouches[0].clientX;
y = e.targetTouches[0].clientY;
}
return [x, y];
}
/*
Add an action for long-touching (or long-pressing if with mouse) a button. This is useful for mobile since shift+click, ctrl+click and right click are missing there, so long press at least adds some alternative form of click.
Things that make this tricky:
-JS doesn't have anything for long-touch events built-in, so there's no well-supported guaranteed to work on all platforms method to do it
-Mobile browsers may have their own long-touch behavior (which may differe per platform, e.g. callout, context menu, ...) that could override our intended one
-JS fires different events on mobile and desktop devices (e.g. touchstart vs mousedown), but can also trigger mouse ones on mobile as well
-Mouse or finger may move while holding the long press, we need to distinguish if it's still intended to be a long touch, or dragging an element instead
-There are many other mouse/touch related events that must not be forgotten and might also interact with this, some of which may behave differently per browser, or only added in future JS versions (e.g. in addition to regular mouse events there are also drag-related events)
*/
function addLongTouchEvent(div, fun) {
var longtouchtime = 0.8;
var timer;
var x0 = 0;
var y0 = 0;
var cancelClick = function(e) {
div.removeEventListener('click', cancelClick, true);
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
return false;
};
var touch = isTouchDevice();
var startEvent = touch ? 'touchstart' : 'mousedown';
var endEvent = touch ? 'touchend' : 'mouseup';
var leaveEvent = touch ? undefined : 'mouseleave';
var moveEvent = touch ? 'touchmove' : 'mousemove';
util.addEvent(div, startEvent, function(e) {
if(!touch && e.which == 3) return; // don't prevent right click menu in regular browsers
if(eventHasCtrlKey(e) || eventHasShiftKey(e)) return; // if user is using shift/ctrl, the long click is definitely not needed.
// don't prevent *next* click (for touch case, where preventing regular click below is in fact possibly not executed, but some other things use onclick)
div.removeEventListener('click', cancelClick, true);
var pos = getEventXY(e);
x0 = pos[0];
y0 = pos[1];
if(timer) clearTimeout(timer);
timer = window.setTimeout(function() {
timer = undefined;
if(!div.isConnected) return; // if the element is gone in the meantime, don't execute this. Example: the close button of dialogs, the close button itself disappears when the dialog closed, but this timer may still have been active (and no mouseup event canceled the timer)
fun();
// prevent the regular click event
util.addEvent(div, 'click', cancelClick, true);
}, longtouchtime * 1000);
}, true);
util.addEvent(div, endEvent, function() {
if(!timer) return;
clearTimeout(timer);
timer = undefined;
}, true);
if(leaveEvent) util.addEvent(div, leaveEvent, function() {
// leave event is not there for touch devices, but on PC's it can trigger, and we clear timeout because when mouse leaves element, the mouseup event will not trigger anymore so it would think we held down mouse forever and will show the long-click menu when it shouldn't if mouse accidently moves a few pixels outside while short clicking the element
if(!timer) return;
clearTimeout(timer);
timer = undefined;
}, true);
util.addEvent(div, moveEvent, function(e) {
if(!timer) return;
var pos = getEventXY(e);
// allow some slack in the movement for touch position
if(Math.abs(x0 - pos[0]) >= 10 || Math.abs(y0 - pos[1]) >= 10) {
clearTimeout(timer);
timer = undefined;
}
}, true);
// this event shouldn't appear on touch devices, but should stop the timer if present since it prevents moveEvents from detecting that the long prss menu shouldn't appear
util.addEvent(div, 'dragstart', function(e) {
if(!timer) return;
clearTimeout(timer);
timer = undefined;
}, true);
// this stops mobile context menu from appearing on long press, since we have our own context menu
div.oncontextmenu = function(e) {
if(!touch && e.which == 3) return; // don't prevent right click menu in regular browsers
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
return false;
};
// this stops mobile context callouts from appearing on long press on ios
div.style.webkitTouchCallout = 'none';
div.style.webkitUserSelect = 'none';
}
// styles only a few of the essential properties for button
// does not do centering (can be used for other text position), or colors/borders/....
// must be called *after* any styles such as backgroundColor have already been set
// opt_disallow_hover_filter: disallow the mouse hover filter effect. Normally would be nice to always have, but chrome will make pixelated canvases a blurry mess when applying opacity or filter, so disable it for canvases for now.
function styleButton0(div, opt_disallow_hover_filter) {
div.style.cursor = 'pointer';
div.style.userSelect = 'none'; // prevent unwanted selections when double clicking things
if(div.textEl) div.textEl.style.userSelect = 'none'; // prevent unwanted selections when double clicking things
if(opt_disallow_hover_filter == 2) {
util.setEvent(div, 'mouseover', function() { div.style.border = '4px solid red';}, 'buttonstyle');
util.setEvent(div, 'mouseout', function() { div.style.filter = ''; }, 'buttonstyle');
} else if(!opt_disallow_hover_filter) {
util.setEvent(div, 'mouseover', function() { div.style.filter = 'brightness(0.93)';}, 'buttonstyle');
util.setEvent(div, 'mouseout', function() { div.style.filter = ''; }, 'buttonstyle');
} else {
// as an alternative to the filter, add an invisible border around the canvas, this slightly changes its size, indicating the hover that way
util.setEvent(div, 'mouseover', function() { div.style.border = '1px solid #0000';}, 'buttonstyle');
util.setEvent(div, 'mouseout', function() { div.style.border = ''; }, 'buttonstyle');
}
}
// somewhat like makeDiv, but gives mouseover/pointer/... styles
// also sets some fields on the div: hightlight/hover colors, textEl, ...
// opt_flat: use flat style (no shadow, ....), e.g. for tabs and grids
function styleButton(div, opt_color, opt_flat) {
div.className = opt_flat ? 'efFlatButton' : 'efButton';
div.textEl = div; // for consistency with what different centerText varieties do
centerText2(div);
styleButton0(div);
div.use_flat_style = !!opt_flat;
}
function highlightButton(div, highlight) {
if(highlight) div.className = div.use_flat_style ? 'efFlatButtonHighlighted' : 'efButtonHighlighted';
else div.className = div.use_flat_style ? 'efFlatButton' : 'efButton';
}
// div must already have the position and size (the arguments are used to compute stuff inside of it)
function initProgressBar(div, color) {
div.style.boxSizing = 'border-box'; // have the border not make the total size bigger, have it go inside
div.style.border = '1px solid black';
var c = makeDiv('0%', '0%', '100%', '100%', div);
c.style.backgroundColor = 'red';
div.style.display = 'none';
div.style.backgroundColor = '#ddd';
div.visible = false;
div.c = c;
}
// value is in range 0-1. Make negative to hide the progress bar.
function setProgressBar(div, value, color) {
if(value < 0) {
if(div.visible) {
div.style.display = 'none';
div.visible = false;
div.progressbarvalue_ = value;
}
return;
}
if(value > 1) value = 1;
if(div.progressbarvalue_ != undefined) {
var diff = Math.abs(value - div.progressbarvalue_);
if(diff < 0.01) return;
}
div.progressbarvalue_ = value;
var c = div.c;
if(!div.visible) {
div.style.display = '';
div.visible = true;
}
c.style.width = (100 * value) + '%';
if(color && div.colorCache_ != color) {
div.c.style.backgroundColor = color;
div.colorCache_ = color; // reading div.style.backgroundColor is slow, so set in higher field.
}
}
// for the updatedialogfun parameter of dialogs. Currently there can be only one global one, so multiple stacked dialogs each having their own updatedialogfun is not yet supported
var globalupdatedialogfun = undefined;
var dialog_level = 0;
var created_dialogs = []; // array of objects created by the createDialog function
var created_overlays = [];
var DIALOG_TINY = 0;
var DIALOG_SMALL = 1;
var DIALOG_MEDIUM = 2;
var DIALOG_LARGE = 3;
var DIALOG_EXTRA_LARGE = 4;
// if not undefined, can handle shortcuts for dialog (as opposed to global shortcut)
// NOTE: only supported in one dialog level in the chain
var dialogshortcutfun = undefined;
var NOCANCELBUTTON = 'nocancel';
/*
params is object with following named parameters, all optional:
params.functions: function, or array of functions, for the ok/action buttons
params.names: button names for the functions. This and functions must be either arrays of same size, or function and string (or undefined for default 'ok') if there should be exactly one non-cancel button, or both undefined for none at all
params.names_shift: button names for shift/ctrl variants of functions. Optional. Allows the mobile long-press context menu on dialog buttons.
params.names_ctrl: idem for CTRL key
params.names_ctrl_shift: idem vor SHIFT+CTRL key
params.tooltips: optional tooltips for some buttons
params.onclose: function that is always called when the dialog closes, no matter how (whether through an action, the cancel button, or some other means like global close or escape key). It receives a boolean argument 'cancel' that's true if it was closed by any other means than a non-cancel button (so, true if it was canceled)
params.oncancel: similar to onclose, but only called in case of cancel actions (the cancel button, esc key, ...), not when one of the buttons from params.functions/params.names got pressed
params.cancelname: name for the cancel button, gets a default name if not given
params.nocancel: if set, no cancel button will be rendered at all
params.title: title for top of the dialog. If empty, title can still be set on the flex result.title
params.icon: image, icon for top left of the dialog. If empty, title can still be set on the flex result.icon
params.iconmargin: if present, gives this percentage of margin to the icon, if not present there's 0 margin. Should be used only when filling the dialog.icon space yourself, so when not using params.icon: params.icon already applies its own margin.
params.help: optional function or string for help text. If set, there'll be a help button. If text, a dialog with the help text will be opened. If function, the function will be executed when pressing the help button
params.size: DIALOG_TINY, DIALOG_SMALL, DIALOG_MEDIUM, DIALOG_LARGE or DIALOG_EXTRA_LARGE
parrams.narrow: content will be more narrow, the width of the top icon / close button will be removed from each side, allows making a taller icon
params.scrollable: whether to make the content flex scrollable with scrollbar if the content is too large
params.scrollable_canchange: like scrollable, but with more event listeners to adapt to changing content
params.shortcutfun: a function handling shortcuts that are active while this dialog is open. Takes JS event e as a parameter
params.nobgclose: boolean, don't close by clicking background or pressing esc, for e.g. savegame recovery dialog
params.swapbuttons: swap the order of the buttons. This order can also be swapped by the state.cancelbuttonright setting. This swaps them in addition to what that does
params.bgstyle: className of alternative background CSS style, e.g. 'efDialogEthereal'
params.invbold: make the cancel button instead of ok button bold
params.allbold: make all buttons bold, cancel and action buttons
params.updatedialogfun: if you wish text of a dialog to be updated dynamically, set this function to something. NOTE: try to make this function efficient and only update DOM if something actually changes, to avoid too many updates every frame: this will be called every update()
Return object contains (amongst other fields):
dialog.content: flex where the main content can be put
dialog.div: div of the entire dialog (to change e.g. the background color)
dialog.icon: flex where the icon can go
*/
function createDialog(params) {
if(!params) params = {};
var functions = params.functions;
var names = params.names;
var tooltips = params.tooltips;
var names_shift = params.names_shift;
var names_ctrl = params.names_ctrl;
var names_ctrl_shift = params.names_ctrl_shift;
if(!Array.isArray(functions)) functions = (functions ? [functions] : []);
if(!Array.isArray(names)) names = (functions ? [names || 'ok'] : []);
if(!Array.isArray(tooltips)) tooltips = (tooltips ? [tooltips] : []);
if(!Array.isArray(names_shift)) names_shift = (names_shift ? [names_shift] : []);
if(!Array.isArray(names_ctrl)) names_ctrl = (names_ctrl ? [names_ctrl] : []);
if(!Array.isArray(names_ctrl_shift)) names_ctrl_shift = (names_ctrl_shift ? [names_ctrl_shift] : []);
var dialog = {};
if(dialog_level < 0) {
// some bug involving having many help dialogs pop up at once and rapidly closing them using multiple methods at the same time (esc key, click next to dialog, ...) can cause this, and negative dialog_level makes dialogs appear in wrong z-order
closeAllDialogs();
}
dialog_level++;
updateDialogLevel();
dialogshortcutfun = params.shortcutfun; // may be undefined
removeAllTooltips(); // this is because often clicking some button with a tooltip that opens a dialog, then causes that tooltip to stick around which is annoying
removeAllDropdownElements();
var dialogFlex;
if(params.size == DIALOG_TINY) {
dialogFlex = new Flex(topDialogFlex, 0.05, 0.33, 0.95, 0.66);
} else if(params.size == DIALOG_SMALL) {
dialogFlex = new Flex(topDialogFlex, 0.05, 0.25, 0.95, 0.75);
} else if(params.size == DIALOG_LARGE) {
dialogFlex = new Flex(topDialogFlex, 0.05, 0.075, 0.95, 0.925);
} else if(params.size == DIALOG_EXTRA_LARGE) {
dialogFlex = new Flex(topDialogFlex, 0.025, 0.025, 0.95, 0.975);
} else {
// default, medium. Designed to be as big as possible without covering up the resource display
dialogFlex = new Flex(topDialogFlex, 0.05, 0.12, 0.95, 0.9);
}
dialog.onclose = params.onclose;
dialog.oncancel = params.oncancel;
dialog.shortcutfun = params.shortcutfun;
dialog.have_updatedialogfun = !!params.updatedialogfun;
if(params.updatedialogfun) globalupdatedialogfun = params.updatedialogfun;
created_dialogs.push(dialog);
dialogFlex.div.className = params.bgstyle || 'efDialog';
setAriaRole(dialogFlex.div, 'dialog');
dialogFlex.div.setAttribute('aria-modal', 'true');
dialogFlex.div.style.zIndex = '' + (dialog_level * 10 + 5);
// function that will be called when the dialog is closed by cancel (including e.g. the esc key), but not ok and extra funs
dialog.cancelFun = function() {
dialog.closeFun(true);
};
// function that will be called when the dialog is closed by cancel, ok and extra funs
dialog.closeFun = function(opt_cancel) {
if(dialog.have_updatedialogfun) globalupdatedialogfun = undefined; // only do this if this dialog has one, don't needlessly clear it if it's actually a parent dialog that has this updatedialogfun
dialogshortcutfun = undefined;
util.removeElement(overlay);
for(var i = 0; i < created_dialogs.length; i++) {
if(created_dialogs[i] == dialog) {
created_dialogs.splice(i, 1);
if(i > 0) dialogshortcutfun = created_dialogs[i - 1].shortcutfun;
break;
}
}
for(var i = 0; i < created_overlays.length; i++) {
if(created_overlays[i] == overlay) {
created_overlays.splice(i, 1);
break;
}
}
dialog_level--;
// a tooltip created by an element from a dialog could remain, make sure those are removed too
removeAllTooltips();
dialog.removeSelfFun(opt_cancel);
showGoalChips(); // this ensures a faster response time for the display of red help arrows when dialogs are opening/closing, otherwise it only happens after a tick, which, even if sub-second, feels sluggish
updateDialogLevel();
};
// more primitive close, only intended for external use by closeAllDialogs, since that does all the things that closeFun above does in a global way for all dialogs
dialog.removeSelfFun = function(opt_cancel) {
dialogFlex.removeSelf(topDialogFlex);
if(opt_cancel && dialog.oncancel) dialog.oncancel();
if(dialog.onclose) dialog.onclose(); // this must be called no matter with what method this dialog is closed/forcibly removed/...
};
var topHeight = 0.11;
var title_x0;
var title_x1;
var topFlex = new Flex(dialogFlex, 0, 0, 1, [0, topHeight]);
topFlex.div.className = 'efDialogTop';
var xbutton = new Flex(dialogFlex, 1 - topHeight, 0, 1, [0, topHeight]);
var canvas = createCanvas('20%', '20%', '60%', '60%', xbutton.div);
renderImage(image_close, canvas);
styleButton0(xbutton.div);
registerAction(xbutton.div, function(shift, ctrl) {
if(dialog.cancelFun) dialog.cancelFun();
if(ctrl) closeAllDialogs();
}, (params.title ? (' close dialog: "' + params.title + '"') : 'dialog close button'), {
immediate:true,
tooltip:'close'
});
xbutton.div.className = 'efNoOutline';
var helpbutton;
if(params.help) {
helpbutton = new Flex(dialogFlex, 1- topHeight * 2, 0, 1 - topHeight, [0, topHeight]);
var canvas = createCanvas('20%', '20%', '60%', '60%', helpbutton.div);
renderImage(image_help, canvas);
styleButton0(helpbutton.div);
if(typeof params.help == 'string') {
var helptext = params.help;
params.help = function() {
var helpdialog = createDialog({scrollable:true, title:'Help'});
helpdialog.content.div.innerHTML = helptext;
};
}
addButtonAction(helpbutton.div, params.help, 'help');
helpbutton.div.title = 'help';
title_x0 = topHeight * 2;
title_x1 = 1 -topHeight * 2;
} else {
title_x0 = topHeight;
title_x1 = 1 - topHeight;
}
var nobottombuttons = params.nocancel && functions.length == 0;
var contentHeight = 0.88;
if(params.size == DIALOG_TINY) contentHeight = 0.8; // ensure content doesn't go over the buttons
if(params.size == DIALOG_SMALL) contentHeight = 0.8; // ensure content doesn't go over the buttons
if(nobottombuttons) contentHeight = 1;
dialog.flex = dialogFlex;
dialog.div = dialogFlex.div;
var iconmargin = params.iconmargin || 0.05;
dialog.icon = new Flex(dialogFlex, topHeight * iconmargin, [0, topHeight * iconmargin], topHeight * (1 - iconmargin), [0, topHeight * (1 - iconmargin)]);
if(params.narrow) {
dialog.title = new Flex(dialogFlex, title_x0, 0, title_x1, [0, topHeight], FONT_TITLE);
dialog.content = new Flex(dialogFlex, topHeight, [0.01, topHeight], 1 - topHeight, contentHeight);
} else {
dialog.title = new Flex(dialogFlex, title_x0, 0, title_x1, [0, topHeight], FONT_TITLE);
dialog.content = new Flex(dialogFlex, 0.02, [0.01, topHeight], 0.98, contentHeight);
}
if(params.scrollable) makeScrollable(dialog.content, false);
if(params.scrollable_canchange) makeScrollable(dialog.content, true);
centerText2(dialog.title.div);
dialog.titleEl = dialog.title.div.textEl;
if(params.title) {
dialog.title.div.textEl.innerText = params.title;
setAriaLabel(dialog.title.div, 'Dialog: ' + params.title);
}
if(params.icon) {
canvas = createCanvas('5%', '5%', '90%', '90%', dialog.icon.div);
renderImage(params.icon, canvas);
}
// allow giving undefined at input to set an extra function/name to be disabled, remove this from the array here
for(var i = 0; i < functions.length; i++) {
if(functions[i] == undefined || names[i] == undefined) {
functions.splice(i, 1);
names.splice(i, 1);
i--;
}
}
var num_buttons = functions.length + 1;
var buttonsize = 0.27;
if(num_buttons > 3) buttonsize = 0.247;
// the is_cancel is for positioning cancel as if it was first, when state.cancelbuttonright, given that this function is called last for the cancel button
var makeButton = function(is_cancel) {
var dialog;
if((!state || state.cancelbuttonright) != !!params.swapbuttons) {
var s = buttonshift;
if(is_cancel) {
s = 0;
} else {
s++;
}
dialog = (new Flex(dialogFlex, 1.0 - buttonsize * (s + 1), [1.0, -0.4 * buttonsize], 1.0 - 0.01 - buttonsize * s, [1.0, -0.01], FONT_DIALOG_BUTTON)).div;
} else {
dialog = (new Flex(dialogFlex, 1.0 - buttonsize * (buttonshift + 1), [1.0, -0.4 * buttonsize], 1.0 - 0.01 - buttonsize * buttonshift, [1.0, -0.01], FONT_DIALOG_BUTTON)).div;
}
buttonshift++;
return dialog;
};
var button;
var buttonshift = 0;
if(functions) {
for(var i = 0; i < functions.length; i++) {
button = makeButton(false);
if(!params.invbold || params.allbold) button.style.fontWeight = 'bold';
styleButton(button);
button.textEl.innerText = names[i];
var fun = functions[i];
registerAction(button, bind(function(fun, e) {
var keep = fun(e);
if(!keep) dialog.closeFun(false);
}, fun), names[i] + ' (dialog button)', {
tooltip:tooltips[i],
label_shift:(names_shift[i]),
label_ctrl:(names_ctrl[i]),
label_ctrl_shift:(names_ctrl_shift[i]),
});
}
}
if(!params.nocancel) {
button = makeButton(true);
if(params.invbold || params.allbold) button.style.fontWeight = 'bold';
styleButton(button);
var cancelname = params.cancelname || (functions.length > 0 ? 'cancel' : 'back');
button.textEl.innerText = cancelname;
addButtonAction(button, dialog.cancelFun, cancelname + ': dialog button');
}
var overlay = createOverlay(dialog_level * 10);
created_overlays.push(overlay);
if(!params.nobgclose) overlay.onclick = dialog.cancelFun;
window.setTimeout(function() {
showGoalChips(); // this ensures a faster response time for the display of red help arrows when dialogs are opening/closing, otherwise it only happens after a tick, which, even if sub-second, feels sluggish
});
xbutton.div.focus(); // focus an element of the dialog for aria
return dialog;
}
function createOverlay(zIndex) {
var overlay = makeDiv(0, 0, window.innerWidth, window.innerHeight);
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.35)';
overlay.style.position = 'fixed';
overlay.style.zIndex = '' + zIndex;
return overlay;
}
function closeAllDialogs() {
globalupdatedialogfun = undefined;
for(var i = 0; i < created_dialogs.length; i++) {
created_dialogs[i].removeSelfFun(true);
}
for(var i = 0; i < created_overlays.length; i++) {
util.removeElement(created_overlays[i]);
}
created_dialogs = [];
created_overlays = [];
dialog_level = 0;
// a tooltip created by an element from a dialog could remain, make sure those are removed too
removeAllTooltips();
updateDialogLevel();
}
// update other effects that are there due to the dialog level
function updateDialogLevel() {
// when using closeAllDialogs() and then the closing of a dialog itself activates as well due to button press, this may happen, fix it here
if(dialog_level < 0) dialog_level = 0;
if(dialog_level == 0) {
topDialogFlex.div.style.visibility = 'hidden';
makeAriaHidden(nonDialogFlex.div, false);
} else {
topDialogFlex.div.style.visibility = '';
makeAriaHidden(nonDialogFlex.div, true);
if(created_dialogs.length >= dialog_level) {
makeAriaHidden(created_dialogs[dialog_level - 1].flex.div, false);
}
if(dialog_level > 1) {
makeAriaHidden(created_dialogs[dialog_level - 2].flex.div, true);
}
}
}
// opt_cancel: boolean, whether this is an intential cancel (from the Escape key), so that the dialog's cancel function will be called as well, or a close for another programmatic reason where only closeFun of the dialog should be called.
function closeTopDialog(opt_cancel) {
if(created_dialogs && created_dialogs.length > 0) {
var dialog = created_dialogs[created_dialogs.length - 1];
if(opt_cancel) dialog.cancelFun();
else dialog.closeFun(opt_cancel);
}
}
// close all dialogs up to that level. E.g. if level is 1, 1 dialog will remain.
function closeDialogsUpTo(level) {
while(created_dialogs.length > level) {
closeTopDialog();
}
}
function makeAriaHidden(div, hidden) {
// the goal is to make all elements, recursively, of this element hidden, even any focusable elements inside, but have it be still visual (no "display none")
// this is for the background behind modal dialogs.
// aria-hidden seems to not really work as focusable elements will still be tabbable too, even when due to
// modal dialog these elements are not clickable for non screenreader users
// but the attribute 'inert' seems to work correctly
// also set aria-hidden anyway to really indicate it
if(hidden) {
div.setAttribute('aria-hidden', true);
div.setAttribute('inert', true);
} else {
div.setAttribute('aria-hidden', false);
div.removeAttribute('inert');
//div.setAttribute('inert', false);
}
}
document.addEventListener('keydown', function(e) {
if(dialogshortcutfun) {
if(dialog_level <= 0) {
dialogshortcutfun = undefined;
return;
}
dialogshortcutfun(e);
e.stopImmediatePropagation(); // if this shortcut caused the dialog to close (e.g. planting blueprint), ensure it stops propagating to the main game keyboard handler, which will think there's no dialog open and so could handle the shortcut a second time for a different non-dialog action
if(e.keyCode == 27 || e.code == 'Escape') {
if(dropdownEl) {
removeAllDropdownElements();
} else {
closeTopDialog(true);
}
}
}
});
// It matters whether there is a mouse pointer that can hover over things to
// show tooltips, or it's a touch device where you only get taps on a location
// without seeing a mouse pointer move there first.
// Note: it's not possible to use something like check whether or not onmouseover
// was called, because mobile browsers call onmouseover anyway
function isTouchDevice() {
return ('ontouchstart' in window) || (navigator.MaxTouchPoints > 0) || (navigator.msMaxTouchPoints > 0);
}
var globaltooltip;
// MOBILEMODE forces the mobile mode for tooltips by disabling tooltip hover (mouseover and mouseout) functions
var MOBILEMODE = false;
var updatetooltipfun = undefined; // must be called by the game update fun if set
function applyTooltipStyle(div, tooltipstyle) {
if(tooltipstyle == 0 || tooltipstyle == 1) {
div.style.backgroundColor = '#004';
div.style.color = '#fff';
div.style.border = '2px solid #fff';
} else if(tooltipstyle == 2) {
div.style.backgroundColor = '#ccce';
div.style.color = '#000';
div.style.border = '1px solid #000';
} else if(tooltipstyle == 4) {
div.style.backgroundColor = '#840e';
div.style.color = '#fff';
div.style.border = '2px solid #fff';
} else {
div.style.backgroundColor = '#0008';
div.style.color = '#fff';
div.style.border = '';
}
}
/*
tooltip for the desktop version. For mobile, may be able to show it through an indirect info button
el is HTML element to give tooltip
fun is function that gets the tooltip text, or text directly, or undefined to remove the tooltip (to unregister it)
opt_poll, if true, will make the tooltip dynamically update by calling fun again
opt_allowmobile, if true, then the tooltip will show when clicking the item. It will likely be too tiny though. Do not use if clicking the item already has some other action.
*/
function registerTooltip_(el, fun, opt_poll, opt_allowmobile) {
var istext = (typeof fun == 'string');
if((typeof fun == 'string') || !fun) fun = bind(function(text) { return text; }, fun);
// can't set this if opt_poll, fun() then most likely returns something incorrect now (e.g. on the field tiles)
if(!opt_poll) {
el.setAttribute('aria-description', fun());
if(!istext) el.onfocus = function() { el.setAttribute('aria-description', fun()); }; // dynamic (but without opt_poll), so update every now and then
}
var div = undefined;
var MOBILEMODE = isTouchDevice();
if(MOBILEMODE) {
removeAllTooltips();
return;
}
var init = function() {
el.tooltipfun = fun;
if(el.tooltipregistered && util.hasEvents(el)) {
// prevent keeping adding event listeners, and make sure re-calling registerTooltip is fast (can be done every frame), just update the minimum needed to change the text
// the reason for also having the "util.hasEvents" check us due to how with caching of elements and usage of util.cleanSlateElement, events may have been removed, but this internal "tooltipregistered" variable here is not known by that
return;
}
el.tooltipregistered = true;
util.setEvent(el, 'mouseover', function(e) {
if(e.shiftKey || eventHasCtrlKey(e)) return;
if(MOBILEMODE && !opt_allowmobile) return;
maketip(el.tooltipfun(), e, false);
}, 'tooltip');
// NOTE: mouseout unwantedly also triggers when over child elements of el (solved inside) or when over the tooltip itself (solved by making tooltip never overlap el)
util.setEvent(el, 'mouseout', function(e) {
if(MOBILEMODE && !opt_allowmobile) return;
// avoid the tooltip triggering many times while hovering over child nodes, which does cause mouseout events
var e_el = e.toElement || e.relatedTarget;
if(e_el == el) return;
if(!!e_el && e_el.parentNode == el) return;
if(!!e_el && !!e_el.parentNode && e_el.parentNode.parentNode == el) return;
if(!!e_el && !!e_el.parentNode && !!e_el.parentNode.parentNode && e_el.parentNode.parentNode.parentNode == el) return;
if(!!e_el && !!e_el.parentNode && !!e_el.parentNode.parentNode && !!e_el.parentNode.parentNode.parentNode && e_el.parentNode.parentNode.parentNode.parentNode == el) return;
if(e_el == div) return;
remtip();
}, 'tooltip');
};
var maketip = function(text, e, mobilemode) {
if(!state) return; // game not yet loaded
if(state.tooltipstyle == 0) {
removeAllTooltips();
return;
}
// already displaying
if(div && div == globaltooltip) return;
// if a tooltip somehow remained from elsewhere, remove it. Even if fun returned undefined (so we make no new tip), because any remaining one may be stray
if(globaltooltip) {
util.removeElement(globaltooltip);
globaltooltip = undefined;
updatetooltipfun = undefined;
}
if(text) {
var rect = el.getBoundingClientRect();
var tipx0 = rect.x;
var tipy0 = rect.y;
var tipx1 = rect.x + rect.width;
var tipy1 = rect.y + rect.height;
var x = e.clientX + 20;
// TODO: adjust y position such that tooltip does not appear over the element itself, only below or above (do not cover it)
var y = Math.max(e.clientY + 20, tipy1);
// NOTE: the div has document.body as parent, not el, otherwise it gets affected by styles of el (such as darkening on mouseover, ...)
///div = util.makeElementAt('div', x, y, document.body); // give some shift. Note that if tooltip appears under mousebutton, it will trigger mouseout and cause flicker... so TODO: make sure it never goes under mouse cursor
div = document.createElement('div');
div.style.position = 'fixed'; // make the x,y coordinats relative to whole window so that the coordinates match the mouse position
globaltooltip = div;
// no width or hight set on the div: make it automatically match the size of the text. But the maxWidth ensures it won't get too wide in case of long text without newlines.
///div.style.maxWidth = mainFlex.div.clientWidth + 'px';
applyTooltipStyle(div, state.tooltipstyle);
div.style.padding = '4px';
div.style.zIndex = '999';
div.style.lineHeight = 'normal';
div.style.textAlign = 'left';
div.style.verticalAlign = 'top';
//div.style.fontSize = '150%';
div.style.fontSize = '';
var textel = util.makeElementAt('span', 0, 0, div);
textel.style.position = ''; // not absolute, so that the parent div will grow its size to fit this one.
textel.innerHTML = text;
document.body.appendChild(div);
var tw = div.clientWidth;
tw += 16; // make it a bit bigger, sometimes the clientWidth computation is slightly too small and the text will wrap the last word anyway
var maxw = Math.max(300, Math.floor(mainFlex.div.clientWidth * 0.3));
if(text.length > 100) maxw = Math.max(400, Math.floor(mainFlex.div.clientWidth * 0.4));
var maxr = mainFlex.div.clientWidth;
if(tw > maxw) tw = maxw;
if(x + maxw > maxr) x = maxr - maxw;
div.style.left = x + 'px';
div.style.top = y + 'px';
div.style.width = tw + 'px';