decred.org/dcrdex@v1.0.5/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