svghmi/widget_dropdown.ysl2
author Edouard Tisserant
Thu, 04 Jun 2020 11:14:21 +0200
branchsvghmi
changeset 2980 2a21d6060d64
parent 2936 53fb11263ff1
child 3001 003fd80ff0b8
permissions -rw-r--r--
SVGHMI: add "unsubscribable" property to widgets in order to generalize what already happens for jump buttons.
In most cases jump buttons do not really subscribe to pointed HMI variable, path is given as a relative page jump path. When widget.unsubscribable is set to true, no subscription is made on page switch, but still offset is updated.
This fixes bug happening on relative jump buttons without "disabled" element where offset did not change on relative page switch.
// widget_dropdown.ysl2

template "widget[@type='DropDown']", mode="widget_defs" {
    param "hmi_element";
    labels("text box button");
||
    dispatch: function(value) {
        if(!this.opened) this.set_selection(value);
    },
    init: function() {
        this.button_elt.setAttribute("onclick", "hmi_widgets['«$hmi_element/@id»'].on_button_click()");
        // Save original size of rectangle
        this.box_bbox = this.box_elt.getBBox()

        // Compute margins
        text_bbox = this.text_elt.getBBox()
        lmargin = text_bbox.x - this.box_bbox.x;
        tmargin = text_bbox.y - this.box_bbox.y;
        this.margins = [lmargin, tmargin].map(x => Math.max(x,0));

        // It is assumed that list content conforms to Array interface.
        this.content = [
        ``foreach "arg" | "«@value»",
        ];

        // Index of first visible element in the menu, when opened
        this.menu_offset = 0;

        // How mutch to lift the menu vertically so that it does not cross bottom border
        this.lift = 0;

        // Event handlers cannot be object method ('this' is unknown)
        // as a workaround, handler given to addEventListener is bound in advance.
        this.bound_close_on_click_elsewhere = this.close_on_click_elsewhere.bind(this);

        this.opened = false;
    },
    // Called when a menu entry is clicked
    on_selection_click: function(selection) {
        this.close();
        let orig = this.indexes[0];
        let idx = this.offset ? orig - this.offset : orig;
        apply_hmi_value(idx, selection);
    },
    on_button_click: function() {
        this.open();
    },
    on_backward_click: function(){
        this.scroll(false);
    },
    on_forward_click:function(){
        this.scroll(true);
    },
    set_selection: function(value) {
        let display_str;
        if(value >= 0 && value < this.content.length){
            // if valid selection resolve content
            display_str = this.content[value];
            this.last_selection = value;
        } else {
            // otherwise show problem
            display_str = "?"+String(value)+"?";
        }
        // It is assumed that first span always stays,
        // and contains selection when menu is closed
        this.text_elt.firstElementChild.textContent = display_str;
    },
    grow_text: function(up_to) {
        let count = 1;
        let txt = this.text_elt; 
        let first = txt.firstElementChild;
        // Real world (pixels) boundaries of current page
        let bounds = svg_root.getBoundingClientRect(); 
        this.lift = 0;
        while(count < up_to) {
            let next = first.cloneNode();
            // relative line by line text flow instead of absolute y coordinate
            next.removeAttribute("y");
            next.setAttribute("dy", "1.1em");
            // default content to allow computing text element bbox
            next.textContent = "...";
            // append new span to text element
            txt.appendChild(next);
            // now check if text extended by one row fits to page
            // FIXME : exclude margins to be more accurate on box size
            let rect = txt.getBoundingClientRect();
            if(rect.bottom > bounds.bottom){
                // in case of overflow at the bottom, lift up one row
                let backup = first.getAttribute("dy");
                // apply lift asr a dy added too first span (y attrib stays)
                first.setAttribute("dy", "-"+String((this.lift+1)*1.1)+"em");
                rect = txt.getBoundingClientRect();
                if(rect.top > bounds.top){
                    this.lift += 1;
                } else {
                    // if it goes over the top, then backtrack
                    // restore dy attribute on first span
                    if(backup)
                        first.setAttribute("dy", backup);
                    else
                        first.removeAttribute("dy");
                    // remove unwanted child
                    txt.removeChild(next);
                    return count;
                }
            }
            count++;
        }
        return count;
    },
    close_on_click_elsewhere: function(e) {
        // inhibit events not targetting spans (menu items)
        if(e.target.parentNode !== this.text_elt){
            e.stopPropagation();
            // close menu in case click is outside box
            if(e.target !== this.box_elt)
                this.close();
        }
    },
    close: function(){
        // Stop hogging all click events
        svg_root.removeEventListener("click", this.bound_close_on_click_elsewhere, true);
        // Restore position and sixe of widget elements
        this.reset_text();
        this.reset_box();
        // Put the button back in place
        this.element.appendChild(this.button_elt);
        // Mark as closed (to allow dispatch)
        this.opened = false;
        // Dispatch last cached value
        this.apply_cache();
    },
    // Set text content when content is smaller than menu (no scrolling)
    set_complete_text: function(){
        let spans = this.text_elt.children; 
        let c = 0;
        for(let item of this.content){
            let span=spans[c];
            span.textContent = item;
            span.setAttribute("onclick", "hmi_widgets['«$hmi_element/@id»'].on_selection_click("+c+")");
            c++;
        }
    },
    // Move partial view :
    // false : upward, lower value
    // true  : downward, higher value
    scroll: function(forward){
        let contentlength = this.content.length;
        let spans = this.text_elt.children; 
        let spanslength = spans.length;
        // reduce accounted menu size according to jumps
        if(this.menu_offset != 0) spanslength--;
        if(this.menu_offset < contentlength - 1) spanslength--;
        if(forward){
            this.menu_offset = Math.min(
                contentlength - spans.length + 1, 
                this.menu_offset + spanslength);
        }else{
            this.menu_offset = Math.max(
                0, 
                this.menu_offset - spanslength);
        }
        console.log(this.menu_offset);
        this.set_partial_text();
    },
    // Setup partial view text content
    // with jumps at first and last entry when appropriate
    set_partial_text: function(){
        let spans = this.text_elt.children; 
        let contentlength = this.content.length;
        let spanslength = spans.length;
        let i = this.menu_offset, c = 0;
        while(c < spanslength){
            let span=spans[c];
            // backward jump only present if not exactly at start
            if(c == 0 && i != 0){
                span.textContent = "↑  ↑  ↑";
                span.setAttribute("onclick", "hmi_widgets['«$hmi_element/@id»'].on_backward_click()");
            // presence of forward jump when not right at the end
            }else if(c == spanslength-1 && i < contentlength - 1){
                span.textContent = "↓  ↓  ↓";
                span.setAttribute("onclick", "hmi_widgets['«$hmi_element/@id»'].on_forward_click()");
            // otherwise normal content
            }else{
                span.textContent = this.content[i];
                span.setAttribute("onclick", "hmi_widgets['«$hmi_element/@id»'].on_selection_click("+i+")");
                i++;
            }
            c++;
        }
    },
    open: function(){
        let length = this.content.length;
        // systematically reset text, to strip eventual whitespace spans
        this.reset_text();
        // grow as much as needed or possible
        let slots = this.grow_text(length);
        // Depending on final size
        if(slots == length) {
            // show all at once
            this.set_complete_text();
        } else {
            // eventualy align menu to current selection, compensating for lift
            let offset = this.last_selection - this.lift;
            if(offset > 0)
                this.menu_offset = Math.min(offset + 1, length - slots + 1);
            else
                this.menu_offset = 0;
            // show surrounding values
            this.set_partial_text();
        }
        // Now that text size is known, we can set the box around it
        this.adjust_box_to_text();
        // Take button out until menu closed
        this.element.removeChild(this.button_elt);
        // Rise widget to top by moving it to last position among siblings
        this.element.parentNode.appendChild(this.element.parentNode.removeChild(this.element));
        // disable interaction with background
        svg_root.addEventListener("click", this.bound_close_on_click_elsewhere, true);
        // mark as open
        this.opened = true;
    },
    // Put text element in normalized state
    reset_text: function(){
        let txt = this.text_elt; 
        let first = txt.firstElementChild;
        // remove attribute eventually added to first text line while opening
        first.removeAttribute("onclick");
        first.removeAttribute("dy");
        // keep only the first line of text
        for(let span of Array.from(txt.children).slice(1)){
            txt.removeChild(span)
        }
    },
    // Put rectangle element in saved original state
    reset_box: function(){
        let m = this.box_bbox;
        let b = this.box_elt;
        b.x.baseVal.value = m.x;
        b.y.baseVal.value = m.y;
        b.width.baseVal.value = m.width;
        b.height.baseVal.value = m.height;
    },
    // Use margin and text size to compute box size
    adjust_box_to_text: function(){
        let [lmargin, tmargin] = this.margins;
        let m = this.text_elt.getBBox();
        let b = this.box_elt;
        b.x.baseVal.value = m.x - lmargin;
        b.y.baseVal.value = m.y - tmargin;
        b.width.baseVal.value = 2 * lmargin + m.width;
        b.height.baseVal.value = 2 * tmargin + m.height;
    },
||
}