// widget_dropdown.ysl2 widget_desc("DropDown") { longdesc || DropDown widget let user select an entry in a list of texts, given as arguments. Single variable path is index of selection. It needs "text" (svg:text or svg:use referring to svg:text), "box" (svg:rect), "button" (svg:*), and "highlight" (svg:rect) labeled elements. When user clicks on "button", "text" is duplicated to display enties in the limit of available space in page, and "box" is extended to contain all texts. "highlight" is moved over pre-selected entry. When only one argument is given and argment contains "#langs" then list of texts is automatically set to the human-readable list of supported languages by this HMI. If "text" labeled element is of type svg:use and refers to a svg:text element part of a TextList widget, no argument is expected. In that case list of texts is set to TextList content. || shortdesc > Let user select text entry in a drop-down menu arg name="entries" count="many" accepts="string" > drop-down menu entries path name="selection" accepts="HMI_INT" > selection index } // TODO: support i18n of menu entries using svg:text elements with labels starting with "_" widget_class("DropDown") { || dispatch(value) { if(!this.opened) this.set_selection(value); } init() { this.init_specific(); 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 = gettext(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", this.numb_event, true); svg_root.removeEventListener("pointerup", this.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 = gettext(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 = gettext(this.content[i]); let sel = i; onclickfunc = (evt) => this.bound_on_selection_click(sel); span.removeAttribute("dx"); i++; } this.make_clickable(span, onclickfunc); c++; } } numb_event(e) { e.stopPropagation(); } 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", this.numb_event, true); svg_root.addEventListener("pointerup", this.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; } || } widget_defs("DropDown") { labels("box button highlight"); // It is assumed that list content conforms to Array interface. const "text_elt","$hmi_element//*[@inkscape:label='text'][1]"; | init_specific: function() { choose{ // special case when used for language selection when "count(arg) = 1 and arg[1]/@value = '#langs'" { | this.text_elt = id("«$text_elt/@id»"); | this.content = langs; } when "count(arg) = 0"{ if "not($text_elt[self::svg:use])" error > No argrument for HMI:DropDown widget id="«$hmi_element/@id»" and "text" labeled element is not a svg:use element const "real_text_elt","$result_widgets[@id = $hmi_element/@id]//*[@original=$text_elt/@id]/svg:text"; | this.text_elt = id("«$real_text_elt/@id»"); const "from_list_id", "substring-after($text_elt/@xlink:href,'#')"; const "from_list", "$hmi_textlists[(@id | */@id) = $from_list_id]"; if "count($from_list) = 0" error > HMI:DropDown widget id="«$hmi_element/@id»" "text" labeled element does not point to a svg:text owned by a HMI:List widget | this.content = hmi_widgets["«$from_list/@id»"].texts; } otherwise { | this.text_elt = id("«$text_elt/@id»"); | this.content = [ foreach "arg" | "«@value»", | ]; } } | } } emit "declarations:DropDown" || function gettext(o) { if(typeof(o) == "string"){ return o; } return svg_text_to_multiline(o); }; ||