blob: 4f5a8b5644001e9a4f126c5416960d571b040e96 [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 () {
567 if (this._list === undefined)
568 this._list = [];
569 else if (this._list.length != 0) {
570 this._boundary(false);
571 this._list.length = 0;
572 };
573
574 this._offset = 0;
575
576 if (this.prefix.length <= 0) {
577 for (var i = 0; i < this._items.length; i++)
578 this._list.push(i);
579 return true;
580 };
581
582 var pos;
583 var paddedPrefix = " " + this.prefix;
584 for (pos = 0; pos < this._items.length; pos++) {
585 if ((this.item(pos).lcfield.indexOf(paddedPrefix)) >= 0)
586 this._list.push(pos);
587 };
588 if (this._list.length == 0) {
589 for (pos = 0; pos < this._items.length; pos++) {
590 if ((this.item(pos).lcfield.indexOf(this.prefix)) >= 0)
591 this._list.push(pos);
592 };
593 };
594
595 // Filter was successful
596 return this._list.length > 0 ? true : false;
597 },
598
599 _removeFirst : function () {
600 this.item(this._list[this._offset]).lowlight();
601 this._element.removeChild(this._element.firstChild);
602 },
603
604 _removeLast : function () {
605 this.item(this._list[this._offset + this.limit - 1]).lowlight();
606 this._element.removeChild(this._element.lastChild);
607 },
608
609 // Append item to the shown list based on index
610 _append : function (i) {
611 var item = this.item(i);
612
613 // Highlight based on prefix
614 if (this.prefix.length > 0)
615 item.highlight(this.prefix);
616
617 // Append element
618 this.element.appendChild(item.element);
619 },
620
621 // Prepend item to the shown list based on index
622 _prepend : function (i) {
623 var item = this.item(i);
624
625 // Highlight based on prefix
626 if (this.prefix.length > 0)
627 item.highlight(this.prefix);
628
629 // Append element
630 this.element.insertBefore(
631 item.element,
632 this.element.firstChild
633 );
634 },
635 _init : function (context, items) {
636 this._context = context;
637 this._element = document.createElement("ul");
638 this._element.style.opacity = 0;
639 this.active = false;
640/*
641 Todo:
642 this._element.addEventListener("click", chooseHint, false);
643*/
644 this._items = new Array();
645 var i;
646 for (i in items)
647 this._items.push(KorAP.MenuItem.create(items[i]));
648
649 this._reset();
650 return this;
651 },
652
653 _showItems : function (offset) {
654 this.delete();
655
656 // Use list
657 var shown = 0;
658 var i;
659 for (i in this._list) {
660
661 // Don't show - it's before offset
662 if (shown++ < offset)
663 continue;
664
665 this._append(this._list[i]);
666
667 if (shown >= (this.limit + this._offset))
668 break;
669 };
670 }
671 };
672
673
674 /**
675 * Item in the Dropdown menu
676 */
677 KorAP.MenuItem = {
678
679 /**
680 * Create a new MenuItem object.
681 *
682 * @constructor
683 * @this {MenuItem}
684 * @param {Array.<string>} An array object of name, action and
685 * optionally a description
686 */
687 create : function (params) {
688 return Object.create(KorAP.MenuItem)._init(params);
689 },
690
691 /**
692 * Get the name of the item
693 */
694 get name () {
695 return this._name;
696 },
697
698 /**
699 * Get the action string
700 */
701 get action () {
702 return this._action;
703 },
704
705 /**
706 * Get the description of the item
707 */
708 get desc () {
709 return this._desc;
710 },
711
712 /**
713 * Get the lower case field
714 */
715 get lcfield () {
716 return this._lcfield;
717 },
718
719 /**
720 * Check or set if the item is active
721 *
722 * @param {boolean|null} State of activity
723 */
724 active : function (bool) {
725 var cl = this.element.classList;
726 if (bool === undefined)
727 return cl.contains("active");
728 else if (bool)
729 cl.add("active");
730 else
731 cl.remove("active");
732 },
733
734 /**
735 * Check or set if the item is
736 * at the boundary of the menu
737 * list
738 *
739 * @param {boolean|null} State of activity
740 */
741 noMore : function (bool) {
742 var cl = this.element.classList;
743 if (bool === undefined)
744 return cl.contains("no-more");
745 else if (bool)
746 cl.add("no-more");
747 else
748 cl.remove("no-more");
749 },
750
751 /**
752 * Get the document element of the menu item
753 */
754 get element () {
755 // already defined
756 if (this._element !== undefined)
757 return this._element;
758
759 // Create list item
760 var li = document.createElement("li");
761 li.setAttribute("data-action", this._action);
762
763 // Create title
764 var name = document.createElement("strong");
765 name.appendChild(document.createTextNode(this._name));
766
767 li.appendChild(name);
768
769 // Create description
770 if (this._desc !== undefined) {
771 var desc = document.createElement("span");
772 desc.appendChild(document.createTextNode(this._desc));
773 li.appendChild(desc);
774 };
775 return this._element = li;
776 },
777
778 /**
779 * Highlight parts of the item
780 *
781 * @param {string} Prefix string for highlights
782 */
783 highlight : function (prefix) {
784 var e = this.element;
785 this._highlight(e.firstChild, prefix);
786 if (this._desc !== undefined)
787 this._highlight(e.lastChild, prefix);
788 },
789
790 /**
791 * Remove highlight of the menu item
792 */
793 lowlight : function () {
794 var e = this.element;
795 e.firstChild.innerHTML = this._name;
796 if (this._desc !== undefined)
797 e.lastChild.innerHTML = this._desc;
798 },
799
800 // Initialize menu item
801 _init : function (params) {
802 if (params[0] === undefined || params[1] === undefined)
803 throw new Error("Missing parameters");
804
805 this._name = params[0];
806 this._action = params[1];
807 this._lcfield = " " + this._name.toLowerCase();
808
809 if (params.length > 2) {
810 this._desc = params[2];
811 this._lcfield += " " + this._desc.toLowerCase();
812 };
813 return this;
814 },
815
816 // Highlight a certain element of the menu item
817 _highlight : function (elem, prefix) {
818 var text = elem.firstChild.nodeValue;
819 var textlc = text.toLowerCase();
820 var pos = textlc.indexOf(prefix);
821 if (pos >= 0) {
822
823 // First element
824 elem.firstChild.nodeValue = pos > 0 ? text.substr(0, pos) : "";
825
826 // Second element
827 var hl = document.createElement("em");
828 hl.appendChild(
829 document.createTextNode(text.substr(pos, prefix.length))
830 );
831 elem.appendChild(hl);
832
833 // Third element
834 elem.appendChild(
835 document.createTextNode(text.substr(pos + prefix.length))
836 );
837 };
838 }
839 };
840}(this.KorAP));