blob: b5c20432ce0fce1524bd906a30006304f6533c3c [file] [log] [blame]
Nils Diewald19ccee92014-12-08 11:30:08 +00001/**
2 * Context aware drop down menu
3 * for annotation related query hints.
4 *
5 * @author Nils Diewald
6 */
7
8// - http://www.cryer.co.uk/resources/javascript/script20_respond_to_keypress.htm
9// - https://developers.google.com/closure/compiler/docs/js-for-compiler
10// TODO:
11// - Add help option that opens the tutorial, e.g. to the foundry
12// - http://en.wikipedia.org/wiki/JSDoc
13
Nils Diewald46e6f182015-02-04 15:22:49 +000014// The last entry types (foundry, foundry/layer) is remembered and chosen by default
15
16// Show the context at the top as breadcrumbs
17// Highlight the context in the query (probably)
18// Support backspace for removing the last prefix
19
20/*
21Alternative: Use right arrow for temporary context switch and arrow back
22for temporary context removal
23*/
24
Nils Diewald19ccee92014-12-08 11:30:08 +000025/**
26 * The KorAP namespace for project related scripts
27 * @namespace
28 */
29var KorAP = KorAP || {};
30
31/*
32 this._search.addEventListener(
33 "keyup",
34 function () {
35 that.update();
36 },
37 false
38 );
39*/
40
41(function (KorAP) {
42 "use strict";
43
44 // Don't let events bubble up
Nils Diewald6ac292b2015-01-15 21:33:21 +000045 if (Event.halt === undefined) {
Nils Diewald19ccee92014-12-08 11:30:08 +000046 Event.prototype.halt = function () {
47 this.stopPropagation();
48 this.preventDefault();
49 };
50 };
51
52 // Default log message
Nils Diewalddc3862c2014-12-16 02:44:59 +000053 KorAP.log = KorAP.log || function (type, msg) {
Nils Diewald19ccee92014-12-08 11:30:08 +000054 console.log(type + ": " + msg);
55 };
56
57 /* TODO: Add event listener on windows resize to change that! */
58
59 /** @define {number} Limited view of menu items */
60 KorAP.limit = 8;
61
62 /**
63 * @define {regex} Regular expression for context
64 */
65 KorAP.context =
66 "(?:^|[^-_a-zA-Z0-9])" + // Anchor
67 "((?:[-_a-zA-Z0-9]+?)\/" + // Foundry
68 "(?:" +
69 "(?:[-_a-zA-Z0-9]+?)=" + // Layer
70 "(?:(?:[^:=\/ ]+?):)?" + // Key
71 ")?" +
72 ")$";
73
74 // Initialize hint array
75 KorAP.hintArray = KorAP.hintArray || {};
76
77
78 KorAP.updateKeyDown = function (event) {
79 };
80
81 KorAP.InputField = {
82 create : function (element) {
83 return Object.create(KorAP.InputField)._init(element);
84 },
85 _init : function (element) {
86 this._element = element;
87
88 // Create mirror for searchField
89 if ((this._mirror = document.getElementById("searchMirror")) === null) {
90 this._mirror = document.createElement("div");
91 this._mirror.setAttribute("id", "searchMirror");
92 this._mirror.appendChild(document.createElement("span"));
93 this._mirror.style.height = "1px";
94 document.getElementsByTagName("body")[0].appendChild(this._mirror);
95 };
96
97 // Update position of the mirror
98 var that = this;
99 window.resize = function () {
100 that.reposition();
101 };
102/*
103 // Add event listener for key down
104 element.addEventListener(
105 "keydown",
106 function (e) {
107// KorAP.updateKeyDown(e).bind(that)
108 },
109 false
110 );
111*/
112 return this;
113 },
114 get mirror () {
115 return this._mirror;
116 },
117 get element () {
118 return this._element;
119 },
120 get value () {
121 return this._element.value;
122 },
123 update : function () {
124 this._mirror.firstChild.textContent = this.split()[0];
125 },
126 insert : function (text) {
127 var splittedText = this.split();
128 var s = this.element;
129 s.value = splittedText[0] + text + splittedText[1];
130 s.selectionStart = (splittedText[0] + text).length;
131 s.selectionEnd = s.selectionStart;
132 this._mirror.firstChild.textContent = splittedText[0] + text;
133 },
134 // Return two substrings, splitted at current position
135 split : function () {
136 var s = this._element;
137 var value = s.value;
138 var start = s.selectionStart;
139 return new Array(
140 value.substring(0, start),
141 value.substring(start, value.length)
142 );
143 },
144 // Position the input mirror directly below the input box
145 reposition : function () {
146 var inputClientRect = this._element.getBoundingClientRect();
147 var inputStyle = window.getComputedStyle(this._element, null);
148 var mirrorStyle = this._mirror.style;
149 mirrorStyle.left = inputClientRect.left + "px";
150 mirrorStyle.top = inputClientRect.bottom + "px";
151
152 // These may be relevant in case of media depending css
153 mirrorStyle.paddingLeft = inputStyle.getPropertyValue("padding-left");
154 mirrorStyle.marginLeft = inputStyle.getPropertyValue("margin-left");
155 mirrorStyle.borderLeftWidth = inputStyle.getPropertyValue("border-left-width");
156 mirrorStyle.borderLeftStyle = inputStyle.getPropertyValue("border-left-style");
157 mirrorStyle.fontSize = inputStyle.getPropertyValue("font-size");
158 mirrorStyle.fontFamily = inputStyle.getPropertyValue("font-family");
159 },
160 get context () {
161 return this.split()[0];
162 }
163 };
164
165 KorAP.Hint = {
166 _firstTry : true,
167 create : function (param) {
168 return Object.create(KorAP.Hint)._init(param);
169 },
170 _init : function (param) {
171 param = param || {};
172 this._menu = {};
173
174 // Get input field
175 this._inputField = KorAP.InputField.create(
176 param["inputField"] || document.getElementById("q-field")
177 );
178
179 var that = this;
180 var inputFieldElement = this._inputField.element;
181
182 // Add event listener for key pressed down
183 inputFieldElement.addEventListener(
184 "keypress", function (e) {that.updateKeyPress(e)}, false
185 );
186
187 // Set Analyzer for context
188 this._analyzer = KorAP.ContextAnalyzer.create(
189 param["context"]|| KorAP.context
190 );
191
192 return this;
193 },
194 _codeFromEvent : function (e) {
195 if ((e.charCode) && (e.keyCode==0))
196 return e.charCode
197 return e.keyCode;
198 },
199 updateKeyPress : function (e) {
200 if (!this._active)
201 return;
202
203 var character = String.fromCharCode(
204 this._codeFromEvent(e)
205 );
206
207 e.halt(); // No event propagation
208
209 console.log("TODO: filter view");
210 },
211 updateKeyDown : function (e) {
212 var code = this._codeFromEvent(e)
213
214 /*
215 * keyCodes:
216 * - Down = 40
217 * - Esc = 27
218 * - Up = 38
219 * - Enter = 13
220 * - shift = 16
221 * for characters use e.key
222 */
223 switch (code) {
224 case 27: // 'Esc'
225 // TODO: menu.hide();
226 break;
227 case 40: // 'Down'
228 e.halt(); // No event propagation
229
230 // Menu is not active
231 if (!this._active)
232 this.popUp();
233 // Menu is active
234 else {
235 // TODO: that.removePrefix();
236 // TODO: menu.next();
237 };
238
239 break;
240 case 38: // "Up"
241 if (!this._active)
242 break;
243 e.halt(); // No event propagation
244 // TODO: that.removePrefix();
245 // TODO: menu.prev();
246 break;
247 case 13: // "Enter"
248 if (!this._active)
249 break;
250 e.halt(); // No event propagation
251 // TODO: that.insertText(menu.getActiveItem().getAction());
252 // TODO: that.removePrefix();
253
254 // Remove menu
255 // TODO: menu.hide();
256
257 // Fill this with the correct value
258 // Todo: This is redundant with click function
259 /*
260 var show;
261 if ((show = that.analyzeContext()) != "-") {
262 menu.show(show);
263 menu.update(
264 e.target.getBoundingClientRect().right
265 );
266 };
267 */
268
269 break;
270 default:
271 if (!this._active)
272 return;
273
274 // Surpress propagation in firefox
275 /*
276 if (e.key !== undefined && e.key.length != 1) {
277 menu.hide();
278 };
279 */
280 };
281 },
282
283 getMenuByContext : function () {
284 var context = this._inputField.context;
285 if (context === undefined || context.length == 0)
286 return this.menu("-");
287
288 context = this._analyzer.analyze(context);
289 if (context === undefined || context.length == 0)
290 return this.menu("-");
291
292 return this.menu(context);
293 },
294 // Return and probably init a menu based on an action
295 menu : function (action) {
296 if (this._menu[action] === undefined) {
297 if (KorAP.hintArray[action] === undefined)
298 return;
299 this._menu[action] = KorAP.menu.create(action, KorAP.hintArray[action]);
300 };
301 return this._menu[action];
302 },
303 get inputField () {
304 return this._inputField;
305 },
306 get active () {
307 return this._active;
308 },
309 popUp : function () {
310 if (this.active)
311 return;
312
313 if (this._firstTry) {
314 this._inputField.reposition();
315 this._firstTry = false;
316 };
317
318 // update
319
320 var menu;
321 if (menu = this.getMenuByContext()) {
322 menu.show();
323// Update bounding box
324 }
325 else {
326// this.hide();
327 };
328
329 // Focus on input field
330 this.inputField.element.focus();
331 }
332 };
333
334 /**
335 Regex object for checking the context of the hint
336 */
337 KorAP.ContextAnalyzer = {
338 create : function (regex) {
339 return Object.create(KorAP.ContextAnalyzer)._init(regex);
340 },
341 _init : function (regex) {
342 try {
343 this._regex = new RegExp(regex);
344 }
345 catch (e) {
346 KorAP.log("error", e);
347 return;
348 };
349 return this;
350 },
351 test : function (text) {
352 if (!this._regex.exec(text))
353 return;
354 return RegExp.$1;
355 }
356 };
357
358
359 /**
360 * List of items for drop down menu (complete).
361 * Only a sublist of the menu is filtered (live).
362 * Only a sublist of the filtered menu is visible (shown).
363 */
364 KorAP.Menu = {
365 _position : 0, // position in the active list
366 _active : -1, // active item in the item list
367
368 /**
369 * Create new Menu based on the action prefix
370 * and a list of menu items.
371 *
372 * @this {Menu}
373 * @constructor
374 * @param {string} Context prefix
375 * @param {Array.<Array.<string>>} List of menu items
376 */
377 create : function (context, items) {
378 return Object.create(KorAP.Menu)._init(context, items);
379 },
380
381 /*
382 * Make the previous item in the menu active
383 */
384 prev : function () {
385 if (this._position == -1)
386 return;
387
388 // Set new live item
389 var oldItem = this.liveItem(this._position--);
390 oldItem.active(false);
391 var newItem = this.liveItem(this._position);
392
393 // The previous element is undefined - roll to bottom
394 if (newItem === undefined) {
395 this._position = this.liveLength - 1;
396 newItem = this.liveItem(this._position);
397 this._offset = this.liveLength - this.limit;
398 this._showItems(this._offset);
399 }
400
401 // The previous element is outside the view - roll up
402 else if (this._position < this._offset) {
403 this._removeLast();
404 this._offset--;
405 this._prepend(this._list[this._position]);
406 };
407 newItem.active(true);
408 },
409
410 /*
411 * Make the next item in the menu active
412 */
413 next : function () {
414 // No active element set
415 if (this._position == -1)
416 return;
417
418 // Set new live item
419 var oldItem = this.liveItem(this._position++);
420 oldItem.active(false);
421 var newItem = this.liveItem(this._position);
422
423 // The next element is undefined - roll to top
424 if (newItem === undefined) {
425 this._offset = 0;
426 this._position = 0;
427 newItem = this.liveItem(0);
428 this._showItems(0);
429 }
430
431 // The next element is outside the view - roll down
432 else if (this._position >= (this.limit + this._offset)) {
433 this._removeFirst();
434 this._offset++;
435 this._append(this._list[this._position]);
436 };
437 newItem.active(true);
438 },
439
440 /**
441 * Delete all visible items from the menu element
442 */
443 delete : function () {
444 var child;
445 for (var i = 0; i <= this.limit; i++)
446 if (child = this.shownItem(i))
447 child.lowlight();
448 while (child = this._element.firstChild)
449 this._element.removeChild(child);
450 },
451
452 /**
453 * Filter the list and make it visible
454 *
455 * @param {string} Prefix for filtering the list
456 */
457 show : function (prefix) {
458 this._prefix = prefix;
459
460 // Initialize the list
461 if (!this._initList())
462 return;
463
464 // show based on offset
465 this._showItems(0);
466
467 // Set the first element to active
468 this.liveItem(0).active(true);
469 this._position = 0;
470 this._active = this._list[0];
471
472 // Add classes for rolling menus
473 this._boundary(true);
474 },
475
476 /**
477 * Get the prefix for filtering,
478 * e.g. &quot;ve"&quot; for &quot;verb&quot;
479 */
480 get prefix () {
481 return this._prefix || '';
482 },
483
484 /**
485 * Get the numerical value for limit
486 */
487 get limit () {
488 return KorAP.limit;
489 },
490
491 /**
492 * Get the context of the menue,
493 * e.g. &quot;tt/&quot; for the tree tagger menu
494 */
495 get context () {
496 return this._context;
497 },
498
499 /**
500 * Get a specific item from the complete list
501 *
502 * @param {number} index of the list item
503 */
504 item : function (index) {
505 return this._items[index]
506 },
507
508 /**
509 * Get a specific item from the filtered list
510 *
511 * @param {number} index of the list item
512 */
513 liveItem : function (index) {
514 if (this._list === undefined)
515 if (!this._initList())
516 return;
517
518 return this._items[this._list[index]];
519 },
520 /*
521 * Get a specific item from the visible list
522 *
523 * @param {number} index of the list item
524 */
525 shownItem : function (index) {
526 if (index >= this.limit)
527 return;
528 return this.liveItem(this._offset + index);
529 },
530 get element () {
531 return this._element;
532 },
533 get length () {
534 return this._items.length;
535 },
536 get liveLength () {
537 if (this._list === undefined)
538 this._initList();
539 return this._list.length;
540 },
541 chooseHint : function (e) {
542/*
543 var element = e.target;
544 while (element.nodeName == "STRONG" || element.nodeName == "SPAN")
545 element = element.parentNode;
546
547 if (element === undefined || element.nodeName != "LI")
548 return;
549
550 var action = element.getAttribute('data-action');
551 hint.insertText(action);
552 var menu = hint.menu();
553 menu.hide();
554
555 // Fill this with the correct value
556 var show;
557 if ((show = hint.analyzeContext()) != "-") {
558 menu.show(show);
559 menu.update(
560 hint._search.getBoundingClientRect().right
561 );
562 };
563
564 hint._search.focus();
565*/
566 },
567
568 _reset : function () {
569 this._offset = 0;
570 this._pos = 0;
571 this._prefix = undefined;
572 },
573 _boundary : function (bool) {
574 this.item(this._list[0]).noMore(bool);
575 this.item(this._list[this._list.length - 1]).noMore(bool);
576 },
577 _initList : function () {
Nils Diewald86dad5b2015-01-28 15:09:07 +0000578
579console.log("..." + this._items.length);
580
Nils Diewald19ccee92014-12-08 11:30:08 +0000581 if (this._list === undefined)
582 this._list = [];
583 else if (this._list.length != 0) {
584 this._boundary(false);
585 this._list.length = 0;
586 };
587
588 this._offset = 0;
589
Nils Diewald86dad5b2015-01-28 15:09:07 +0000590 if (this.prefix().length <= 0) {
Nils Diewald19ccee92014-12-08 11:30:08 +0000591 for (var i = 0; i < this._items.length; i++)
592 this._list.push(i);
593 return true;
594 };
595
596 var pos;
597 var paddedPrefix = " " + this.prefix;
598 for (pos = 0; pos < this._items.length; pos++) {
599 if ((this.item(pos).lcfield.indexOf(paddedPrefix)) >= 0)
600 this._list.push(pos);
601 };
602 if (this._list.length == 0) {
603 for (pos = 0; pos < this._items.length; pos++) {
604 if ((this.item(pos).lcfield.indexOf(this.prefix)) >= 0)
605 this._list.push(pos);
606 };
607 };
608
609 // Filter was successful
610 return this._list.length > 0 ? true : false;
611 },
612
613 _removeFirst : function () {
614 this.item(this._list[this._offset]).lowlight();
615 this._element.removeChild(this._element.firstChild);
616 },
617
618 _removeLast : function () {
619 this.item(this._list[this._offset + this.limit - 1]).lowlight();
620 this._element.removeChild(this._element.lastChild);
621 },
622
623 // Append item to the shown list based on index
624 _append : function (i) {
625 var item = this.item(i);
626
627 // Highlight based on prefix
628 if (this.prefix.length > 0)
629 item.highlight(this.prefix);
630
631 // Append element
632 this.element.appendChild(item.element);
633 },
634
635 // Prepend item to the shown list based on index
636 _prepend : function (i) {
637 var item = this.item(i);
638
639 // Highlight based on prefix
640 if (this.prefix.length > 0)
641 item.highlight(this.prefix);
642
643 // Append element
644 this.element.insertBefore(
645 item.element,
646 this.element.firstChild
647 );
648 },
649 _init : function (context, items) {
650 this._context = context;
651 this._element = document.createElement("ul");
652 this._element.style.opacity = 0;
653 this.active = false;
654/*
655 Todo:
656 this._element.addEventListener("click", chooseHint, false);
657*/
658 this._items = new Array();
659 var i;
660 for (i in items)
661 this._items.push(KorAP.MenuItem.create(items[i]));
662
663 this._reset();
664 return this;
665 },
666
667 _showItems : function (offset) {
668 this.delete();
669
670 // Use list
671 var shown = 0;
672 var i;
673 for (i in this._list) {
674
675 // Don't show - it's before offset
676 if (shown++ < offset)
677 continue;
678
679 this._append(this._list[i]);
680
681 if (shown >= (this.limit + this._offset))
682 break;
683 };
684 }
685 };
686
687
688 /**
689 * Item in the Dropdown menu
690 */
691 KorAP.MenuItem = {
692
693 /**
694 * Create a new MenuItem object.
695 *
696 * @constructor
697 * @this {MenuItem}
698 * @param {Array.<string>} An array object of name, action and
699 * optionally a description
700 */
701 create : function (params) {
702 return Object.create(KorAP.MenuItem)._init(params);
703 },
704
705 /**
706 * Get the name of the item
707 */
708 get name () {
709 return this._name;
710 },
711
712 /**
713 * Get the action string
714 */
715 get action () {
716 return this._action;
717 },
718
719 /**
720 * Get the description of the item
721 */
722 get desc () {
723 return this._desc;
724 },
725
726 /**
727 * Get the lower case field
728 */
729 get lcfield () {
730 return this._lcfield;
731 },
732
733 /**
734 * Check or set if the item is active
735 *
736 * @param {boolean|null} State of activity
737 */
738 active : function (bool) {
739 var cl = this.element.classList;
740 if (bool === undefined)
741 return cl.contains("active");
742 else if (bool)
743 cl.add("active");
744 else
745 cl.remove("active");
746 },
747
748 /**
749 * Check or set if the item is
750 * at the boundary of the menu
751 * list
752 *
753 * @param {boolean|null} State of activity
754 */
755 noMore : function (bool) {
756 var cl = this.element.classList;
757 if (bool === undefined)
758 return cl.contains("no-more");
759 else if (bool)
760 cl.add("no-more");
761 else
762 cl.remove("no-more");
763 },
764
765 /**
766 * Get the document element of the menu item
767 */
768 get element () {
769 // already defined
770 if (this._element !== undefined)
771 return this._element;
772
773 // Create list item
774 var li = document.createElement("li");
775 li.setAttribute("data-action", this._action);
776
777 // Create title
778 var name = document.createElement("strong");
779 name.appendChild(document.createTextNode(this._name));
780
781 li.appendChild(name);
782
783 // Create description
784 if (this._desc !== undefined) {
785 var desc = document.createElement("span");
786 desc.appendChild(document.createTextNode(this._desc));
787 li.appendChild(desc);
788 };
789 return this._element = li;
790 },
791
792 /**
793 * Highlight parts of the item
794 *
795 * @param {string} Prefix string for highlights
796 */
797 highlight : function (prefix) {
798 var e = this.element;
799 this._highlight(e.firstChild, prefix);
800 if (this._desc !== undefined)
801 this._highlight(e.lastChild, prefix);
802 },
803
804 /**
805 * Remove highlight of the menu item
806 */
807 lowlight : function () {
808 var e = this.element;
809 e.firstChild.innerHTML = this._name;
810 if (this._desc !== undefined)
811 e.lastChild.innerHTML = this._desc;
812 },
813
814 // Initialize menu item
815 _init : function (params) {
816 if (params[0] === undefined || params[1] === undefined)
817 throw new Error("Missing parameters");
818
819 this._name = params[0];
820 this._action = params[1];
821 this._lcfield = " " + this._name.toLowerCase();
822
823 if (params.length > 2) {
824 this._desc = params[2];
825 this._lcfield += " " + this._desc.toLowerCase();
826 };
827 return this;
828 },
829
830 // Highlight a certain element of the menu item
831 _highlight : function (elem, prefix) {
832 var text = elem.firstChild.nodeValue;
833 var textlc = text.toLowerCase();
834 var pos = textlc.indexOf(prefix);
835 if (pos >= 0) {
836
837 // First element
838 elem.firstChild.nodeValue = pos > 0 ? text.substr(0, pos) : "";
839
840 // Second element
841 var hl = document.createElement("em");
842 hl.appendChild(
843 document.createTextNode(text.substr(pos, prefix.length))
844 );
845 elem.appendChild(hl);
846
847 // Third element
848 elem.appendChild(
849 document.createTextNode(text.substr(pos + prefix.length))
850 );
851 };
852 }
853 };
854}(this.KorAP));