svghmi/widget_dropdown.ysl2
changeset 3302 c89fc366bebd
parent 3241 fe945f1f48b7
child 3320 9fe5b4a04acc
equal deleted inserted replaced
2744:577118ebd179 3302:c89fc366bebd
       
     1 // widget_dropdown.ysl2
       
     2 
       
     3 widget_desc("DropDown") {
       
     4 
       
     5     longdesc
       
     6     ||
       
     7     DropDown widget let user select an entry in a list of texts, given as
       
     8     arguments. Single variable path is index of selection.
       
     9 
       
    10     It needs "text" (svg:text), "box" (svg:rect), "button" (svg:*),
       
    11     and "highlight" (svg:rect) labeled elements.
       
    12 
       
    13     When user clicks on "button", "text" is duplicated to display enties in the
       
    14     limit of available space in page, and "box" is extended to contain all
       
    15     texts. "highlight" is moved over pre-selected entry.
       
    16 
       
    17     When only one argument is given, and argment contains "#langs" then list of
       
    18     texts is automatically set to the list of human-readable languages supported
       
    19     by this HMI. 
       
    20     ||
       
    21 
       
    22     shortdesc > Let user select text entry in a drop-down menu
       
    23 
       
    24     arg name="entries" count="many" accepts="string" > drop-down menu entries
       
    25 
       
    26     path name="selection" accepts="HMI_INT" > selection index
       
    27 }
       
    28 
       
    29 // TODO: support i18n of menu entries using svg:text elements with labels starting with "_"
       
    30 
       
    31 widget_class("DropDown") {
       
    32 ||
       
    33         dispatch(value) {
       
    34             if(!this.opened) this.set_selection(value);
       
    35         }
       
    36         init() {
       
    37             this.button_elt.onclick = this.on_button_click.bind(this);
       
    38             // Save original size of rectangle
       
    39             this.box_bbox = this.box_elt.getBBox()
       
    40             this.highlight_bbox = this.highlight_elt.getBBox()
       
    41             this.highlight_elt.style.visibility = "hidden";
       
    42 
       
    43             // Compute margins
       
    44             this.text_bbox = this.text_elt.getBBox();
       
    45             let lmargin = this.text_bbox.x - this.box_bbox.x;
       
    46             let tmargin = this.text_bbox.y - this.box_bbox.y;
       
    47             this.margins = [lmargin, tmargin].map(x => Math.max(x,0));
       
    48 
       
    49             // Index of first visible element in the menu, when opened
       
    50             this.menu_offset = 0;
       
    51 
       
    52             // How mutch to lift the menu vertically so that it does not cross bottom border
       
    53             this.lift = 0;
       
    54 
       
    55             // Event handlers cannot be object method ('this' is unknown)
       
    56             // as a workaround, handler given to addEventListener is bound in advance.
       
    57             this.bound_close_on_click_elsewhere = this.close_on_click_elsewhere.bind(this);
       
    58             this.bound_on_selection_click = this.on_selection_click.bind(this);
       
    59             this.bound_on_backward_click = this.on_backward_click.bind(this);
       
    60             this.bound_on_forward_click = this.on_forward_click.bind(this);
       
    61             this.opened = false;
       
    62             this.clickables = [];
       
    63         }
       
    64         on_button_click() {
       
    65             this.open();
       
    66         }
       
    67         // Called when a menu entry is clicked
       
    68         on_selection_click(selection) {
       
    69             this.close();
       
    70             this.apply_hmi_value(0, selection);
       
    71         }
       
    72         on_backward_click(){
       
    73             this.scroll(false);
       
    74         }
       
    75         on_forward_click(){
       
    76             this.scroll(true);
       
    77         }
       
    78         set_selection(value) {
       
    79             let display_str;
       
    80             if(value >= 0 && value < this.content.length){
       
    81                 // if valid selection resolve content
       
    82                 display_str = this.content[value];
       
    83                 this.last_selection = value;
       
    84             } else {
       
    85                 // otherwise show problem
       
    86                 display_str = "?"+String(value)+"?";
       
    87             }
       
    88             // It is assumed that first span always stays,
       
    89             // and contains selection when menu is closed
       
    90             this.text_elt.firstElementChild.textContent = display_str;
       
    91         }
       
    92         grow_text(up_to) {
       
    93             let count = 1;
       
    94             let txt = this.text_elt;
       
    95             let first = txt.firstElementChild;
       
    96             // Real world (pixels) boundaries of current page
       
    97             let bounds = svg_root.getBoundingClientRect();
       
    98             this.lift = 0;
       
    99             while(count < up_to) {
       
   100                 let next = first.cloneNode();
       
   101                 // relative line by line text flow instead of absolute y coordinate
       
   102                 next.removeAttribute("y");
       
   103                 next.setAttribute("dy", "1.1em");
       
   104                 // default content to allow computing text element bbox
       
   105                 next.textContent = "...";
       
   106                 // append new span to text element
       
   107                 txt.appendChild(next);
       
   108                 // now check if text extended by one row fits to page
       
   109                 // FIXME : exclude margins to be more accurate on box size
       
   110                 let rect = txt.getBoundingClientRect();
       
   111                 if(rect.bottom > bounds.bottom){
       
   112                     // in case of overflow at the bottom, lift up one row
       
   113                     let backup = first.getAttribute("dy");
       
   114                     // apply lift as a dy added too first span (y attrib stays)
       
   115                     first.setAttribute("dy", "-"+String((this.lift+1)*1.1)+"em");
       
   116                     rect = txt.getBoundingClientRect();
       
   117                     if(rect.top > bounds.top){
       
   118                         this.lift += 1;
       
   119                     } else {
       
   120                         // if it goes over the top, then backtrack
       
   121                         // restore dy attribute on first span
       
   122                         if(backup)
       
   123                             first.setAttribute("dy", backup);
       
   124                         else
       
   125                             first.removeAttribute("dy");
       
   126                         // remove unwanted child
       
   127                         txt.removeChild(next);
       
   128                         return count;
       
   129                     }
       
   130                 }
       
   131                 count++;
       
   132             }
       
   133             return count;
       
   134         }
       
   135         close_on_click_elsewhere(e) {
       
   136             // inhibit events not targetting spans (menu items)
       
   137             if([this.text_elt, this.element].indexOf(e.target.parentNode) == -1){
       
   138                 e.stopPropagation();
       
   139                 // close menu in case click is outside box
       
   140                 if(e.target !== this.box_elt)
       
   141                     this.close();
       
   142             }
       
   143         }
       
   144         close(){
       
   145             // Stop hogging all click events
       
   146             svg_root.removeEventListener("pointerdown", this.numb_event, true);
       
   147             svg_root.removeEventListener("pointerup", this.numb_event, true);
       
   148             svg_root.removeEventListener("click", this.bound_close_on_click_elsewhere, true);
       
   149             // Restore position and sixe of widget elements
       
   150             this.reset_text();
       
   151             this.reset_clickables();
       
   152             this.reset_box();
       
   153             this.reset_highlight();
       
   154             // Put the button back in place
       
   155             this.element.appendChild(this.button_elt);
       
   156             // Mark as closed (to allow dispatch)
       
   157             this.opened = false;
       
   158             // Dispatch last cached value
       
   159             this.apply_cache();
       
   160         }
       
   161         // Make item (text span) clickable by overlaying a rectangle on top of it
       
   162         make_clickable(span, func) {
       
   163             let txt = this.text_elt;
       
   164             let original_text_y = this.text_bbox.y;
       
   165             let highlight = this.highlight_elt;
       
   166             let original_h_y = this.highlight_bbox.y;
       
   167             let clickable = highlight.cloneNode();
       
   168             let yoffset = span.getBBox().y - original_text_y;
       
   169             clickable.y.baseVal.value = original_h_y + yoffset;
       
   170             clickable.style.pointerEvents = "bounding-box";
       
   171             //clickable.style.visibility = "hidden";
       
   172             //clickable.onclick = () => alert("love JS");
       
   173             clickable.onclick = func;
       
   174             this.element.appendChild(clickable);
       
   175             this.clickables.push(clickable)
       
   176         }
       
   177         reset_clickables() {
       
   178             while(this.clickables.length){
       
   179                 this.element.removeChild(this.clickables.pop());
       
   180             }
       
   181         }
       
   182         // Set text content when content is smaller than menu (no scrolling)
       
   183         set_complete_text(){
       
   184             let spans = this.text_elt.children;
       
   185             let c = 0;
       
   186             for(let item of this.content){
       
   187                 let span=spans[c];
       
   188                 span.textContent = item;
       
   189                 let sel = c;
       
   190                 this.make_clickable(span, (evt) => this.bound_on_selection_click(sel));
       
   191                 c++;
       
   192             }
       
   193         }
       
   194         // Move partial view :
       
   195         // false : upward, lower value
       
   196         // true  : downward, higher value
       
   197         scroll(forward){
       
   198             let contentlength = this.content.length;
       
   199             let spans = this.text_elt.children;
       
   200             let spanslength = spans.length;
       
   201             // reduce accounted menu size according to prsence of scroll buttons
       
   202             // since we scroll there is necessarly one button
       
   203             spanslength--;
       
   204             if(forward){
       
   205                 // reduce accounted menu size because of back button
       
   206                 // in current view
       
   207                 if(this.menu_offset > 0) spanslength--;
       
   208                 this.menu_offset = Math.min(
       
   209                     contentlength - spans.length + 1,
       
   210                     this.menu_offset + spanslength);
       
   211             }else{
       
   212                 // reduce accounted menu size because of back button
       
   213                 // in view once scrolled
       
   214                 if(this.menu_offset - spanslength > 0) spanslength--;
       
   215                 this.menu_offset = Math.max(
       
   216                     0,
       
   217                     this.menu_offset - spanslength);
       
   218             }
       
   219             if(this.menu_offset == 1)
       
   220                 this.menu_offset = 0;
       
   221 
       
   222             this.reset_highlight();
       
   223 
       
   224             this.reset_clickables();
       
   225             this.set_partial_text();
       
   226 
       
   227             this.highlight_selection();
       
   228         }
       
   229         // Setup partial view text content
       
   230         // with jumps at first and last entry when appropriate
       
   231         set_partial_text(){
       
   232             let spans = this.text_elt.children;
       
   233             let contentlength = this.content.length;
       
   234             let spanslength = spans.length;
       
   235             let i = this.menu_offset, c = 0;
       
   236             let m = this.box_bbox;
       
   237             while(c < spanslength){
       
   238                 let span=spans[c];
       
   239                 let onclickfunc;
       
   240                 // backward jump only present if not exactly at start
       
   241                 if(c == 0 && i != 0){
       
   242                     span.textContent = "▲";
       
   243                     onclickfunc = this.bound_on_backward_click;
       
   244                     let o = span.getBBox();
       
   245                     span.setAttribute("dx", (m.width - o.width)/2);
       
   246                 // presence of forward jump when not right at the end
       
   247                 }else if(c == spanslength-1 && i < contentlength - 1){
       
   248                     span.textContent = "▼";
       
   249                     onclickfunc = this.bound_on_forward_click;
       
   250                     let o = span.getBBox();
       
   251                     span.setAttribute("dx", (m.width - o.width)/2);
       
   252                 // otherwise normal content
       
   253                 }else{
       
   254                     span.textContent = this.content[i];
       
   255                     let sel = i;
       
   256                     onclickfunc = (evt) => this.bound_on_selection_click(sel);
       
   257                     span.removeAttribute("dx");
       
   258                     i++;
       
   259                 }
       
   260                 this.make_clickable(span, onclickfunc);
       
   261                 c++;
       
   262             }
       
   263         }
       
   264         numb_event(e) {
       
   265              e.stopPropagation();
       
   266         }
       
   267         open(){
       
   268             let length = this.content.length;
       
   269             // systematically reset text, to strip eventual whitespace spans
       
   270             this.reset_text();
       
   271             // grow as much as needed or possible
       
   272             let slots = this.grow_text(length);
       
   273             // Depending on final size
       
   274             if(slots == length) {
       
   275                 // show all at once
       
   276                 this.set_complete_text();
       
   277             } else {
       
   278                 // eventualy align menu to current selection, compensating for lift
       
   279                 let offset = this.last_selection - this.lift;
       
   280                 if(offset > 0)
       
   281                     this.menu_offset = Math.min(offset + 1, length - slots + 1);
       
   282                 else
       
   283                     this.menu_offset = 0;
       
   284                 // show surrounding values
       
   285                 this.set_partial_text();
       
   286             }
       
   287             // Now that text size is known, we can set the box around it
       
   288             this.adjust_box_to_text();
       
   289             // Take button out until menu closed
       
   290             this.element.removeChild(this.button_elt);
       
   291             // Rise widget to top by moving it to last position among siblings
       
   292             this.element.parentNode.appendChild(this.element.parentNode.removeChild(this.element));
       
   293             // disable interaction with background
       
   294             svg_root.addEventListener("pointerdown", this.numb_event, true);
       
   295             svg_root.addEventListener("pointerup", this.numb_event, true);
       
   296             svg_root.addEventListener("click", this.bound_close_on_click_elsewhere, true);
       
   297             this.highlight_selection();
       
   298 
       
   299             // mark as open
       
   300             this.opened = true;
       
   301         }
       
   302         // Put text element in normalized state
       
   303         reset_text(){
       
   304             let txt = this.text_elt;
       
   305             let first = txt.firstElementChild;
       
   306             // remove attribute eventually added to first text line while opening
       
   307             first.onclick = null;
       
   308             first.removeAttribute("dy");
       
   309             first.removeAttribute("dx");
       
   310             // keep only the first line of text
       
   311             for(let span of Array.from(txt.children).slice(1)){
       
   312                 txt.removeChild(span)
       
   313             }
       
   314         }
       
   315         // Put rectangle element in saved original state
       
   316         reset_box(){
       
   317             let m = this.box_bbox;
       
   318             let b = this.box_elt;
       
   319             b.x.baseVal.value = m.x;
       
   320             b.y.baseVal.value = m.y;
       
   321             b.width.baseVal.value = m.width;
       
   322             b.height.baseVal.value = m.height;
       
   323         }
       
   324         highlight_selection(){
       
   325             if(this.last_selection == undefined) return;
       
   326             let highlighted_row = this.last_selection - this.menu_offset;
       
   327             if(highlighted_row < 0) return;
       
   328             let spans = this.text_elt.children;
       
   329             let spanslength = spans.length;
       
   330             let contentlength = this.content.length;
       
   331             if(this.menu_offset != 0) {
       
   332                 spanslength--;
       
   333                 highlighted_row++;
       
   334             }
       
   335             if(this.menu_offset + spanslength < contentlength - 1) spanslength--;
       
   336             if(highlighted_row > spanslength) return;
       
   337             let original_text_y = this.text_bbox.y;
       
   338             let highlight = this.highlight_elt;
       
   339             let span = spans[highlighted_row];
       
   340             let yoffset = span.getBBox().y - original_text_y;
       
   341             highlight.y.baseVal.value = this.highlight_bbox.y + yoffset;
       
   342             highlight.style.visibility = "visible";
       
   343         }
       
   344         reset_highlight(){
       
   345             let highlight = this.highlight_elt;
       
   346             highlight.y.baseVal.value = this.highlight_bbox.y;
       
   347             highlight.style.visibility = "hidden";
       
   348         }
       
   349         // Use margin and text size to compute box size
       
   350         adjust_box_to_text(){
       
   351             let [lmargin, tmargin] = this.margins;
       
   352             let m = this.text_elt.getBBox();
       
   353             let b = this.box_elt;
       
   354             // b.x.baseVal.value = m.x - lmargin;
       
   355             b.y.baseVal.value = m.y - tmargin;
       
   356             // b.width.baseVal.value = 2 * lmargin + m.width;
       
   357             b.height.baseVal.value = 2 * tmargin + m.height;
       
   358         }
       
   359 ||
       
   360 }
       
   361 
       
   362 widget_defs("DropDown") {
       
   363     labels("text box button highlight");
       
   364     // It is assumed that list content conforms to Array interface.
       
   365     >   content:
       
   366     choose{
       
   367         // special case when used for language selection
       
   368         when "count(arg) = 1 and arg[1]/@value = '#langs'" {
       
   369             > langs
       
   370         }
       
   371         otherwise {
       
   372             > [\n
       
   373             foreach "arg" | "«@value»",
       
   374             >   ]
       
   375         }
       
   376     }
       
   377     > ,\n
       
   378 }