1 // widget_dropdown.ysl2 |
1 // widget_dropdown.ysl2 |
2 |
2 |
|
3 template "widget[@type='DropDown']", mode="widget_class"{ |
|
4 || |
|
5 class DropDownWidget extends Widget{ |
|
6 dispatch(value) { |
|
7 if(!this.opened) this.set_selection(value); |
|
8 } |
|
9 init() { |
|
10 this.button_elt.onclick = this.on_button_click.bind(this); |
|
11 // Save original size of rectangle |
|
12 this.box_bbox = this.box_elt.getBBox() |
|
13 |
|
14 // Compute margins |
|
15 let text_bbox = this.text_elt.getBBox(); |
|
16 let lmargin = text_bbox.x - this.box_bbox.x; |
|
17 let tmargin = text_bbox.y - this.box_bbox.y; |
|
18 this.margins = [lmargin, tmargin].map(x => Math.max(x,0)); |
|
19 |
|
20 // Index of first visible element in the menu, when opened |
|
21 this.menu_offset = 0; |
|
22 |
|
23 // How mutch to lift the menu vertically so that it does not cross bottom border |
|
24 this.lift = 0; |
|
25 |
|
26 // Event handlers cannot be object method ('this' is unknown) |
|
27 // as a workaround, handler given to addEventListener is bound in advance. |
|
28 this.bound_close_on_click_elsewhere = this.close_on_click_elsewhere.bind(this); |
|
29 this.bound_on_selection_click = this.on_selection_click.bind(this); |
|
30 this.bound_on_backward_click = this.on_backward_click.bind(this); |
|
31 this.bound_on_forward_click = this.on_forward_click.bind(this); |
|
32 this.opened = false; |
|
33 } |
|
34 on_button_click() { |
|
35 this.open(); |
|
36 } |
|
37 // Called when a menu entry is clicked |
|
38 on_selection_click(selection) { |
|
39 this.close(); |
|
40 this.apply_hmi_value(0, selection); |
|
41 } |
|
42 on_backward_click(){ |
|
43 this.scroll(false); |
|
44 } |
|
45 on_forward_click(){ |
|
46 this.scroll(true); |
|
47 } |
|
48 set_selection(value) { |
|
49 let display_str; |
|
50 if(value >= 0 && value < this.content.length){ |
|
51 // if valid selection resolve content |
|
52 display_str = this.content[value]; |
|
53 this.last_selection = value; |
|
54 } else { |
|
55 // otherwise show problem |
|
56 display_str = "?"+String(value)+"?"; |
|
57 } |
|
58 // It is assumed that first span always stays, |
|
59 // and contains selection when menu is closed |
|
60 this.text_elt.firstElementChild.textContent = display_str; |
|
61 } |
|
62 grow_text(up_to) { |
|
63 let count = 1; |
|
64 let txt = this.text_elt; |
|
65 let first = txt.firstElementChild; |
|
66 // Real world (pixels) boundaries of current page |
|
67 let bounds = svg_root.getBoundingClientRect(); |
|
68 this.lift = 0; |
|
69 while(count < up_to) { |
|
70 let next = first.cloneNode(); |
|
71 // relative line by line text flow instead of absolute y coordinate |
|
72 next.removeAttribute("y"); |
|
73 next.setAttribute("dy", "1.1em"); |
|
74 // default content to allow computing text element bbox |
|
75 next.textContent = "..."; |
|
76 // append new span to text element |
|
77 txt.appendChild(next); |
|
78 // now check if text extended by one row fits to page |
|
79 // FIXME : exclude margins to be more accurate on box size |
|
80 let rect = txt.getBoundingClientRect(); |
|
81 if(rect.bottom > bounds.bottom){ |
|
82 // in case of overflow at the bottom, lift up one row |
|
83 let backup = first.getAttribute("dy"); |
|
84 // apply lift asr a dy added too first span (y attrib stays) |
|
85 first.setAttribute("dy", "-"+String((this.lift+1)*1.1)+"em"); |
|
86 rect = txt.getBoundingClientRect(); |
|
87 if(rect.top > bounds.top){ |
|
88 this.lift += 1; |
|
89 } else { |
|
90 // if it goes over the top, then backtrack |
|
91 // restore dy attribute on first span |
|
92 if(backup) |
|
93 first.setAttribute("dy", backup); |
|
94 else |
|
95 first.removeAttribute("dy"); |
|
96 // remove unwanted child |
|
97 txt.removeChild(next); |
|
98 return count; |
|
99 } |
|
100 } |
|
101 count++; |
|
102 } |
|
103 return count; |
|
104 } |
|
105 close_on_click_elsewhere(e) { |
|
106 // inhibit events not targetting spans (menu items) |
|
107 if(e.target.parentNode !== this.text_elt){ |
|
108 e.stopPropagation(); |
|
109 // close menu in case click is outside box |
|
110 if(e.target !== this.box_elt) |
|
111 this.close(); |
|
112 } |
|
113 } |
|
114 close(){ |
|
115 // Stop hogging all click events |
|
116 svg_root.removeEventListener("click", this.bound_close_on_click_elsewhere, true); |
|
117 // Restore position and sixe of widget elements |
|
118 this.reset_text(); |
|
119 this.reset_box(); |
|
120 // Put the button back in place |
|
121 this.element.appendChild(this.button_elt); |
|
122 // Mark as closed (to allow dispatch) |
|
123 this.opened = false; |
|
124 // Dispatch last cached value |
|
125 this.apply_cache(); |
|
126 } |
|
127 // Set text content when content is smaller than menu (no scrolling) |
|
128 set_complete_text(){ |
|
129 let spans = this.text_elt.children; |
|
130 let c = 0; |
|
131 for(let item of this.content){ |
|
132 let span=spans[c]; |
|
133 span.textContent = item; |
|
134 let sel = c; |
|
135 span.onclick = (evt) => this.bound_on_selection_click(sel); |
|
136 c++; |
|
137 } |
|
138 } |
|
139 // Move partial view : |
|
140 // false : upward, lower value |
|
141 // true : downward, higher value |
|
142 scroll(forward){ |
|
143 let contentlength = this.content.length; |
|
144 let spans = this.text_elt.children; |
|
145 let spanslength = spans.length; |
|
146 // reduce accounted menu size according to jumps |
|
147 if(this.menu_offset != 0) spanslength--; |
|
148 if(this.menu_offset < contentlength - 1) spanslength--; |
|
149 if(forward){ |
|
150 this.menu_offset = Math.min( |
|
151 contentlength - spans.length + 1, |
|
152 this.menu_offset + spanslength); |
|
153 }else{ |
|
154 this.menu_offset = Math.max( |
|
155 0, |
|
156 this.menu_offset - spanslength); |
|
157 } |
|
158 this.set_partial_text(); |
|
159 } |
|
160 // Setup partial view text content |
|
161 // with jumps at first and last entry when appropriate |
|
162 set_partial_text(){ |
|
163 let spans = this.text_elt.children; |
|
164 let contentlength = this.content.length; |
|
165 let spanslength = spans.length; |
|
166 let i = this.menu_offset, c = 0; |
|
167 while(c < spanslength){ |
|
168 let span=spans[c]; |
|
169 // backward jump only present if not exactly at start |
|
170 if(c == 0 && i != 0){ |
|
171 span.textContent = "↑ ↑ ↑"; |
|
172 span.onclick = this.bound_on_backward_click; |
|
173 // presence of forward jump when not right at the end |
|
174 }else if(c == spanslength-1 && i < contentlength - 1){ |
|
175 span.textContent = "↓ ↓ ↓"; |
|
176 span.onclick = this.bound_on_forward_click; |
|
177 // otherwise normal content |
|
178 }else{ |
|
179 span.textContent = this.content[i]; |
|
180 let sel = i; |
|
181 span.onclick = (evt) => this.bound_on_selection_click(sel); |
|
182 i++; |
|
183 } |
|
184 c++; |
|
185 } |
|
186 } |
|
187 open(){ |
|
188 let length = this.content.length; |
|
189 // systematically reset text, to strip eventual whitespace spans |
|
190 this.reset_text(); |
|
191 // grow as much as needed or possible |
|
192 let slots = this.grow_text(length); |
|
193 // Depending on final size |
|
194 if(slots == length) { |
|
195 // show all at once |
|
196 this.set_complete_text(); |
|
197 } else { |
|
198 // eventualy align menu to current selection, compensating for lift |
|
199 let offset = this.last_selection - this.lift; |
|
200 if(offset > 0) |
|
201 this.menu_offset = Math.min(offset + 1, length - slots + 1); |
|
202 else |
|
203 this.menu_offset = 0; |
|
204 // show surrounding values |
|
205 this.set_partial_text(); |
|
206 } |
|
207 // Now that text size is known, we can set the box around it |
|
208 this.adjust_box_to_text(); |
|
209 // Take button out until menu closed |
|
210 this.element.removeChild(this.button_elt); |
|
211 // Rise widget to top by moving it to last position among siblings |
|
212 this.element.parentNode.appendChild(this.element.parentNode.removeChild(this.element)); |
|
213 // disable interaction with background |
|
214 svg_root.addEventListener("click", this.bound_close_on_click_elsewhere, true); |
|
215 // mark as open |
|
216 this.opened = true; |
|
217 } |
|
218 // Put text element in normalized state |
|
219 reset_text(){ |
|
220 let txt = this.text_elt; |
|
221 let first = txt.firstElementChild; |
|
222 // remove attribute eventually added to first text line while opening |
|
223 first.removeAttribute("onclick"); |
|
224 first.removeAttribute("dy"); |
|
225 // keep only the first line of text |
|
226 for(let span of Array.from(txt.children).slice(1)){ |
|
227 txt.removeChild(span) |
|
228 } |
|
229 } |
|
230 // Put rectangle element in saved original state |
|
231 reset_box(){ |
|
232 let m = this.box_bbox; |
|
233 let b = this.box_elt; |
|
234 b.x.baseVal.value = m.x; |
|
235 b.y.baseVal.value = m.y; |
|
236 b.width.baseVal.value = m.width; |
|
237 b.height.baseVal.value = m.height; |
|
238 } |
|
239 // Use margin and text size to compute box size |
|
240 adjust_box_to_text(){ |
|
241 let [lmargin, tmargin] = this.margins; |
|
242 let m = this.text_elt.getBBox(); |
|
243 let b = this.box_elt; |
|
244 b.x.baseVal.value = m.x - lmargin; |
|
245 b.y.baseVal.value = m.y - tmargin; |
|
246 b.width.baseVal.value = 2 * lmargin + m.width; |
|
247 b.height.baseVal.value = 2 * tmargin + m.height; |
|
248 } |
|
249 } |
|
250 || |
|
251 } |
3 template "widget[@type='DropDown']", mode="widget_defs" { |
252 template "widget[@type='DropDown']", mode="widget_defs" { |
4 param "hmi_element"; |
253 param "hmi_element"; |
5 labels("text box button"); |
254 labels("text box button"); |
6 || |
255 || |
7 dispatch: function(value) { |
256 // It is assumed that list content conforms to Array interface. |
8 if(!this.opened) this.set_selection(value); |
257 content: [ |
9 }, |
258 ``foreach "arg" | "«@value»", |
10 init: function() { |
259 ], |
11 this.button_elt.setAttribute("onclick", "hmi_widgets['«$hmi_element/@id»'].on_button_click()"); |
260 |
12 // Save original size of rectangle |
|
13 this.box_bbox = this.box_elt.getBBox() |
|
14 |
|
15 // Compute margins |
|
16 text_bbox = this.text_elt.getBBox() |
|
17 lmargin = text_bbox.x - this.box_bbox.x; |
|
18 tmargin = text_bbox.y - this.box_bbox.y; |
|
19 this.margins = [lmargin, tmargin].map(x => Math.max(x,0)); |
|
20 |
|
21 // It is assumed that list content conforms to Array interface. |
|
22 this.content = [ |
|
23 ``foreach "arg" | "«@value»", |
|
24 ]; |
|
25 |
|
26 // Index of first visible element in the menu, when opened |
|
27 this.menu_offset = 0; |
|
28 |
|
29 // How mutch to lift the menu vertically so that it does not cross bottom border |
|
30 this.lift = 0; |
|
31 |
|
32 // Event handlers cannot be object method ('this' is unknown) |
|
33 // as a workaround, handler given to addEventListener is bound in advance. |
|
34 this.bound_close_on_click_elsewhere = this.close_on_click_elsewhere.bind(this); |
|
35 |
|
36 this.opened = false; |
|
37 }, |
|
38 // Called when a menu entry is clicked |
|
39 on_selection_click: function(selection) { |
|
40 this.close(); |
|
41 this.apply_hmi_value(0, selection); |
|
42 }, |
|
43 on_button_click: function() { |
|
44 this.open(); |
|
45 }, |
|
46 on_backward_click: function(){ |
|
47 this.scroll(false); |
|
48 }, |
|
49 on_forward_click:function(){ |
|
50 this.scroll(true); |
|
51 }, |
|
52 set_selection: function(value) { |
|
53 let display_str; |
|
54 if(value >= 0 && value < this.content.length){ |
|
55 // if valid selection resolve content |
|
56 display_str = this.content[value]; |
|
57 this.last_selection = value; |
|
58 } else { |
|
59 // otherwise show problem |
|
60 display_str = "?"+String(value)+"?"; |
|
61 } |
|
62 // It is assumed that first span always stays, |
|
63 // and contains selection when menu is closed |
|
64 this.text_elt.firstElementChild.textContent = display_str; |
|
65 }, |
|
66 grow_text: function(up_to) { |
|
67 let count = 1; |
|
68 let txt = this.text_elt; |
|
69 let first = txt.firstElementChild; |
|
70 // Real world (pixels) boundaries of current page |
|
71 let bounds = svg_root.getBoundingClientRect(); |
|
72 this.lift = 0; |
|
73 while(count < up_to) { |
|
74 let next = first.cloneNode(); |
|
75 // relative line by line text flow instead of absolute y coordinate |
|
76 next.removeAttribute("y"); |
|
77 next.setAttribute("dy", "1.1em"); |
|
78 // default content to allow computing text element bbox |
|
79 next.textContent = "..."; |
|
80 // append new span to text element |
|
81 txt.appendChild(next); |
|
82 // now check if text extended by one row fits to page |
|
83 // FIXME : exclude margins to be more accurate on box size |
|
84 let rect = txt.getBoundingClientRect(); |
|
85 if(rect.bottom > bounds.bottom){ |
|
86 // in case of overflow at the bottom, lift up one row |
|
87 let backup = first.getAttribute("dy"); |
|
88 // apply lift asr a dy added too first span (y attrib stays) |
|
89 first.setAttribute("dy", "-"+String((this.lift+1)*1.1)+"em"); |
|
90 rect = txt.getBoundingClientRect(); |
|
91 if(rect.top > bounds.top){ |
|
92 this.lift += 1; |
|
93 } else { |
|
94 // if it goes over the top, then backtrack |
|
95 // restore dy attribute on first span |
|
96 if(backup) |
|
97 first.setAttribute("dy", backup); |
|
98 else |
|
99 first.removeAttribute("dy"); |
|
100 // remove unwanted child |
|
101 txt.removeChild(next); |
|
102 return count; |
|
103 } |
|
104 } |
|
105 count++; |
|
106 } |
|
107 return count; |
|
108 }, |
|
109 close_on_click_elsewhere: function(e) { |
|
110 // inhibit events not targetting spans (menu items) |
|
111 if(e.target.parentNode !== this.text_elt){ |
|
112 e.stopPropagation(); |
|
113 // close menu in case click is outside box |
|
114 if(e.target !== this.box_elt) |
|
115 this.close(); |
|
116 } |
|
117 }, |
|
118 close: function(){ |
|
119 // Stop hogging all click events |
|
120 svg_root.removeEventListener("click", this.bound_close_on_click_elsewhere, true); |
|
121 // Restore position and sixe of widget elements |
|
122 this.reset_text(); |
|
123 this.reset_box(); |
|
124 // Put the button back in place |
|
125 this.element.appendChild(this.button_elt); |
|
126 // Mark as closed (to allow dispatch) |
|
127 this.opened = false; |
|
128 // Dispatch last cached value |
|
129 this.apply_cache(); |
|
130 }, |
|
131 // Set text content when content is smaller than menu (no scrolling) |
|
132 set_complete_text: function(){ |
|
133 let spans = this.text_elt.children; |
|
134 let c = 0; |
|
135 for(let item of this.content){ |
|
136 let span=spans[c]; |
|
137 span.textContent = item; |
|
138 span.setAttribute("onclick", "hmi_widgets['«$hmi_element/@id»'].on_selection_click("+c+")"); |
|
139 c++; |
|
140 } |
|
141 }, |
|
142 // Move partial view : |
|
143 // false : upward, lower value |
|
144 // true : downward, higher value |
|
145 scroll: function(forward){ |
|
146 let contentlength = this.content.length; |
|
147 let spans = this.text_elt.children; |
|
148 let spanslength = spans.length; |
|
149 // reduce accounted menu size according to jumps |
|
150 if(this.menu_offset != 0) spanslength--; |
|
151 if(this.menu_offset < contentlength - 1) spanslength--; |
|
152 if(forward){ |
|
153 this.menu_offset = Math.min( |
|
154 contentlength - spans.length + 1, |
|
155 this.menu_offset + spanslength); |
|
156 }else{ |
|
157 this.menu_offset = Math.max( |
|
158 0, |
|
159 this.menu_offset - spanslength); |
|
160 } |
|
161 this.set_partial_text(); |
|
162 }, |
|
163 // Setup partial view text content |
|
164 // with jumps at first and last entry when appropriate |
|
165 set_partial_text: function(){ |
|
166 let spans = this.text_elt.children; |
|
167 let contentlength = this.content.length; |
|
168 let spanslength = spans.length; |
|
169 let i = this.menu_offset, c = 0; |
|
170 while(c < spanslength){ |
|
171 let span=spans[c]; |
|
172 // backward jump only present if not exactly at start |
|
173 if(c == 0 && i != 0){ |
|
174 span.textContent = "↑ ↑ ↑"; |
|
175 span.setAttribute("onclick", "hmi_widgets['«$hmi_element/@id»'].on_backward_click()"); |
|
176 // presence of forward jump when not right at the end |
|
177 }else if(c == spanslength-1 && i < contentlength - 1){ |
|
178 span.textContent = "↓ ↓ ↓"; |
|
179 span.setAttribute("onclick", "hmi_widgets['«$hmi_element/@id»'].on_forward_click()"); |
|
180 // otherwise normal content |
|
181 }else{ |
|
182 span.textContent = this.content[i]; |
|
183 span.setAttribute("onclick", "hmi_widgets['«$hmi_element/@id»'].on_selection_click("+i+")"); |
|
184 i++; |
|
185 } |
|
186 c++; |
|
187 } |
|
188 }, |
|
189 open: function(){ |
|
190 let length = this.content.length; |
|
191 // systematically reset text, to strip eventual whitespace spans |
|
192 this.reset_text(); |
|
193 // grow as much as needed or possible |
|
194 let slots = this.grow_text(length); |
|
195 // Depending on final size |
|
196 if(slots == length) { |
|
197 // show all at once |
|
198 this.set_complete_text(); |
|
199 } else { |
|
200 // eventualy align menu to current selection, compensating for lift |
|
201 let offset = this.last_selection - this.lift; |
|
202 if(offset > 0) |
|
203 this.menu_offset = Math.min(offset + 1, length - slots + 1); |
|
204 else |
|
205 this.menu_offset = 0; |
|
206 // show surrounding values |
|
207 this.set_partial_text(); |
|
208 } |
|
209 // Now that text size is known, we can set the box around it |
|
210 this.adjust_box_to_text(); |
|
211 // Take button out until menu closed |
|
212 this.element.removeChild(this.button_elt); |
|
213 // Rise widget to top by moving it to last position among siblings |
|
214 this.element.parentNode.appendChild(this.element.parentNode.removeChild(this.element)); |
|
215 // disable interaction with background |
|
216 svg_root.addEventListener("click", this.bound_close_on_click_elsewhere, true); |
|
217 // mark as open |
|
218 this.opened = true; |
|
219 }, |
|
220 // Put text element in normalized state |
|
221 reset_text: function(){ |
|
222 let txt = this.text_elt; |
|
223 let first = txt.firstElementChild; |
|
224 // remove attribute eventually added to first text line while opening |
|
225 first.removeAttribute("onclick"); |
|
226 first.removeAttribute("dy"); |
|
227 // keep only the first line of text |
|
228 for(let span of Array.from(txt.children).slice(1)){ |
|
229 txt.removeChild(span) |
|
230 } |
|
231 }, |
|
232 // Put rectangle element in saved original state |
|
233 reset_box: function(){ |
|
234 let m = this.box_bbox; |
|
235 let b = this.box_elt; |
|
236 b.x.baseVal.value = m.x; |
|
237 b.y.baseVal.value = m.y; |
|
238 b.width.baseVal.value = m.width; |
|
239 b.height.baseVal.value = m.height; |
|
240 }, |
|
241 // Use margin and text size to compute box size |
|
242 adjust_box_to_text: function(){ |
|
243 let [lmargin, tmargin] = this.margins; |
|
244 let m = this.text_elt.getBBox(); |
|
245 let b = this.box_elt; |
|
246 b.x.baseVal.value = m.x - lmargin; |
|
247 b.y.baseVal.value = m.y - tmargin; |
|
248 b.width.baseVal.value = 2 * lmargin + m.width; |
|
249 b.height.baseVal.value = 2 * tmargin + m.height; |
|
250 }, |
|
251 || |
261 || |
252 } |
262 } |