xref: /webtrees/resources/js/webtrees.js (revision ba1d7494e9648df5a87aaa3fece3e098e5ce26b1)
1/**
2 * webtrees: online genealogy
3 * Copyright (C) 2019 webtrees development team
4 * This program is free software: you can redistribute it and/or modify
5 * it under the terms of the GNU General Public License as published by
6 * the Free Software Foundation, either version 3 of the License, or
7 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 * You should have received a copy of the GNU General Public License
13 * along with this program. If not, see <http://www.gnu.org/licenses/>.
14 */
15
16'use strict';
17
18let webtrees = function () {
19    const lang = document.documentElement.lang;
20
21    /**
22     * Tidy the whitespace in a string.
23     */
24    function trim(str) {
25        return str.replace(/\s+/g, " ").trim();
26
27    }
28
29    /**
30     * Look for non-latin characters in a string.
31     */
32    function detectScript(str) {
33        if (str.match(/[\u3400-\u9FCC]/)) {
34            return "cjk";
35        } else if (str.match(/[\u0370-\u03FF]/)) {
36            return "greek";
37        } else if (str.match(/[\u0400-\u04FF]/)) {
38            return "cyrillic";
39        } else if (str.match(/[\u0590-\u05FF]/)) {
40            return "hebrew";
41        } else if (str.match(/[\u0600-\u06FF]/)) {
42            return "arabic";
43        }
44
45        return "latin";
46    }
47
48    /**
49     * In some languages, the SURN uses a male/default form, but NAME uses a gender-inflected form.
50     */
51    function inflectSurname(surname, sex) {
52        if (lang === "pl" && sex === "F") {
53            return surname
54                .replace(/ski$/, "ska")
55                .replace(/cki$/, "cka")
56                .replace(/dzki$/, "dzka")
57                .replace(/żki$/, "żka");
58        }
59
60        return surname;
61    }
62
63    /**
64     * Build a NAME from a NPFX, GIVN, SPFX, SURN and NSFX parts.
65     *
66     * Assumes the language of the document is the same as the language of the name.
67     */
68    function buildNameFromParts(npfx, givn, spfx, surn, nsfx, sex) {
69        const usesCJK      = detectScript(npfx + givn + spfx + givn + surn + nsfx) === "cjk";
70        const separator    = usesCJK ? "" : " ";
71        const surnameFirst = usesCJK || ['hu', 'jp', 'ko', 'vi', 'zh-Hans', 'zh-Hant'].indexOf(lang) !== -1;
72        const patronym     = ['is'].indexOf(lang) !== -1;
73        const slash        = patronym ? "" : "/";
74
75        // GIVN and SURN may be a comma-separated lists.
76        npfx = trim(npfx);
77        givn = trim(givn.replace(",", separator));
78        spfx = trim(spfx);
79        surn = inflectSurname(trim(surn.replace(",", separator)), sex);
80        nsfx = trim(nsfx);
81
82        const surname = trim(spfx + separator + surn);
83
84        const name = surnameFirst ? slash + surname + slash + separator + givn : givn + separator + slash + surname + slash;
85
86        return trim(npfx + separator + name + separator + nsfx);
87    }
88
89    // Public methods
90    return {
91        buildNameFromParts: buildNameFromParts,
92        detectScript:       detectScript,
93    };
94}();
95
96function expand_layer(sid)
97{
98    $('#' + sid + '_img').toggleClass('icon-plus icon-minus');
99    $('#' + sid).slideToggle('fast');
100    $('#' + sid + '-alt').toggle(); // hide something when we show the layer - and vice-versa
101    return false;
102}
103
104var pastefield;
105function addmedia_links(field, iid, iname)
106{
107    pastefield = field;
108    insertRowToTable(iid, iname);
109    return false;
110}
111
112function valid_date(datefield, dmy)
113{
114    var months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
115    var hijri_months = ['MUHAR', 'SAFAR', 'RABIA', 'RABIT', 'JUMAA', 'JUMAT', 'RAJAB', 'SHAAB', 'RAMAD', 'SHAWW', 'DHUAQ', 'DHUAH'];
116    var hebrew_months = ['TSH', 'CSH', 'KSL', 'TVT', 'SHV', 'ADR', 'ADS', 'NSN', 'IYR', 'SVN', 'TMZ', 'AAV', 'ELL'];
117    var french_months = ['VEND', 'BRUM', 'FRIM', 'NIVO', 'PLUV', 'VENT', 'GERM', 'FLOR', 'PRAI', 'MESS', 'THER', 'FRUC', 'COMP'];
118    var jalali_months = ['FARVA', 'ORDIB', 'KHORD', 'TIR', 'MORDA', 'SHAHR', 'MEHR', 'ABAN', 'AZAR', 'DEY', 'BAHMA', 'ESFAN'];
119
120    var datestr = datefield.value;
121  // if a date has a date phrase marked by () this has to be excluded from altering
122    var datearr = datestr.split('(');
123    var datephrase = '';
124    if (datearr.length > 1) {
125        datestr = datearr[0];
126        datephrase = datearr[1];
127    }
128
129  // Gedcom dates are upper case
130    datestr = datestr.toUpperCase();
131  // Gedcom dates have no leading/trailing/repeated whitespace
132    datestr = datestr.replace(/\s+/, ' ');
133    datestr = datestr.replace(/(^\s)|(\s$)/, '');
134  // Gedcom dates have spaces between letters and digits, e.g. "01JAN2000" => "01 JAN 2000"
135    datestr = datestr.replace(/(\d)([A-Z])/, '$1 $2');
136    datestr = datestr.replace(/([A-Z])(\d)/, '$1 $2');
137
138  // Shortcut for quarter format, "Q1 1900" => "BET JAN 1900 AND MAR 1900". See [ 1509083 ]
139    if (datestr.match(/^Q ([1-4]) (\d\d\d\d)$/)) {
140        datestr = 'BET ' + months[RegExp.$1 * 3 - 3] + ' ' + RegExp.$2 + ' AND ' + months[RegExp.$1 * 3 - 1] + ' ' + RegExp.$2;
141    }
142
143  // Shortcut for @#Dxxxxx@ 01 01 1400, etc.
144    if (datestr.match(/^(@#DHIJRI@|HIJRI)( \d?\d )(\d?\d)( \d?\d?\d?\d)$/)) {
145        datestr = '@#DHIJRI@' + RegExp.$2 + hijri_months[parseInt(RegExp.$3, 10) - 1] + RegExp.$4;
146    }
147    if (datestr.match(/^(@#DJALALI@|JALALI)( \d?\d )(\d?\d)( \d?\d?\d?\d)$/)) {
148        datestr = '@#DJALALI@' + RegExp.$2 + jalali_months[parseInt(RegExp.$3, 10) - 1] + RegExp.$4;
149    }
150    if (datestr.match(/^(@#DHEBREW@|HEBREW)( \d?\d )(\d?\d)( \d?\d?\d?\d)$/)) {
151        datestr = '@#DHEBREW@' + RegExp.$2 + hebrew_months[parseInt(RegExp.$3, 10) - 1] + RegExp.$4;
152    }
153    if (datestr.match(/^(@#DFRENCH R@|FRENCH)( \d?\d )(\d?\d)( \d?\d?\d?\d)$/)) {
154        datestr = '@#DFRENCH R@' + RegExp.$2 + french_months[parseInt(RegExp.$3, 10) - 1] + RegExp.$4;
155    }
156
157  // e.g. 17.11.1860, 03/04/2005 or 1999-12-31. Use locale settings where DMY order is ambiguous.
158    var qsearch = /^([^\d]*)(\d+)[^\d](\d+)[^\d](\d+)$/i;
159    if (qsearch.exec(datestr)) {
160        var f0 = RegExp.$1;
161        var f1 = parseInt(RegExp.$2, 10);
162        var f2 = parseInt(RegExp.$3, 10);
163        var f3 = parseInt(RegExp.$4, 10);
164        var yyyy = new Date().getFullYear();
165        var yy = yyyy % 100;
166        var cc = yyyy - yy;
167        if (dmy === 'DMY' && f1 <= 31 && f2 <= 12 || f1 > 13 && f1 <= 31 && f2 <= 12 && f3 > 31) {
168            datestr = f0 + f1 + ' ' + months[f2 - 1] + ' ' + (f3 >= 100 ? f3 : (f3 <= yy ? f3 + cc : f3 + cc - 100));
169        } else {
170            if (dmy === 'MDY' && f1 <= 12 && f2 <= 31 || f2 > 13 && f2 <= 31 && f1 <= 12 && f3 > 31) {
171                datestr = f0 + f2 + ' ' + months[f1 - 1] + ' ' + (f3 >= 100 ? f3 : (f3 <= yy ? f3 + cc : f3 + cc - 100));
172            } else {
173                if (dmy === 'YMD' && f2 <= 12 && f3 <= 31 || f3 > 13 && f3 <= 31 && f2 <= 12 && f1 > 31) {
174                    datestr = f0 + f3 + ' ' + months[f2 - 1] + ' ' + (f1 >= 100 ? f1 : (f1 <= yy ? f1 + cc : f1 + cc - 100));
175                }
176            }
177        }
178    }
179
180  // Shortcuts for date ranges
181    datestr = datestr.replace(/^[>]([\w ]+)$/, 'AFT $1');
182    datestr = datestr.replace(/^[<]([\w ]+)$/, 'BEF $1');
183    datestr = datestr.replace(/^([\w ]+)[-]$/, 'FROM $1');
184    datestr = datestr.replace(/^[-]([\w ]+)$/, 'TO $1');
185    datestr = datestr.replace(/^[~]([\w ]+)$/, 'ABT $1');
186    datestr = datestr.replace(/^[*]([\w ]+)$/, 'EST $1');
187    datestr = datestr.replace(/^[#]([\w ]+)$/, 'CAL $1');
188    datestr = datestr.replace(/^([\w ]+) ?- ?([\w ]+)$/, 'BET $1 AND $2');
189    datestr = datestr.replace(/^([\w ]+) ?~ ?([\w ]+)$/, 'FROM $1 TO $2');
190
191  // Convert full months to short months
192    datestr = datestr.replace(/(JANUARY)/, 'JAN');
193    datestr = datestr.replace(/(FEBRUARY)/, 'FEB');
194    datestr = datestr.replace(/(MARCH)/, 'MAR');
195    datestr = datestr.replace(/(APRIL)/, 'APR');
196    datestr = datestr.replace(/(MAY)/, 'MAY');
197    datestr = datestr.replace(/(JUNE)/, 'JUN');
198    datestr = datestr.replace(/(JULY)/, 'JUL');
199    datestr = datestr.replace(/(AUGUST)/, 'AUG');
200    datestr = datestr.replace(/(SEPTEMBER)/, 'SEP');
201    datestr = datestr.replace(/(OCTOBER)/, 'OCT');
202    datestr = datestr.replace(/(NOVEMBER)/, 'NOV');
203    datestr = datestr.replace(/(DECEMBER)/, 'DEC');
204
205  // Americans frequently enter dates as SEP 20, 1999
206  // No need to internationalise this, as this is an english-language issue
207    datestr = datestr.replace(/(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)\.? (\d\d?)[, ]+(\d\d\d\d)/, '$2 $1 $3');
208
209  // Apply leading zero to day numbers
210    datestr = datestr.replace(/(^| )(\d [A-Z]{3,5} \d{4})/, '$10$2');
211
212    if (datephrase) {
213        datestr = datestr + ' (' + datephrase;
214    }
215  // Only update it if is has been corrected - otherwise input focus
216  // moves to the end of the field unnecessarily
217    if (datefield.value !== datestr) {
218        datefield.value = datestr;
219    }
220}
221
222var monthLabels = [];
223monthLabels[1] = 'January';
224monthLabels[2] = 'February';
225monthLabels[3] = 'March';
226monthLabels[4] = 'April';
227monthLabels[5] = 'May';
228monthLabels[6] = 'June';
229monthLabels[7] = 'July';
230monthLabels[8] = 'August';
231monthLabels[9] = 'September';
232monthLabels[10] = 'October';
233monthLabels[11] = 'November';
234monthLabels[12] = 'December';
235
236var monthShort = [];
237monthShort[1] = 'JAN';
238monthShort[2] = 'FEB';
239monthShort[3] = 'MAR';
240monthShort[4] = 'APR';
241monthShort[5] = 'MAY';
242monthShort[6] = 'JUN';
243monthShort[7] = 'JUL';
244monthShort[8] = 'AUG';
245monthShort[9] = 'SEP';
246monthShort[10] = 'OCT';
247monthShort[11] = 'NOV';
248monthShort[12] = 'DEC';
249
250var daysOfWeek = [];
251daysOfWeek[0] = 'S';
252daysOfWeek[1] = 'M';
253daysOfWeek[2] = 'T';
254daysOfWeek[3] = 'W';
255daysOfWeek[4] = 'T';
256daysOfWeek[5] = 'F';
257daysOfWeek[6] = 'S';
258
259var weekStart = 0;
260
261function cal_setMonthNames(jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec)
262{
263    monthLabels[1] = jan;
264    monthLabels[2] = feb;
265    monthLabels[3] = mar;
266    monthLabels[4] = apr;
267    monthLabels[5] = may;
268    monthLabels[6] = jun;
269    monthLabels[7] = jul;
270    monthLabels[8] = aug;
271    monthLabels[9] = sep;
272    monthLabels[10] = oct;
273    monthLabels[11] = nov;
274    monthLabels[12] = dec;
275}
276
277function cal_setDayHeaders(sun, mon, tue, wed, thu, fri, sat)
278{
279    daysOfWeek[0] = sun;
280    daysOfWeek[1] = mon;
281    daysOfWeek[2] = tue;
282    daysOfWeek[3] = wed;
283    daysOfWeek[4] = thu;
284    daysOfWeek[5] = fri;
285    daysOfWeek[6] = sat;
286}
287
288function cal_setWeekStart(day)
289{
290    if (day >= 0 && day < 7) {
291        weekStart = day;
292    }
293}
294
295function calendarWidget(dateDivId, dateFieldId)
296{
297    var dateDiv = document.getElementById(dateDivId);
298    var dateField = document.getElementById(dateFieldId);
299
300    if (dateDiv.style.visibility === 'visible') {
301        dateDiv.style.visibility = 'hidden';
302        return false;
303    }
304    if (dateDiv.style.visibility === 'show') {
305        dateDiv.style.visibility = 'hide';
306        return false;
307    }
308
309  /* Javascript calendar functions only work with precise gregorian dates "D M Y" or "Y" */
310    var greg_regex = /((\d+ (JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC) )?\d+)/i;
311    var date;
312    if (greg_regex.exec(dateField.value)) {
313        date = new Date(RegExp.$1);
314    } else {
315        date = new Date();
316    }
317
318    dateDiv.innerHTML = cal_generateSelectorContent(dateFieldId, dateDivId, date);
319    if (dateDiv.style.visibility === 'hidden') {
320        dateDiv.style.visibility = 'visible';
321        return false;
322    }
323    if (dateDiv.style.visibility === 'hide') {
324        dateDiv.style.visibility = 'show';
325        return false;
326    }
327
328    return false;
329}
330
331function cal_generateSelectorContent(dateFieldId, dateDivId, date)
332{
333    var i, j;
334    var content = '<table border="1"><tr>';
335    content += '<td><select class="form-control" id="' + dateFieldId + '_daySelect" onchange="return cal_updateCalendar(\'' + dateFieldId + '\', \'' + dateDivId + '\');">';
336    for (i = 1; i < 32; i++) {
337        content += '<option value="' + i + '"';
338        if (date.getDate() === i) {
339            content += ' selected="selected"';
340        }
341        content += '>' + i + '</option>';
342    }
343    content += '</select></td>';
344    content += '<td><select class="form-control" id="' + dateFieldId + '_monSelect" onchange="return cal_updateCalendar(\'' + dateFieldId + '\', \'' + dateDivId + '\');">';
345    for (i = 1; i < 13; i++) {
346        content += '<option value="' + i + '"';
347        if (date.getMonth() + 1 === i) {
348            content += ' selected="selected"';
349        }
350        content += '>' + monthLabels[i] + '</option>';
351    }
352    content += '</select></td>';
353    content += '<td><input class="form-control" type="text" id="' + dateFieldId + '_yearInput" size="5" value="' + date.getFullYear() + '" onchange="return cal_updateCalendar(\'' + dateFieldId + '\', \'' + dateDivId + '\');" /></td></tr>';
354    content += '<tr><td colspan="3">';
355    content += '<table width="100%">';
356    content += '<tr>';
357    j = weekStart;
358    for (i = 0; i < 7; i++) {
359        content += '<td ';
360        content += 'class="descriptionbox"';
361        content += '>';
362        content += daysOfWeek[j];
363        content += '</td>';
364        j++;
365        if (j > 6) {
366            j = 0;
367        }
368    }
369    content += '</tr>';
370
371    var tdate = new Date(date.getFullYear(), date.getMonth(), 1);
372    var day = tdate.getDay();
373    day = day - weekStart;
374    var daymilli = 1000 * 60 * 60 * 24;
375    tdate = tdate.getTime() - (day * daymilli) + (daymilli / 2);
376    tdate = new Date(tdate);
377
378    for (j = 0; j < 6; j++) {
379        content += '<tr>';
380        for (i = 0; i < 7; i++) {
381            content += '<td ';
382            if (tdate.getMonth() === date.getMonth()) {
383                if (tdate.getDate() === date.getDate()) {
384                    content += 'class="descriptionbox"';
385                } else {
386                    content += 'class="optionbox"';
387                }
388            } else {
389                content += 'style="background-color:#EAEAEA; border: solid #AAAAAA 1px;"';
390            }
391            content += '><a href="#" onclick="return cal_dateClicked(\'' + dateFieldId + '\', \'' + dateDivId + '\', ' + tdate.getFullYear() + ', ' + tdate.getMonth() + ', ' + tdate.getDate() + ');">';
392            content += tdate.getDate();
393            content += '</a></td>';
394            var datemilli = tdate.getTime() + daymilli;
395            tdate = new Date(datemilli);
396        }
397        content += '</tr>';
398    }
399    content += '</table>';
400    content += '</td></tr>';
401    content += '</table>';
402
403    return content;
404}
405
406function cal_setDateField(dateFieldId, year, month, day)
407{
408    var dateField = document.getElementById(dateFieldId);
409    if (!dateField) {
410        return false;
411    }
412    if (day < 10) {
413        day = '0' + day;
414    }
415    dateField.value = day + ' ' + monthShort[month + 1] + ' ' + year;
416    return false;
417}
418
419function cal_updateCalendar(dateFieldId, dateDivId)
420{
421    var dateSel = document.getElementById(dateFieldId + '_daySelect');
422    if (!dateSel) {
423        return false;
424    }
425    var monthSel = document.getElementById(dateFieldId + '_monSelect');
426    if (!monthSel) {
427        return false;
428    }
429    var yearInput = document.getElementById(dateFieldId + '_yearInput');
430    if (!yearInput) {
431        return false;
432    }
433
434    var month = parseInt(monthSel.options[monthSel.selectedIndex].value, 10);
435    month = month - 1;
436
437    var date = new Date(yearInput.value, month, dateSel.options[dateSel.selectedIndex].value);
438    cal_setDateField(dateFieldId, date.getFullYear(), date.getMonth(), date.getDate());
439
440    var dateDiv = document.getElementById(dateDivId);
441    if (!dateDiv) {
442        alert('no dateDiv ' + dateDivId);
443        return false;
444    }
445    dateDiv.innerHTML = cal_generateSelectorContent(dateFieldId, dateDivId, date);
446
447    return false;
448}
449
450function cal_dateClicked(dateFieldId, dateDivId, year, month, day)
451{
452    cal_setDateField(dateFieldId, year, month, day);
453    calendarWidget(dateDivId, dateFieldId);
454    return false;
455}
456
457function openerpasteid(id)
458{
459    if (window.opener.paste_id) {
460        window.opener.paste_id(id);
461    }
462    window.close();
463}
464
465function paste_id(value)
466{
467    pastefield.value = value;
468}
469
470function pastename(name)
471{
472    if (nameElement) {
473        nameElement.innerHTML = name;
474    }
475    if (remElement) {
476        remElement.style.display = 'block';
477    }
478}
479
480function paste_char(value)
481{
482    if (document.selection) {
483      // IE
484        pastefield.focus();
485        document.selection.createRange().text = value;
486    } else if (pastefield.selectionStart || pastefield.selectionStart === 0) {
487      // Mozilla/Chrome/Safari
488        pastefield.value =
489        pastefield.value.substring(0, pastefield.selectionStart) +
490        value +
491        pastefield.value.substring(pastefield.selectionEnd, pastefield.value.length);
492        pastefield.selectionStart = pastefield.selectionEnd = pastefield.selectionStart + value.length;
493    } else {
494      // Fallback? - just append
495        pastefield.value += value;
496    }
497
498    if (pastefield.id === 'NPFX' || pastefield.id === 'GIVN' || pastefield.id === 'SPFX' || pastefield.id === 'SURN' || pastefield.id === 'NSFX') {
499        updatewholename();
500    }
501}
502
503/**
504 * Persistant checkbox options to hide/show extra data.
505
506 * @param element_id
507 */
508function persistent_toggle(element_id)
509{
510    let element = document.getElementById(element_id);
511    let key     = 'state-of-' + element_id;
512    let state   = localStorage.getItem(key);
513
514    // Previously selected?
515    if (state === 'true') {
516        $(element).click();
517    }
518
519    // Remember state for the next page load.
520    $(element).on('change', function() { localStorage.setItem(key, element.checked); });
521}
522
523function valid_lati_long(field, pos, neg)
524{
525  // valid LATI or LONG according to Gedcom standard
526  // pos (+) : N or E
527  // neg (-) : S or W
528    var txt = field.value.toUpperCase();
529    txt = txt.replace(/(^\s*)|(\s*$)/g, ''); // trim
530    txt = txt.replace(/ /g, ':'); // N12 34 ==> N12.34
531    txt = txt.replace(/\+/g, ''); // +17.1234 ==> 17.1234
532    txt = txt.replace(/-/g, neg); // -0.5698 ==> W0.5698
533    txt = txt.replace(/,/g, '.'); // 0,5698 ==> 0.5698
534  // 0°34'11 ==> 0:34:11
535    txt = txt.replace(/\u00b0/g, ':'); // °
536    txt = txt.replace(/\u0027/g, ':'); // '
537  // 0:34:11.2W ==> W0.5698
538    txt = txt.replace(/^([0-9]+):([0-9]+):([0-9.]+)(.*)/g, function ($0, $1, $2, $3, $4) {
539        var n = parseFloat($1);
540        n += ($2 / 60);
541        n += ($3 / 3600);
542        n = Math.round(n * 1E4) / 1E4;
543        return $4 + n;
544    });
545  // 0:34W ==> W0.5667
546    txt = txt.replace(/^([0-9]+):([0-9]+)(.*)/g, function ($0, $1, $2, $3) {
547        var n = parseFloat($1);
548        n += ($2 / 60);
549        n = Math.round(n * 1E4) / 1E4;
550        return $3 + n;
551    });
552  // 0.5698W ==> W0.5698
553    txt = txt.replace(/(.*)([N|S|E|W]+)$/g, '$2$1');
554  // 17.1234 ==> N17.1234
555    if (txt && txt.charAt(0) !== neg && txt.charAt(0) !== pos) {
556        txt = pos + txt;
557    }
558    field.value = txt;
559}
560
561// This is the default way for webtrees to show image galleries.
562// Custom themes may use a different viewer.
563function activate_colorbox(config)
564{
565    $.extend($.colorbox.settings, {
566      // Don't scroll window with document
567        fixed: true,
568        current: '',
569        previous: '\uf048',
570        next: '\uf051',
571        slideshowStart: '\uf04b',
572        slideshowStop: '\uf04c',
573        close: '\uf00d'
574    });
575    if (config) {
576        $.extend($.colorbox.settings, config);
577    }
578
579  // Trigger an event when we click on an (any) image
580    $('body').on('click', 'a.gallery', function () {
581      // Enable colorbox for images
582        $('a[type^=image].gallery').colorbox({
583            photo: true,
584            maxWidth: '95%',
585            maxHeight: '95%',
586            rel: 'gallery', // Turn all images on the page into a slideshow
587            slideshow: true,
588            slideshowAuto: false,
589          // Add wheelzoom to the displayed image
590            onComplete: function () {
591              // Disable click on image triggering next image
592              // https://github.com/jackmoore/colorbox/issues/668
593                $('.cboxPhoto').unbind('click');
594
595                wheelzoom(document.querySelectorAll('.cboxPhoto'));
596            }
597        });
598
599      // Enable colorbox for audio using <audio></audio>, where supported
600      // $('html.video a[type^=video].gallery').colorbox({
601      //  rel:         'nofollow' // Slideshows are just for images
602      // });
603
604      // Enable colorbox for video using <video></video>, where supported
605      // $('html.audio a[type^=audio].gallery').colorbox({
606      //  rel:         'nofollow', // Slideshows are just for images
607      // });
608
609      // Allow all other media types remain as download links
610    });
611}
612
613// Initialize autocomplete elements.
614function autocomplete(selector)
615{
616  // Use typeahead/bloodhound for autocomplete
617    $(selector).each(function () {
618        let that = this;
619        $(this).typeahead(null, {
620            display: 'value',
621            source: new Bloodhound({
622                datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'),
623                queryTokenizer: Bloodhound.tokenizers.whitespace,
624                remote: {
625                    url: this.dataset.autocompleteUrl,
626                    replace: function(url, uriEncodedQuery) {
627                        if (that.dataset.autocompleteExtra) {
628                            let extra = $(document.querySelector(that.dataset.autocompleteExtra)).val();
629                            return url.replace("QUERY",uriEncodedQuery) + '&extra=' + encodeURIComponent(extra)
630                        }
631                        return url.replace("QUERY",uriEncodedQuery);
632                    },
633                    wildcard: 'QUERY',
634
635                }
636            })
637        });
638    });
639}
640
641/**
642 * Insert text at the current cursor position in an input field.
643 *
644 * @param e The input element.
645 * @param t The text to insert.
646 */
647function insertTextAtCursor(e, t)
648{
649    var scrollTop = e.scrollTop;
650    var selectionStart = e.selectionStart;
651    var prefix = e.value.substring(0, selectionStart);
652    var suffix = e.value.substring(e.selectionEnd, e.value.length);
653    e.value = prefix + t + suffix;
654    e.selectionStart = selectionStart + t.length;
655    e.selectionEnd = e.selectionStart;
656    e.focus();
657    e.scrollTop = scrollTop;
658}
659
660// Send the CSRF token on all AJAX requests
661$.ajaxSetup({
662    headers: {
663        'X-CSRF-TOKEN': $('meta[name=csrf]').attr('content')
664    }
665});
666
667// Initialisation
668$(function () {
669  // Page elements that load automaticaly via AJAX.
670  // This prevents bad robots from crawling resource-intensive pages.
671    $("[data-ajax-url]").each(function () {
672        $(this).load($(this).data('ajaxUrl'));
673    });
674
675  // Select2 - format entries in the select list
676    function templateOptionForSelect2(data)
677    {
678        if (data.loading) {
679          // If we're waiting for the server, this will be a "waiting..." message
680            return data.text;
681        } else {
682          // The response from the server is already in HTML, so no need to format it here.
683            return data.text;
684        }
685    }
686
687    // Autocomplete
688    autocomplete('input[data-autocomplete-url]');
689
690    // Select2 - activate autocomplete fields
691    const lang = document.documentElement.lang;
692    const select2_languages = {
693        'zh-Hans': 'zh-CN',
694        'zh-Hant': 'zh-TW',
695    };
696    $("select.select2").select2({
697        language: select2_languages[lang] || lang,
698        // Do not escape - we do it on the server.
699        escapeMarkup: function (x) {
700            return x;
701        },
702    });
703
704    // If we clear the select (using the "X" button), we need an empty value
705    // (rather than no value at all) for (non-multiple) selects with name="array[]"
706    $("select.select2:not([multiple])")
707    .on("select2:unselect", function (evt) {
708        $(evt.delegateTarget).html("<option value=\"\" selected></option>");
709    });
710
711    // Datatables - locale aware sorting
712    $.fn.dataTableExt.oSort['text-asc'] = function (x, y) {
713        return x.localeCompare(y, document.documentElement.lang, {'sensitivity': 'base'});
714    };
715    $.fn.dataTableExt.oSort['text-desc'] = function (x, y) {
716        return y.localeCompare(x, document.documentElement.lang, {'sensitivity': 'base'});
717    };
718
719  // DataTables - start hidden to prevent FOUC.
720    $('table.datatables').each(function () {
721        $(this).DataTable(); $(this).removeClass('d-none'); });
722
723  // Activate the on-screen keyboard
724    var osk_focus_element;
725    $('.wt-osk-trigger').click(function () {
726      // When a user clicks the icon, set focus to the corresponding input
727        osk_focus_element = document.getElementById($(this).data('id'));
728        osk_focus_element.focus();
729        $('.wt-osk').show();
730
731    });
732
733    $('.wt-osk-script-button').change(function () {
734        $('.wt-osk-script').prop('hidden', true);
735        $('.wt-osk-script-' + $(this).data('script')).prop('hidden', false);
736    });
737    $('.wt-osk-shift-button').click(function () {
738        document.querySelector('.wt-osk-keys').classList.toggle('shifted');
739    });
740    $('.wt-osk-keys').on('click', '.wt-osk-key', function () {
741        var key = $(this).contents().get(0).nodeValue;
742        var shift_state = $('.wt-osk-shift-button').hasClass('active');
743        var shift_key = $('sup', this)[0];
744        if (shift_state && shift_key !== undefined) {
745            key = shift_key.innerText;
746        }
747        if (osk_focus_element !== null) {
748            var cursorPos = osk_focus_element.selectionStart;
749            var v = osk_focus_element.value;
750            var textBefore = v.substring(0, cursorPos);
751            var textAfter  = v.substring(cursorPos, v.length);
752            osk_focus_element.value = textBefore + key + textAfter;
753            if ($('.wt-osk-pin-button').hasClass('active') === false) {
754                $('.wt-osk').hide();
755            }
756        }
757    });
758
759    $('.wt-osk-close').on('click', function () {
760        $('.wt-osk').hide();
761    });
762});
763
764// Convert data-confirm and data-post-url attributes into useful behavior.
765document.addEventListener("click",  (event) => {
766    const target = event.target.closest("a");
767
768    if (target === null) {
769        return;
770    }
771
772    if ("confirm" in target.dataset && !confirm(target.dataset.confirm)) {
773        event.preventDefault();
774        return;
775    }
776
777    if ("postUrl" in target.dataset) {
778        let request = new XMLHttpRequest();
779        let token   = document.querySelector("meta[name=csrf]").content;
780        request.open("POST", target.dataset.postUrl, true);
781        request.setRequestHeader("X-CSRF-TOKEN", token);
782        request.onreadystatechange = () => {
783            if (request.readyState === request.DONE) {
784                document.location.reload();
785            }
786        };
787        request.send();
788        event.preventDefault();
789    }
790});
791