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