svghmi/widget_dropdown.ysl2
author Edouard Tisserant <edouard.tisserant@gmail.com>
Fri, 12 Feb 2021 22:00:07 +0100
branchsvghmi
changeset 3136 784c839d4259
parent 3133 450cd01324ad
child 3232 7bdb766c2a4d
permissions -rw-r--r--
SVGHMI: Add a robust ScrollBar widget. HMI:ScrollBar@positionrange@size
// widget_dropdown.ysl2

template "widget[@type='DropDown']", mode="widget_class"{
||
    function numb_event(e) {
        e.stopPropagation();
    }
    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()
            this.highlight_bbox = this.highlight_elt.getBBox()
            this.highlight_elt.style.visibility = "hidden";

            // Compute margins
            this.text_bbox = this.text_elt.getBBox();
            let lmargin = this.text_bbox.x - this.box_bbox.x;
            let tmargin = this.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;
            this.clickables = [];
        }
        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 as 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([this.text_elt, this.element].indexOf(e.target.parentNode) == -1){
                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("pointerdown", numb_event, true);
            svg_root.removeEventListener("pointerup", numb_event, true);
            svg_root.removeEventListener("click", this.bound_close_on_click_elsewhere, true);
            // Restore position and sixe of widget elements
            this.reset_text();
            this.reset_clickables();
            this.reset_box();
            this.reset_highlight();
            // 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();
        }
        // Make item (text span) clickable by overlaying a rectangle on top of it
        make_clickable(span, func) {
            let txt = this.text_elt;
            let original_text_y = this.text_bbox.y;
            let highlight = this.highlight_elt;
            let original_h_y = this.highlight_bbox.y;
            let clickable = highlight.cloneNode();
            let yoffset = span.getBBox().y - original_text_y;
            clickable.y.baseVal.value = original_h_y + yoffset;
            clickable.style.pointerEvents = "bounding-box";
            //clickable.style.visibility = "hidden";
            //clickable.onclick = () => alert("love JS");
            clickable.onclick = func;
            this.element.appendChild(clickable);
            this.clickables.push(clickable)
        }
        reset_clickables() {
            while(this.clickables.length){
                this.element.removeChild(this.clickables.pop());
            }
        }
        // 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;
                this.make_clickable(span, (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 prsence of scroll buttons
            // since we scroll there is necessarly one button
            spanslength--;
            if(forward){
                // reduce accounted menu size because of back button
                // in current view
                if(this.menu_offset > 0) spanslength--;
                this.menu_offset = Math.min(
                    contentlength - spans.length + 1,
                    this.menu_offset + spanslength);
            }else{
                // reduce accounted menu size because of back button
                // in view once scrolled
                if(this.menu_offset - spanslength > 0) spanslength--;
                this.menu_offset = Math.max(
                    0,
                    this.menu_offset - spanslength);
            }
            if(this.menu_offset == 1)
                this.menu_offset = 0;

            this.reset_highlight();

            this.reset_clickables();
            this.set_partial_text();

            this.highlight_selection();
        }
        // 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;
            let m = this.box_bbox;
            while(c < spanslength){
                let span=spans[c];
                let onclickfunc;
                // backward jump only present if not exactly at start
                if(c == 0 && i != 0){
                    span.textContent = "▲";
                    onclickfunc = this.bound_on_backward_click;
                    let o = span.getBBox();
                    span.setAttribute("dx", (m.width - o.width)/2);
                // presence of forward jump when not right at the end
                }else if(c == spanslength-1 && i < contentlength - 1){
                    span.textContent = "▼";
                    onclickfunc = this.bound_on_forward_click;
                    let o = span.getBBox();
                    span.setAttribute("dx", (m.width - o.width)/2);
                // otherwise normal content
                }else{
                    span.textContent = this.content[i];
                    let sel = i;
                    onclickfunc = (evt) => this.bound_on_selection_click(sel);
                    span.removeAttribute("dx");
                    i++;
                }
                this.make_clickable(span, onclickfunc);
                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("pointerdown", numb_event, true);
            svg_root.addEventListener("pointerup", numb_event, true);
            svg_root.addEventListener("click", this.bound_close_on_click_elsewhere, true);
            this.highlight_selection();

            // 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.onclick = null;
            first.removeAttribute("dy");
            first.removeAttribute("dx");
            // 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;
        }
        highlight_selection(){
            if(this.last_selection == undefined) return;
            let highlighted_row = this.last_selection - this.menu_offset;
            if(highlighted_row < 0) return;
            let spans = this.text_elt.children;
            let spanslength = spans.length;
            let contentlength = this.content.length;
            if(this.menu_offset != 0) {
                spanslength--;
                highlighted_row++;
            }
            if(this.menu_offset + spanslength < contentlength - 1) spanslength--;
            if(highlighted_row > spanslength) return;
            let original_text_y = this.text_bbox.y;
            let highlight = this.highlight_elt;
            let span = spans[highlighted_row];
            let yoffset = span.getBBox().y - original_text_y;
            highlight.y.baseVal.value = this.highlight_bbox.y + yoffset;
            highlight.style.visibility = "visible";
        }
        reset_highlight(){
            let highlight = this.highlight_elt;
            highlight.y.baseVal.value = this.highlight_bbox.y;
            highlight.style.visibility = "hidden";
        }
        // 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 highlight");
    // It is assumed that list content conforms to Array interface.
    >   content:
    choose{
        // special case when used for language selection
        when "count(arg) = 1 and arg[1]/@value = '#langs'" {
            > langs
        }
        otherwise {
            > [\n
            foreach "arg" | "«@value»",
            >   ]
        }
    }
    > ,\n
}