decred.org/dcrdex@v1.0.3/client/webserver/site/src/js/opts.ts (about) 1 import Doc from './doc' 2 import { 3 PageElement, 4 XYRange, 5 OrderOption 6 } from './registry' 7 8 interface OptionsReporters { 9 enable: () => void 10 disable: () => void 11 } 12 13 // Having the caller set these vars on load using an exported function makes 14 // life easier. 15 let orderOptTmpl: HTMLElement, booleanOptTmpl: HTMLElement, rangeOptTmpl: HTMLElement 16 17 // setOptionTemplates sets the package vars for the templates and application. 18 export function setOptionTemplates (page: Record<string, PageElement>): void { 19 [booleanOptTmpl, rangeOptTmpl, orderOptTmpl] = [page.booleanOptTmpl, page.rangeOptTmpl, page.orderOptTmpl] 20 } 21 22 const threeSigFigs = new Intl.NumberFormat(Doc.languages(), { 23 minimumSignificantDigits: 3, 24 maximumSignificantDigits: 3 25 }) 26 27 /* 28 * Option is a base class for option elements. Option stores some common 29 * parameters and monitors the toggle switch, calling the child class's 30 * enable/disable methods when the user manually turns the option on or off. 31 */ 32 export class Option { 33 opt: OrderOption 34 node: HTMLElement 35 tmpl: Record<string, PageElement> 36 on: boolean 37 38 constructor (opt: OrderOption, symbol: string, report: OptionsReporters) { 39 this.opt = opt 40 const node = this.node = orderOptTmpl.cloneNode(true) as HTMLElement 41 const tmpl = this.tmpl = Doc.parseTemplate(node) 42 43 tmpl.optName.textContent = opt.displayname 44 tmpl.tooltip.dataset.tooltip = opt.description 45 46 // const isBaseChain = (isSwapOption && order.sell) || (!isSwapOption && !order.sell) 47 // const symbol = isBaseChain ? this.baseSymbol() : this.quoteSymbol() 48 if (symbol) tmpl.chainIcon.src = Doc.logoPath(symbol) 49 else Doc.hide(tmpl.chainIcon) 50 51 this.on = false 52 Doc.bind(node, 'click', () => { 53 if (this.on) return 54 this.on = true 55 node.classList.add('selected') 56 report.enable() 57 }) 58 Doc.bind(tmpl.toggle, 'click', e => { 59 if (!this.on) return 60 e.stopPropagation() 61 this.on = false 62 node.classList.remove('selected') 63 report.disable() 64 }) 65 } 66 } 67 68 /* 69 * BooleanOption is a simple on/off option with a short summary of it's effects. 70 * BooleanOrderOption is the handler for a *BooleanConfig from client/asset. 71 */ 72 export class BooleanOption extends Option { 73 control: HTMLElement 74 changed: () => void 75 dict: Record<string, any> 76 77 constructor (opt: OrderOption, symbol: string, dict: Record<string, any>, changed: () => void) { 78 super(opt, symbol, { 79 enable: () => this.enable(), 80 disable: () => this.disable() 81 }) 82 this.dict = dict 83 this.changed = () => changed() 84 if (opt.boolean === undefined) throw Error('not a boolean opt') 85 const cfg = opt.boolean 86 const control = this.control = booleanOptTmpl.cloneNode(true) as HTMLElement 87 // Append to parent's options div. 88 this.tmpl.controls.appendChild(control) 89 const tmpl = Doc.parseTemplate(control) 90 tmpl.reason.textContent = cfg.reason 91 this.on = typeof dict[opt.key] !== 'undefined' ? dict[opt.key] : opt.default 92 if (this.on) this.node.classList.add('selected') 93 } 94 95 store (): void { 96 if (this.on === this.opt.default) delete this.dict[this.opt.key] 97 else this.dict[this.opt.key] = this.on 98 this.changed() 99 } 100 101 enable (): void { 102 this.store() 103 } 104 105 disable (): void { 106 this.store() 107 } 108 } 109 110 /* 111 * XYRangeOption is an order option that contains an XYRangeHandler. The logic 112 * for handling the slider to is defined in XYRangeHandler so that the slider 113 * can be used without being contained in an order option. 114 */ 115 export class XYRangeOption extends Option { 116 handler: XYRangeHandler 117 x: number 118 changed: () => void 119 dict: Record<string, any> 120 121 constructor (opt: OrderOption, symbol: string, dict: Record<string, any>, changed: () => void) { 122 super(opt, symbol, { 123 enable: () => this.enable(), 124 disable: () => this.disable() 125 }) 126 this.dict = dict 127 this.changed = changed 128 if (opt.xyRange === undefined) throw Error('not an xy range opt') 129 const cfg = opt.xyRange 130 const setVal = dict[opt.key] 131 this.on = typeof setVal !== 'undefined' 132 if (this.on) { 133 this.node.classList.add('selected') 134 this.x = setVal 135 } else { 136 this.x = opt.default 137 } 138 const selected = () => { this.node.classList.add('selected') } 139 this.handler = new XYRangeHandler(cfg, this.x, { changed, selected, settingsDict: dict, settingsKey: opt.key }) 140 this.tmpl.controls.appendChild(this.handler.control) 141 } 142 143 enable (): void { 144 this.dict[this.opt.key] = this.x 145 this.changed() 146 } 147 148 disable (): void { 149 delete this.dict[this.opt.key] 150 this.changed() 151 } 152 153 setValue (x: number): void { 154 this.handler.setValue(x) 155 this.on = true 156 this.node.classList.add('selected') 157 } 158 } 159 160 interface AcceptOpts { 161 skipChange?: boolean 162 skipUpdate?: boolean // Implies skipChange 163 } 164 165 interface RangeHandlerOpts { 166 roundY?: boolean 167 roundX?: boolean 168 updated?: (x:number, y:number) => void, // fires while dragging. 169 changed?: () => void, // does not fire while dragging but does when dragging ends. 170 selected?: () => void, 171 disabled?: boolean 172 settingsDict?: {[key: string]: any} 173 settingsKey?: string 174 convert?: (x: number, y: number) => any 175 } 176 177 /* 178 * XYRangeHandler is the handler for an *XYRange from client/asset. XYRange 179 * has a slider which allows adjusting the x and y, linearly between two limits. 180 * The user can also manually enter values for x or y. 181 */ 182 export class XYRangeHandler { 183 control: HTMLElement 184 range: XYRange 185 tmpl: Record<string, PageElement> 186 initVal: number 187 settingsDict?: {[key: string]: any} 188 settingsKey: string 189 x: number 190 scrollingX: number 191 y: number 192 r: number 193 roundX: boolean 194 roundY: boolean 195 disabled: boolean 196 updated: (x:number, y:number) => void // called while dragging 197 changed: () => void // not called while dragging, but called when done dragging 198 selected: () => void 199 convert: (x: number, y: number) => any 200 201 constructor ( 202 range: XYRange, 203 initVal: number, 204 opts: RangeHandlerOpts 205 ) { 206 const control = this.control = rangeOptTmpl.cloneNode(true) as HTMLElement 207 const tmpl = this.tmpl = Doc.parseTemplate(control) 208 tmpl.rangeLblStart.textContent = range.start.label 209 tmpl.rangeLblEnd.textContent = range.end.label 210 tmpl.xUnit.textContent = range.xUnit 211 tmpl.yUnit.textContent = range.yUnit 212 this.range = range 213 this.initVal = initVal 214 this.settingsDict = opts.settingsDict 215 this.settingsKey = opts.settingsKey ?? '' 216 this.roundX = Boolean(opts.roundX) 217 this.roundY = Boolean(opts.roundY) 218 219 this.setDisabled(Boolean(opts.disabled)) 220 this.changed = opts.changed ?? (() => { /* pass */ }) 221 this.selected = opts.selected ?? (() => { /* pass */ }) 222 this.updated = opts.updated ?? (() => { /* pass */ }) 223 this.convert = opts.convert || ((x: number) => x) 224 225 const { slider, handle } = tmpl 226 const rangeX = range.end.x - range.start.x 227 const rangeY = range.end.y - range.start.y 228 const normalizeX = (x: number) => (x - range.start.x) / rangeX 229 230 // r, x, and y will be updated by the various input event handlers. r is 231 // x (or y) normalized on its range, e.g. [x_min, x_max] -> [0, 1] 232 this.r = normalizeX(initVal) 233 this.scrollingX = this.x = initVal 234 this.y = this.r * rangeY + range.start.y 235 this.accept(this.scrollingX, { skipUpdate: true }) 236 237 // Set up the handlers for the x and y text input fields. 238 const clickOutX = (e: MouseEvent) => { 239 if (this.disabled) return 240 if (e.type !== 'change' && e.target === tmpl.xInput) return 241 const s = tmpl.xInput.value 242 if (s) { 243 const xx = parseFloat(s) 244 if (!isNaN(xx)) { 245 this.scrollingX = clamp(xx, range.start.x, range.end.x) 246 this.r = normalizeX(this.scrollingX) 247 this.y = this.r * rangeY + range.start.y 248 this.accept(this.scrollingX) 249 } 250 } 251 Doc.hide(tmpl.xInput) 252 Doc.show(tmpl.x) 253 Doc.unbind(document, 'click', clickOutX) 254 this.changed() 255 } 256 257 Doc.bind(tmpl.x, 'click', e => { 258 if (this.disabled) return 259 Doc.hide(tmpl.x) 260 Doc.show(tmpl.xInput) 261 tmpl.xInput.focus() 262 tmpl.xInput.value = threeSigFigs.format(this.scrollingX) 263 Doc.bind(document, 'click', clickOutX) 264 e.stopPropagation() 265 }) 266 267 Doc.bind(tmpl.xInput, 'change', clickOutX) 268 269 const clickOutY = (e: MouseEvent) => { 270 if (this.disabled) return 271 if (e.type !== 'change' && e.target === tmpl.yInput) return 272 const s = tmpl.yInput.value 273 if (s) { 274 const yy = parseFloat(s) 275 if (!isNaN(yy)) { 276 this.y = clamp(yy, range.start.y, range.end.y) 277 this.r = (this.y - range.start.y) / rangeY 278 this.scrollingX = range.start.x + this.r * rangeX 279 this.accept(this.scrollingX) 280 } 281 } 282 Doc.hide(tmpl.yInput) 283 Doc.show(tmpl.y) 284 Doc.unbind(document, 'click', clickOutY) 285 this.changed() 286 } 287 288 Doc.bind(tmpl.y, 'click', e => { 289 if (this.disabled) return 290 Doc.hide(tmpl.y) 291 Doc.show(tmpl.yInput) 292 tmpl.yInput.focus() 293 tmpl.yInput.value = threeSigFigs.format(this.y) 294 Doc.bind(document, 'click', clickOutY) 295 e.stopPropagation() 296 }) 297 298 Doc.bind(tmpl.yInput, 'change', clickOutY) 299 300 // Read the slider. 301 Doc.bind(handle, 'mousedown', (e: MouseEvent) => { 302 if (this.disabled) return 303 if (e.button !== 0) return 304 e.preventDefault() 305 e.stopPropagation() 306 this.selected() 307 const startX = e.pageX 308 const w = slider.clientWidth - handle.offsetWidth 309 const startLeft = normalizeX(this.scrollingX) * w 310 const left = (ee: MouseEvent) => Math.max(Math.min(startLeft + (ee.pageX - startX), w), 0) 311 const trackMouse = (ee: MouseEvent, emit?: boolean) => { 312 ee.preventDefault() 313 this.r = left(ee) / w 314 this.scrollingX = this.r * rangeX + range.start.x 315 this.y = this.r * rangeY + range.start.y 316 this.accept(this.scrollingX, { skipChange: !emit }) 317 } 318 const mouseUp = (ee: MouseEvent) => { 319 trackMouse(ee, true) 320 Doc.unbind(document, 'mousemove', trackMouse) 321 Doc.unbind(document, 'mouseup', mouseUp) 322 this.changed() 323 } 324 Doc.bind(document, 'mousemove', trackMouse) 325 Doc.bind(document, 'mouseup', mouseUp) 326 }) 327 328 Doc.bind(tmpl.sliderBox, 'click', (e: MouseEvent) => { 329 if (this.disabled) return 330 if (e.button !== 0) return 331 const x = e.pageX 332 const m = Doc.layoutMetrics(tmpl.slider) 333 this.r = clamp((x - m.bodyLeft) / m.width, 0, 1) 334 this.scrollingX = this.r * rangeX + range.start.x 335 this.y = this.r * rangeY + range.start.y 336 this.accept(this.scrollingX) 337 }) 338 } 339 340 setDisabled (disabled: boolean) { 341 this.control.classList.toggle('disabled', disabled) 342 this.disabled = disabled 343 } 344 345 setXLabel (s: string) { 346 this.tmpl.x.textContent = s 347 } 348 349 setYLabel (s: string) { 350 this.tmpl.y.textContent = s 351 } 352 353 accept (x: number, cfg?: AcceptOpts): void { 354 const tmpl = this.tmpl 355 if (this.roundX) x = Math.round(x) 356 if (this.roundY) this.y = Math.round(this.y) 357 tmpl.x.textContent = threeSigFigs.format(x) 358 tmpl.y.textContent = threeSigFigs.format(this.y) 359 if (this.roundY) tmpl.y.textContent = `${this.y}` 360 const rEffective = clamp(this.r, 0, 1) 361 tmpl.handle.style.left = `calc(${rEffective * 100}% - ${rEffective * 14}px)` 362 this.x = x 363 this.scrollingX = x 364 cfg = cfg ?? {} 365 if (this.settingsDict) this.settingsDict[this.settingsKey] = this.convert(this.x, this.y) 366 if (!cfg.skipUpdate) { 367 this.updated(x, this.y) 368 if (!cfg.skipChange) this.changed() 369 } 370 } 371 372 setValue (x: number, skipUpdate?: boolean) { 373 const range = this.range 374 this.r = (x - range.start.x) / (range.end.x - range.start.x) 375 this.y = range.start.y + this.r * (range.end.y - range.start.y) 376 this.accept(x, { skipUpdate }) 377 } 378 379 modified (): boolean { 380 return this.x !== this.initVal 381 } 382 383 reset () { 384 this.setValue(this.initVal, true) 385 } 386 } 387 388 const clamp = (v: number, min: number, max: number): number => v < min ? min : v > max ? max : v