svghmi/widget_dropdown.ysl2
author Edouard Tisserant
Tue, 15 Dec 2020 13:43:21 +0100
branchsvghmi
changeset 3090 9e172e4e50c7
parent 3035 d1fc8c55c1d3
child 3091 f475f39713aa
permissions -rw-r--r--
SVGHMI: DropDown widget now using new class based style
// widget_dropdown.ysl2

template "widget[@type='DropDown']", mode="widget_class"{
||
    class DropDownWidget extends Widget{
        dispatch(value) {
            if(!this.opened) this.set_selection(value);
        }
        init() {
            this.button_elt.onclick = this.on_button_click.bind(this);
            // Save original size of rectangle
            this.box_bbox = this.box_elt.getBBox()

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

            // 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.bound_on_selection_click = this.on_selection_click.bind(this);
            this.bound_on_backward_click = this.on_backward_click.bind(this);
            this.bound_on_forward_click = this.on_forward_click.bind(this);
            this.opened = false;
        }
        on_button_click() {
            this.open();
        }
        // Called when a menu entry is clicked
        on_selection_click(selection) {
            this.close();
            this.apply_hmi_value(0, selection);
        }
        on_backward_click(){
            this.scroll(false);
        }
        on_forward_click(){
            this.scroll(true);
        }
        set_selection(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(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(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(){
            // 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(){
            let spans = this.text_elt.children; 
            let c = 0;
            for(let item of this.content){
                let span=spans[c];
                span.textContent = item;
                let sel = c;
                span.onclick = (evt) => this.bound_on_selection_click(sel);
                c++;
            }
        }
        // Move partial view :
        // false : upward, lower value
        // true  : downward, higher value
        scroll(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);
            }
            this.set_partial_text();
        }
        // Setup partial view text content
        // with jumps at first and last entry when appropriate
        set_partial_text(){
            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.onclick = this.bound_on_backward_click;
                // presence of forward jump when not right at the end
                }else if(c == spanslength-1 && i < contentlength - 1){
                    span.textContent = "↓  ↓  ↓";
                    span.onclick = this.bound_on_forward_click;
                // otherwise normal content
                }else{
                    span.textContent = this.content[i];
                    let sel = i;
                    span.onclick = (evt) => this.bound_on_selection_click(sel);
                    i++;
                }
                c++;
            }
        }
        open(){
            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(){
            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(){
            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(){
            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;
        }
    }
||
}
template "widget[@type='DropDown']", mode="widget_defs" {
    param "hmi_element";
    labels("text box button");
||
    // It is assumed that list content conforms to Array interface.
    content: [
    ``foreach "arg" | "«@value»",
    ],

||
}