feat(timeline): display 1ms grid near max zoom

<script lang="ts">
import * as PIXI from 'pixi.js-legacy'
import { install as installUnsafeEval } from '@pixi/unsafe-eval'
import {
  ref,
  onMounted,
  onUnmounted,
  watch,
  watchEffect,
  defineComponent
} from '@vue/composition-api'
import Vue from 'vue'
import {
  useLayers,
  useTime,
  useSelectedEvent,
  selectEvent,
  onTimelineReset,
  onEventAdd,
  useCursor,
  Layer,
  TimelineEvent
} from './composable'
import { useApps } from '@front/features/apps'
import { onKeyUp } from '@front/util/keyboard'
import SharedData from '@utils/shared-data'
import { useDarkMode } from '@front/util/theme'
import { dimColor, boostColor } from '@front/util/color'

const LAYER_SIZE = 16
const GROUP_SIZE = 6
const MIN_CAMERA_SIZE = 10

installUnsafeEval(PIXI)

export default defineComponent({
  setup () {
    const wrapper = ref<HTMLElement>(null)

    const { currentAppId } = useApps()
    const { startTime, endTime, minTime, maxTime } = useTime()
    const { darkMode } = useDarkMode()

    // Reset

    type ResetCb = () => void

    const resetCbs: ResetCb[] = []

    function onReset (cb: ResetCb) {
      resetCbs.push(cb)
    }

    function reset () {
      for (const cb of resetCbs) {
        cb()
      }
    }

    watch(currentAppId, () => reset())

    onTimelineReset(reset)

    // Pixi App

    let app: PIXI.Application

    let verticalScrollingContainer: PIXI.Container
    let horizontalScrollingContainer: PIXI.Container

    onMounted(() => {
      app = new PIXI.Application({
        resizeTo: wrapper.value,
        antialias: true,
        autoDensity: true
      })
      app.stage.interactive = true
      app.stage.hitArea = new PIXI.Rectangle(0, 0, 100000, 100000)
      updateBackground()
      wrapper.value.appendChild(app.view)
      app.view.style.opacity = '0'
      app.renderer.on('postrender', () => {
        app.view.style.opacity = '1'
      })

      verticalScrollingContainer = new PIXI.Container()
      app.stage.addChild(verticalScrollingContainer)

      horizontalScrollingContainer = new PIXI.Container()
      verticalScrollingContainer.addChild(horizontalScrollingContainer)
    })

    onUnmounted(() => {
      app.destroy()
    })

    function updateBackground () {
      if (darkMode.value) {
        app && (app.renderer.backgroundColor = 0x0b1015)
      } else {
        app && (app.renderer.backgroundColor = 0xffffff)
      }
    }

    watchEffect(() => {
      updateBackground()
    })

    // Layers

    const {
      layers,
      vScroll,
      hoverLayerId,
      selectedLayer
    } = useLayers()

    let layerContainers: PIXI.Container[] = []
    let layersMap: Record<Layer['id'], { layer: Layer, container: PIXI.Container }> = {}

    function initLayers () {
      let y = 0
      for (const layer of layers.value) {
        const container = new PIXI.Container()
        container.y = y
        y += (layer.height + 1) * LAYER_SIZE
        horizontalScrollingContainer.addChild(container)
        // allow z-index sorting
        container.sortableChildren = true
        layerContainers.push(container)
        layersMap[layer.id] = {
          layer,
          container
        }
      }
    }

    function updateLayerPositions () {
      let y = 0
      for (const layer of layers.value) {
        const payload = layersMap[layer.id]
        if (payload) {
          payload.container.y = y
        }
        y += (layer.height + 1) * LAYER_SIZE
      }
    }

    onMounted(() => {
      initLayers()
    })

    function resetLayers () {
      for (const container of layerContainers) {
        container.destroy()
      }
      layerContainers = []
      layersMap = {}
      initLayers()
      resetEvents()
    }

    onReset(() => {
      resetLayers()
    })

    watch(() => layers.value.map(l => l.id).join(','), () => {
      resetLayers()
    })

    // Layer hover

    let layerHoverEffect: PIXI.Graphics

    onMounted(() => {
      layerHoverEffect = new PIXI.Graphics()
      layerHoverEffect.alpha = 0.1
      layerHoverEffect.visible = false
      verticalScrollingContainer.addChild(layerHoverEffect)
    })

    function getLayerY (layer: Layer) {
      return layers.value.slice(0, layers.value.indexOf(layer)).reduce((sum, layer) => sum + (layer.height + 1) * LAYER_SIZE, 0)
    }

    function drawLayerBackgroundEffects () {
      if (!layerHoverEffect) return

      const layerIds = [
        {
          id: hoverLayerId.value,
          alpha: 1
        },
        {
          id: hoverLayerId.value !== selectedLayer.value?.id ? selectedLayer.value?.id : null,
          alpha: 0.5
        }
      ].filter(({ id }) => id != null)

      if (layerIds.length) {
        layerHoverEffect.clear()
        layerIds.forEach(({ id, alpha }) => drawLayerBackground(id, alpha))
        layerHoverEffect.visible = true
      } else {
        layerHoverEffect.visible = false
      }
    }

    function drawLayerBackground (layerId: Layer['id'], alpha = 1) {
      const { layer } = layersMap[layerId]
      layerHoverEffect.beginFill(layer.color, alpha)
      layerHoverEffect.drawRect(0, getLayerY(layer), app.view.width, (layer.height + 1) * LAYER_SIZE)
    }

    watch(hoverLayerId, () => {
      drawLayerBackgroundEffects()
    })

    watch(selectedLayer, () => {
      drawLayerBackgroundEffects()
    })

    function updateLayerHover (event: MouseEvent) {
      let { offsetY } = event
      offsetY -= verticalScrollingContainer.y
      if (offsetY >= 0) {
        let y = 0
        // Find the hovering layer depending on each layer's height
        for (const layer of layers.value) {
          y += (layer.height + 1) * LAYER_SIZE
          if (offsetY <= y) {
            hoverLayerId.value = layer.id
            return
          }
        }
      }
      clearLayerHover()
    }

    function clearLayerHover () {
      hoverLayerId.value = null
    }

    // Events

    const { selectedEvent } = useSelectedEvent()

    let events: TimelineEvent[] = []

    function getEventPosition (event: TimelineEvent) {
      return (event.time - minTime.value) / (endTime.value - startTime.value) * app.view.width
    }

    const updateEventPositionQueue = new Set<TimelineEvent>()
    let currentEventPositionUpdate: TimelineEvent = null
    let updateEventPositionQueued = false

    function queueEventPositionUpdate (...events: TimelineEvent[]) {
      for (const e of events) {
        if (!e.container) continue
        const ignored = isEventIgnored(e)
        e.container.visible = !ignored
        if (ignored) continue
        // Update horizontal position immediately
        e.container.x = getEventPosition(e)
        // Queue vertical position compute
        updateEventPositionQueue.add(e)
      }
      if (!updateEventPositionQueued) {
        updateEventPositionQueued = true
        Vue.nextTick(() => {
          nextEventPositionUpdate()
          updateEventPositionQueued = false
        })
      }
    }

    function nextEventPositionUpdate () {
      if (currentEventPositionUpdate) return
      const event = currentEventPositionUpdate = updateEventPositionQueue.values().next().value
      if (event) {
        computeEventVerticalPosition(event)
      }
    }

    function isEventIgnored (event: TimelineEvent) {
      return event.layer.ignoreNoDurationGroups && event.group && event.group.duration <= 0
    }

    function computeEventVerticalPosition (event: TimelineEvent) {
      const offset = event.layer.groupsOnly ? 0 : 12

      let y = 0
      if (event.group && event !== event.group.firstEvent) {
        // If the event is inside a group, just use the group position
        y = event.group.y
      } else {
        const firstEvent = event.group ? event.group.firstEvent : event
        const lastEvent = event.group ? event.group.lastEvent : event
        const lastOffset = event.layer.groupsOnly && event.group?.duration > 0 ? -1 : 0
        // Check for 'collision' with other event groups
        const l = event.layer.groups.length
        let checkAgain = true
        while (checkAgain) {
          checkAgain = false
          for (let i = 0; i < l; i++) {
            const otherGroup = event.layer.groups[i]
            if (
              // Different group
              (
                !event.group ||
                event.group !== otherGroup
              ) &&
              // Same row
              otherGroup.y === y &&
              (
                // Horizontal intersection (first event)
                (
                  getEventPosition(firstEvent) >= getEventPosition(otherGroup.firstEvent) - offset &&
                  getEventPosition(firstEvent) <= getEventPosition(otherGroup.lastEvent) + offset + lastOffset
                ) ||
                // Horizontal intersection (last event)
                (
                  getEventPosition(lastEvent) >= getEventPosition(otherGroup.firstEvent) - offset - lastOffset &&
                  getEventPosition(lastEvent) <= getEventPosition(otherGroup.lastEvent) + offset
                )
              )
            ) {
              // Collision!
              if (event.group && event.group.duration > otherGroup.duration && firstEvent.time <= otherGroup.firstEvent.time) {
                // Invert positions because current group has higher priority
                queueEventPositionUpdate(otherGroup.firstEvent)
              } else {
                // Offset the current group/event
                y++
                // We need to check all the layers again since we moved the event
                checkAgain = true
                break
              }
            }
          }
        }

        // If the event is the first in a group, update group position
        if (event.group) {
          event.group.y = y
        }

        // Might update the layer's height as well
        if (y + 1 > event.layer.height) {
          const oldLayerHeight = event.layer.height
          const newLayerHeight = event.layer.height = y + 1
          if (oldLayerHeight !== newLayerHeight) {
            updateLayerPositions()
            drawLayerBackgroundEffects()
          }
        }
      }
      event.container.y = (y + 1) * LAYER_SIZE

      // Next
      updateEventPositionQueue.delete(event)
      currentEventPositionUpdate = null
      nextEventPositionUpdate()
    }

    function addEvent (event: TimelineEvent, layerContainer: PIXI.Container) {
      // Container
      const eventContainer = new PIXI.Container()
      event.container = eventContainer
      layerContainer.addChild(eventContainer)

      // Group graphics
      if (event.group) {
        if (event.group.firstEvent === event) {
          const groupG = new PIXI.Graphics()
          event.groupG = groupG
          eventContainer.addChild(groupG)
          drawEventGroup(event)
        } else if (event.group.lastEvent === event) {
          drawEventGroup(event.group.firstEvent)
          // We need to check for collisions again
          Vue.nextTick(() => queueEventsUpdate())
        }
      }

      // Graphics
      const g = new PIXI.Graphics()
      event.g = g
      eventContainer.addChild(g)

      events.push(event)

      refreshEventGraphics(event)
      queueEventPositionUpdate(event)

      return event
    }

    function initEvents () {
      for (const k in layersMap) {
        const { layer, container } = layersMap[k]
        for (const event of layer.events) {
          addEvent(event, container)
        }
      }
    }

    onMounted(() => {
      initEvents()
    })

    function clearEvents () {
      for (const e of events) {
        e.g.destroy()
        e.g = null

        if (e.groupT) {
          (e.groupT.mask as PIXI.Graphics).destroy()
          e.groupT.destroy()
          e.groupT = null
        }

        if (e.groupG) {
          e.groupG.destroy()
          e.groupG = null
        }

        e.container.destroy()
        e.container = null
      }
      events = []
    }

    function resetEvents () {
      clearEvents()
      initEvents()
    }

    onUnmounted(() => {
      clearEvents()
    })

    onEventAdd((event: TimelineEvent) => {
      if (event.appId !== 'all' && event.appId !== currentAppId.value) return

      const layer = layersMap[event.layer.id]
      if (layer) {
        addEvent(event, layer.container)
      }
    })

    let eventsUpdateQueued = false

    function queueEventsUpdate () {
      if (eventsUpdateQueued) return
      eventsUpdateQueued = true
      Vue.nextTick(() => {
        updateEvents()
        eventsUpdateQueued = false
      })
    }

    function updateEvents () {
      for (const layer of layers.value) {
        layer.height = 1
      }
      updateLayerPositions()
      queueEventPositionUpdate(...events)
      for (const event of events) {
        if (event.groupG) {
          drawEventGroup(event)
        }
      }
    }

    watch(startTime, () => queueEventsUpdate())
    watch(endTime, () => queueEventsUpdate())
    watch(minTime, () => queueEventsUpdate())

    // Event selection

    onMounted(() => {
      app.stage.addListener('click', event => {
        const targetX = event.data.global.x
        const targetY = event.data.global.y
        let choice: TimelineEvent

        let y = 0
        for (const layer of layers.value) {
          y += (layer.height + 1) * LAYER_SIZE
          if (targetY - verticalScrollingContainer.y < y) {
            let distance = Number.POSITIVE_INFINITY
            for (const e of layer.events) {
              if (isEventIgnored(e)) continue

              if (layer.groupsOnly) {
                // We find the group inside of which the mouse is
                const bounds = e.group.firstEvent.groupG.getBounds()
                if (bounds.contains(targetX, targetY)) {
                  choice = e
                  break
                }
              } else {
                if (!e.g) continue
                // We find the nearest event from the mouse click position
                const globalPosition = e.g.getGlobalPosition()
                const d = Math.abs(globalPosition.x - targetX) + Math.abs(globalPosition.y - targetY)

                if (!choice || d < distance) {
                  choice = e
                  distance = d
                }
              }
            }
            break
          }
        }

        if (choice) {
          selectEvent(choice)
        }
      })
    })

    function drawEvent (selected: boolean, event: TimelineEvent) {
      if (event) {
        let color = event.layer.color
        if (event.logType === 'error') {
          color = 0xE53E3E
        } else if (event.logType === 'warning') {
          color = 0xECC94B
        }

        if (event.g) {
          /** @type {PIXI.Graphics} */
          const g = event.g
          let size = 3
          g.clear()
          if (!event.layer.groupsOnly) {
            if (selected) {
              // Border-only style
              size--
              g.lineStyle(2, boostColor(color, darkMode.value))
              g.beginFill(dimColor(color, darkMode.value))
              if (!event.group || event.group.firstEvent !== event) {
                event.container.zIndex = 999999999
              }
            } else {
              g.beginFill(color)
              if (!event.group || event.group.firstEvent !== event) {
                event.container.zIndex = size
              }
            }
            g.drawCircle(0, 0, size + (selected ? 1 : 0))
          } else {
            drawEventGroup(event)
          }
        }
      }
    }

    const drawSelectedEvent = drawEvent.bind(null, true)
    const drawUnselectedEvent = drawEvent.bind(null, false)

    function refreshEventGraphics (event: TimelineEvent) {
      if (selectedEvent.value === event) {
        drawSelectedEvent(event)
      } else {
        drawUnselectedEvent(event)
      }
    }

    watch(selectedEvent, (event, oldEvent) => {
      drawUnselectedEvent(oldEvent)
      drawSelectedEvent(event)
    })

    // Event selection with keyboard

    function selectPreviousEvent () {
      let index
      if (selectedEvent.value) {
        index = events.indexOf(selectedEvent.value)
      } else {
        index = events.length
      }

      let fullLoops = 0
      do {
        index--
        if (index < 0) {
          index = events.length - 1
          fullLoops++
        }
      } while (isEventIgnored(events[index]) && fullLoops < 2)

      if (events[index]) {
        selectEvent(events[index])
      }
    }

    function selectNextEvent () {
      let index
      if (selectedEvent.value) {
        index = events.indexOf(selectedEvent.value)
      } else {
        index = -1
      }

      let fullLoops = 0
      do {
        index++
        if (index >= events.length) {
          index = 0
          fullLoops++
        }
      } while (isEventIgnored(events[index]) && fullLoops < 2)

      if (events[index]) {
        selectEvent(events[index])
      }
    }

    onKeyUp(event => {
      if (event.key === 'ArrowLeft') {
        selectPreviousEvent()
      } else if (event.key === 'ArrowRight') {
        selectNextEvent()
      }
    })

    // Event Groups

    function drawEventGroup (event: TimelineEvent) {
      if (event.groupG) {
        const drawAsSelected = event === selectedEvent.value && event.layer.groupsOnly

        /** @type {PIXI.Graphics} */
        const g = event.groupG
        g.clear()
        const size = getEventPosition(event.group.lastEvent) - getEventPosition(event.group.firstEvent)
        if (event.layer.groupsOnly) {
          if (drawAsSelected) {
            g.lineStyle(2, boostColor(event.layer.color, darkMode.value))
            g.beginFill(dimColor(event.layer.color, darkMode.value, 30))
          } else {
            g.beginFill(event.layer.color, 0.5)
          }
        } else {
          g.lineStyle(1, dimColor(event.layer.color, darkMode.value))
          g.beginFill(dimColor(event.layer.color, darkMode.value, 25))
        }
        if (event.layer.groupsOnly) {
          g.drawRect(0, -LAYER_SIZE / 2, size - 1, LAYER_SIZE - 1)
        } else {
          // Some adjustements were made on the vertical position and size to snap border pixels to the screen's grid (LoDPI)
          g.drawRoundedRect(-GROUP_SIZE, -GROUP_SIZE + 0.5, size + GROUP_SIZE * 2, GROUP_SIZE * 2 - 1, GROUP_SIZE)
        }

        // Title
        if (event.layer.groupsOnly && event.title && size > 32) {
          let t = event.groupT
          if (!t) {
            t = event.groupT = new PIXI.Text(`${event.title} ${event.subtitle}`, {
              fontSize: 10,
              fill: darkMode.value ? 0xffffff : 0
            })
            t.y = -t.height / 2
            event.container.addChild(t)

            // Mask
            const mask = new PIXI.Graphics()
            event.container.addChild(mask)
            t.mask = mask
          }

          const mask = t.mask as PIXI.Graphics
          mask.clear()
          mask.beginFill(0)
          mask.drawRect(0, -LAYER_SIZE / 2, size - 1, LAYER_SIZE - 1)
        } else if (event.groupT) {
          const mask = event.groupT.mask as PIXI.Graphics
          mask?.destroy()
          event.groupT.destroy()
          event.groupT = null
        }
      }
    }

    // Time cursor

    const { cursorTime } = useCursor()

    let timeCursor: PIXI.Graphics

    onMounted(() => {
      timeCursor = new PIXI.Graphics()
      timeCursor.visible = false
      drawTimeCursor()
      app.stage.addChild(timeCursor)
    })

    function drawTimeCursor () {
      timeCursor.clear()
      timeCursor.lineStyle(1, 0x888888, 0.2)
      timeCursor.moveTo(0.5, 0)
      timeCursor.lineTo(0.5, app.view.height)
    }

    function updateCursorPosition (event: MouseEvent) {
      const { offsetX } = event
      timeCursor.x = offsetX
      timeCursor.visible = true
      cursorTime.value = offsetX / app.view.width * (endTime.value - startTime.value) + startTime.value
    }

    function clearCursor () {
      timeCursor.visible = false
      cursorTime.value = null
    }

    // Time grid

    let timeGrid: PIXI.Graphics

    onMounted(() => {
      timeGrid = new PIXI.Graphics()
      timeGrid.visible = SharedData.timelineTimeGrid
      drawTimeGrid()
      app.stage.addChild(timeGrid)
    })

    function drawTimeGrid () {
      if (!timeGrid.visible || !app.view.width) return

      const size = endTime.value - startTime.value
      const ratio = size / app.view.width
      let timeInterval = 10
      let width = timeInterval / ratio

      if (size <= MIN_CAMERA_SIZE * 3) {
        // Every ms
        timeInterval = 1
        width = timeInterval / ratio
      } else {
        while (width < 20) {
          timeInterval *= 10
          width *= 10
        }
      }

      const offset = startTime.value % timeInterval / ratio

      timeGrid.clear()
      timeGrid.lineStyle(1, 0x888888, 0.075)
      for (let x = -offset; x < app.view.width; x += width) {
        timeGrid.moveTo(x + 0.5, 0)
        timeGrid.lineTo(x + 0.5, app.view.height)
      }
    }

    watch(() => SharedData.timelineTimeGrid, value => {
      timeGrid.visible = value
      if (value) {
        drawTimeGrid()
      }
    })

    // Camera

    let cameraUpdateQueued = false

    function queueCameraUpdate () {
      if (cameraUpdateQueued) return
      cameraUpdateQueued = true
      Vue.nextTick(() => {
        updateCamera()
        cameraUpdateQueued = false
      })
    }

    function updateCamera () {
      horizontalScrollingContainer.x = -(startTime.value - minTime.value) / (endTime.value - startTime.value) * app.view.width
      drawLayerBackgroundEffects()
      drawTimeGrid()
    }

    watch(startTime, () => queueCameraUpdate())
    watch(endTime, () => queueCameraUpdate())

    onMounted(() => {
      queueCameraUpdate()
    })

    function onMouseWheel (event: WheelEvent) {
      const size = endTime.value - startTime.value
      const viewWidth = wrapper.value.offsetWidth

      if (event.ctrlKey || event.metaKey) {
        // Zoom
        // Firefox doesn't block the event https://bugzilla.mozilla.org/show_bug.cgi?id=1632465
        event.preventDefault()

        const centerRatio = event.offsetX / viewWidth
        const center = size * centerRatio + startTime.value

        let newSize = size + event.deltaY / viewWidth * size * 2
        if (newSize < MIN_CAMERA_SIZE) {
          newSize = MIN_CAMERA_SIZE
        }

        let start = center - newSize * centerRatio
        let end = center + newSize * (1 - centerRatio)
        if (start < minTime.value) {
          start = minTime.value
        }
        if (end > maxTime.value) {
          end = maxTime.value
        }
        startTime.value = start
        endTime.value = end
      } else {
        let deltaX = event.deltaX

        if (deltaX === 0 && event.shiftKey && event.deltaY !== 0) {
          // Horitonzal scroll with vertical mouse wheel and shift key
          deltaX = event.deltaY
        }

        if (deltaX !== 0) {
          // Horizontal scroll
          const delta = deltaX / viewWidth * size
          let start = startTime.value += delta
          if (start < minTime.value) {
            start = minTime.value
          } else if (start + size >= maxTime.value) {
            start = maxTime.value - size
          }
          startTime.value = start
          endTime.value = start + size
        } else if (event.deltaY !== 0) {
          // Vertical scroll
          const layersScroller = document.querySelector('[data-scroller="layers"]')
          if (layersScroller) {
            const speed = SharedData.menuStepScrolling ? 1 : LAYER_SIZE * 4
            if (event.deltaY < 0) {
              layersScroller.scrollTop -= speed
            } else {
              layersScroller.scrollTop += speed
            }
          }
        }
      }
    }

    // Vertical scroll

    function updateVScroll () {
      if (verticalScrollingContainer) {
        verticalScrollingContainer.y = -vScroll.value
      }
    }

    watch(vScroll, () => {
      updateVScroll()
    })

    onMounted(() => {
      updateVScroll()
    })

    // Resize

    function onResize () {
      app.view.style.opacity = '0'
      app.queueResize()
      queueEventsUpdate()
      drawLayerBackgroundEffects()
      drawTimeCursor()
      drawTimeGrid()
    }

    // Events

    function onMouseMove (event: MouseEvent) {
      updateLayerHover(event)
      updateCursorPosition(event)
    }

    function onMouseOut () {
      clearLayerHover()
      clearCursor()
    }

    return {
      wrapper,
      onMouseWheel,
      onMouseMove,
      onMouseOut,
      onResize
    }
  }
})
</script>

<template>
  <div
    ref="wrapper"
    class="relative overflow-hidden"
    data-id="timeline-view-wrapper"
    @wheel="onMouseWheel"
    @mousemove="onMouseMove"
    @mouseout="onMouseOut"
  >
    <resize-observer @notify="onResize" />
  </div>
</template>

<script lang="ts">
import * as PIXI from 'pixi.js-legacy'
import { install as installUnsafeEval } from '@pixi/unsafe-eval'
import {
  ref,
  onMounted,
  onUnmounted,
  watch,
  watchEffect,
  defineComponent
} from '@vue/composition-api'
import Vue from 'vue'
import {
  useLayers,
  useTime,
  useSelectedEvent,
  selectEvent,
  onTimelineReset,
  onEventAdd,
  useCursor,
  Layer,
  TimelineEvent
} from './composable'
import { useApps } from '@front/features/apps'
import { onKeyUp } from '@front/util/keyboard'
import SharedData from '@utils/shared-data'
import { useDarkMode } from '@front/util/theme'
import { dimColor, boostColor } from '@front/util/color'

const LAYER_SIZE = 16
const GROUP_SIZE = 6

installUnsafeEval(PIXI)

export default defineComponent({
  setup () {
    const wrapper = ref<HTMLElement>(null)

    const { currentAppId } = useApps()
    const { startTime, endTime, minTime, maxTime } = useTime()
    const { darkMode } = useDarkMode()

    // Reset

    type ResetCb = () => void

    const resetCbs: ResetCb[] = []

    function onReset (cb: ResetCb) {
      resetCbs.push(cb)
    }

    function reset () {
      for (const cb of resetCbs) {
        cb()
      }
    }

    watch(currentAppId, () => reset())

    onTimelineReset(reset)

    // Pixi App

    let app: PIXI.Application

    let verticalScrollingContainer: PIXI.Container
    let horizontalScrollingContainer: PIXI.Container

    onMounted(() => {
      app = new PIXI.Application({
        resizeTo: wrapper.value,
        antialias: true,
        autoDensity: true
      })
      app.stage.interactive = true
      app.stage.hitArea = new PIXI.Rectangle(0, 0, 100000, 100000)
      updateBackground()
      wrapper.value.appendChild(app.view)
      app.view.style.opacity = '0'
      app.renderer.on('postrender', () => {
        app.view.style.opacity = '1'
      })

      verticalScrollingContainer = new PIXI.Container()
      app.stage.addChild(verticalScrollingContainer)

      horizontalScrollingContainer = new PIXI.Container()
      verticalScrollingContainer.addChild(horizontalScrollingContainer)
    })

    onUnmounted(() => {
      app.destroy()
    })

    function updateBackground () {
      if (darkMode.value) {
        app && (app.renderer.backgroundColor = 0x0b1015)
      } else {
        app && (app.renderer.backgroundColor = 0xffffff)
      }
    }

    watchEffect(() => {
      updateBackground()
    })

    // Layers

    const {
      layers,
      vScroll,
      hoverLayerId,
      selectedLayer
    } = useLayers()

    let layerContainers: PIXI.Container[] = []
    let layersMap: Record<Layer['id'], { layer: Layer, container: PIXI.Container }> = {}

    function initLayers () {
      let y = 0
      for (const layer of layers.value) {
        const container = new PIXI.Container()
        container.y = y
        y += (layer.height + 1) * LAYER_SIZE
        horizontalScrollingContainer.addChild(container)
        // allow z-index sorting
        container.sortableChildren = true
        layerContainers.push(container)
        layersMap[layer.id] = {
          layer,
          container
        }
      }
    }

    function updateLayerPositions () {
      let y = 0
      for (const layer of layers.value) {
        const payload = layersMap[layer.id]
        if (payload) {
          payload.container.y = y
        }
        y += (layer.height + 1) * LAYER_SIZE
      }
    }

    onMounted(() => {
      initLayers()
    })

    function resetLayers () {
      for (const container of layerContainers) {
        container.destroy()
      }
      layerContainers = []
      layersMap = {}
      initLayers()
      resetEvents()
    }

    onReset(() => {
      resetLayers()
    })

    watch(() => layers.value.map(l => l.id).join(','), () => {
      resetLayers()
    })

    // Layer hover

    let layerHoverEffect: PIXI.Graphics

    onMounted(() => {
      layerHoverEffect = new PIXI.Graphics()
      layerHoverEffect.alpha = 0.1
      layerHoverEffect.visible = false
      verticalScrollingContainer.addChild(layerHoverEffect)
    })

    function getLayerY (layer: Layer) {
      return layers.value.slice(0, layers.value.indexOf(layer)).reduce((sum, layer) => sum + (layer.height + 1) * LAYER_SIZE, 0)
    }

    function drawLayerBackgroundEffects () {
      if (!layerHoverEffect) return

      const layerIds = [
        {
          id: hoverLayerId.value,
          alpha: 1
        },
        {
          id: hoverLayerId.value !== selectedLayer.value?.id ? selectedLayer.value?.id : null,
          alpha: 0.5
        }
      ].filter(({ id }) => id != null)

      if (layerIds.length) {
        layerHoverEffect.clear()
        layerIds.forEach(({ id, alpha }) => drawLayerBackground(id, alpha))
        layerHoverEffect.visible = true
      } else {
        layerHoverEffect.visible = false
      }
    }

    function drawLayerBackground (layerId: Layer['id'], alpha = 1) {
      const { layer } = layersMap[layerId]
      layerHoverEffect.beginFill(layer.color, alpha)
      layerHoverEffect.drawRect(0, getLayerY(layer), app.view.width, (layer.height + 1) * LAYER_SIZE)
    }

    watch(hoverLayerId, () => {
      drawLayerBackgroundEffects()
    })

    watch(selectedLayer, () => {
      drawLayerBackgroundEffects()
    })

    function updateLayerHover (event: MouseEvent) {
      let { offsetY } = event
      offsetY -= verticalScrollingContainer.y
      if (offsetY >= 0) {
        let y = 0
        // Find the hovering layer depending on each layer's height
        for (const layer of layers.value) {
          y += (layer.height + 1) * LAYER_SIZE
          if (offsetY <= y) {
            hoverLayerId.value = layer.id
            return
          }
        }
      }
      clearLayerHover()
    }

    function clearLayerHover () {
      hoverLayerId.value = null
    }

    // Events

    const { selectedEvent } = useSelectedEvent()

    let events: TimelineEvent[] = []

    function getEventPosition (event: TimelineEvent) {
      return (event.time - minTime.value) / (endTime.value - startTime.value) * app.view.width
    }

    const updateEventPositionQueue = new Set<TimelineEvent>()
    let currentEventPositionUpdate: TimelineEvent = null
    let updateEventPositionQueued = false

    function queueEventPositionUpdate (...events: TimelineEvent[]) {
      for (const e of events) {
        if (!e.container) continue
        const ignored = isEventIgnored(e)
        e.container.visible = !ignored
        if (ignored) continue
        // Update horizontal position immediately
        e.container.x = getEventPosition(e)
        // Queue vertical position compute
        updateEventPositionQueue.add(e)
      }
      if (!updateEventPositionQueued) {
        updateEventPositionQueued = true
        Vue.nextTick(() => {
          nextEventPositionUpdate()
          updateEventPositionQueued = false
        })
      }
    }

    function nextEventPositionUpdate () {
      if (currentEventPositionUpdate) return
      const event = currentEventPositionUpdate = updateEventPositionQueue.values().next().value
      if (event) {
        computeEventVerticalPosition(event)
      }
    }

    function isEventIgnored (event: TimelineEvent) {
      return event.layer.ignoreNoDurationGroups && event.group && event.group.duration <= 0
    }

    function computeEventVerticalPosition (event: TimelineEvent) {
      const offset = event.layer.groupsOnly ? 0 : 12

      let y = 0
      if (event.group && event !== event.group.firstEvent) {
        // If the event is inside a group, just use the group position
        y = event.group.y
      } else {
        const firstEvent = event.group ? event.group.firstEvent : event
        const lastEvent = event.group ? event.group.lastEvent : event
        const lastOffset = event.layer.groupsOnly && event.group?.duration > 0 ? -1 : 0
        // Check for 'collision' with other event groups
        const l = event.layer.groups.length
        let checkAgain = true
        while (checkAgain) {
          checkAgain = false
          for (let i = 0; i < l; i++) {
            const otherGroup = event.layer.groups[i]
            if (
              // Different group
              (
                !event.group ||
                event.group !== otherGroup
              ) &&
              // Same row
              otherGroup.y === y &&
              (
                // Horizontal intersection (first event)
                (
                  getEventPosition(firstEvent) >= getEventPosition(otherGroup.firstEvent) - offset &&
                  getEventPosition(firstEvent) <= getEventPosition(otherGroup.lastEvent) + offset + lastOffset
                ) ||
                // Horizontal intersection (last event)
                (
                  getEventPosition(lastEvent) >= getEventPosition(otherGroup.firstEvent) - offset - lastOffset &&
                  getEventPosition(lastEvent) <= getEventPosition(otherGroup.lastEvent) + offset
                )
              )
            ) {
              // Collision!
              if (event.group && event.group.duration > otherGroup.duration && firstEvent.time <= otherGroup.firstEvent.time) {
                // Invert positions because current group has higher priority
                queueEventPositionUpdate(otherGroup.firstEvent)
              } else {
                // Offset the current group/event
                y++
                // We need to check all the layers again since we moved the event
                checkAgain = true
                break
              }
            }
          }
        }

        // If the event is the first in a group, update group position
        if (event.group) {
          event.group.y = y
        }

        // Might update the layer's height as well
        if (y + 1 > event.layer.height) {
          const oldLayerHeight = event.layer.height
          const newLayerHeight = event.layer.height = y + 1
          if (oldLayerHeight !== newLayerHeight) {
            updateLayerPositions()
            drawLayerBackgroundEffects()
          }
        }
      }
      event.container.y = (y + 1) * LAYER_SIZE

      // Next
      updateEventPositionQueue.delete(event)
      currentEventPositionUpdate = null
      nextEventPositionUpdate()
    }

    function addEvent (event: TimelineEvent, layerContainer: PIXI.Container) {
      // Container
      const eventContainer = new PIXI.Container()
      event.container = eventContainer
      layerContainer.addChild(eventContainer)

      // Group graphics
      if (event.group) {
        if (event.group.firstEvent === event) {
          const groupG = new PIXI.Graphics()
          event.groupG = groupG
          eventContainer.addChild(groupG)
          drawEventGroup(event)
        } else if (event.group.lastEvent === event) {
          drawEventGroup(event.group.firstEvent)
          // We need to check for collisions again
          Vue.nextTick(() => queueEventsUpdate())
        }
      }

      // Graphics
      const g = new PIXI.Graphics()
      event.g = g
      eventContainer.addChild(g)

      events.push(event)

      refreshEventGraphics(event)
      queueEventPositionUpdate(event)

      return event
    }

    function initEvents () {
      for (const k in layersMap) {
        const { layer, container } = layersMap[k]
        for (const event of layer.events) {
          addEvent(event, container)
        }
      }
    }

    onMounted(() => {
      initEvents()
    })

    function clearEvents () {
      for (const e of events) {
        e.g.destroy()
        e.g = null

        if (e.groupT) {
          (e.groupT.mask as PIXI.Graphics).destroy()
          e.groupT.destroy()
          e.groupT = null
        }

        if (e.groupG) {
          e.groupG.destroy()
          e.groupG = null
        }

        e.container.destroy()
        e.container = null
      }
      events = []
    }

    function resetEvents () {
      clearEvents()
      initEvents()
    }

    onUnmounted(() => {
      clearEvents()
    })

    onEventAdd((event: TimelineEvent) => {
      if (event.appId !== 'all' && event.appId !== currentAppId.value) return

      const layer = layersMap[event.layer.id]
      if (layer) {
        addEvent(event, layer.container)
      }
    })

    let eventsUpdateQueued = false

    function queueEventsUpdate () {
      if (eventsUpdateQueued) return
      eventsUpdateQueued = true
      Vue.nextTick(() => {
        updateEvents()
        eventsUpdateQueued = false
      })
    }

    function updateEvents () {
      for (const layer of layers.value) {
        layer.height = 1
      }
      updateLayerPositions()
      queueEventPositionUpdate(...events)
      for (const event of events) {
        if (event.groupG) {
          drawEventGroup(event)
        }
      }
    }

    watch(startTime, () => queueEventsUpdate())
    watch(endTime, () => queueEventsUpdate())
    watch(minTime, () => queueEventsUpdate())

    // Event selection

    onMounted(() => {
      app.stage.addListener('click', event => {
        const targetX = event.data.global.x
        const targetY = event.data.global.y
        let choice: TimelineEvent

        let y = 0
        for (const layer of layers.value) {
          y += (layer.height + 1) * LAYER_SIZE
          if (targetY - verticalScrollingContainer.y < y) {
            let distance = Number.POSITIVE_INFINITY
            for (const e of layer.events) {
              if (isEventIgnored(e)) continue

              if (layer.groupsOnly) {
                // We find the group inside of which the mouse is
                const bounds = e.group.firstEvent.groupG.getBounds()
                if (bounds.contains(targetX, targetY)) {
                  choice = e
                  break
                }
              } else {
                if (!e.g) continue
                // We find the nearest event from the mouse click position
                const globalPosition = e.g.getGlobalPosition()
                const d = Math.abs(globalPosition.x - targetX) + Math.abs(globalPosition.y - targetY)

                if (!choice || d < distance) {
                  choice = e
                  distance = d
                }
              }
            }
            break
          }
        }

        if (choice) {
          selectEvent(choice)
        }
      })
    })

    function drawEvent (selected: boolean, event: TimelineEvent) {
      if (event) {
        let color = event.layer.color
        if (event.logType === 'error') {
          color = 0xE53E3E
        } else if (event.logType === 'warning') {
          color = 0xECC94B
        }

        if (event.g) {
          /** @type {PIXI.Graphics} */
          const g = event.g
          let size = 3
          g.clear()
          if (!event.layer.groupsOnly) {
            if (selected) {
              // Border-only style
              size--
              g.lineStyle(2, boostColor(color, darkMode.value))
              g.beginFill(dimColor(color, darkMode.value))
              if (!event.group || event.group.firstEvent !== event) {
                event.container.zIndex = 999999999
              }
            } else {
              g.beginFill(color)
              if (!event.group || event.group.firstEvent !== event) {
                event.container.zIndex = size
              }
            }
            g.drawCircle(0, 0, size + (selected ? 1 : 0))
          } else {
            drawEventGroup(event)
          }
        }
      }
    }

    const drawSelectedEvent = drawEvent.bind(null, true)
    const drawUnselectedEvent = drawEvent.bind(null, false)

    function refreshEventGraphics (event: TimelineEvent) {
      if (selectedEvent.value === event) {
        drawSelectedEvent(event)
      } else {
        drawUnselectedEvent(event)
      }
    }

    watch(selectedEvent, (event, oldEvent) => {
      drawUnselectedEvent(oldEvent)
      drawSelectedEvent(event)
    })

    // Event selection with keyboard

    function selectPreviousEvent () {
      let index
      if (selectedEvent.value) {
        index = events.indexOf(selectedEvent.value)
      } else {
        index = events.length
      }

      let fullLoops = 0
      do {
        index--
        if (index < 0) {
          index = events.length - 1
          fullLoops++
        }
      } while (isEventIgnored(events[index]) && fullLoops < 2)

      if (events[index]) {
        selectEvent(events[index])
      }
    }

    function selectNextEvent () {
      let index
      if (selectedEvent.value) {
        index = events.indexOf(selectedEvent.value)
      } else {
        index = -1
      }

      let fullLoops = 0
      do {
        index++
        if (index >= events.length) {
          index = 0
          fullLoops++
        }
      } while (isEventIgnored(events[index]) && fullLoops < 2)

      if (events[index]) {
        selectEvent(events[index])
      }
    }

    onKeyUp(event => {
      if (event.key === 'ArrowLeft') {
        selectPreviousEvent()
      } else if (event.key === 'ArrowRight') {
        selectNextEvent()
      }
    })

    // Event Groups

    function drawEventGroup (event: TimelineEvent) {
      if (event.groupG) {
        const drawAsSelected = event === selectedEvent.value && event.layer.groupsOnly

        /** @type {PIXI.Graphics} */
        const g = event.groupG
        g.clear()
        const size = getEventPosition(event.group.lastEvent) - getEventPosition(event.group.firstEvent)
        if (event.layer.groupsOnly) {
          if (drawAsSelected) {
            g.lineStyle(2, boostColor(event.layer.color, darkMode.value))
            g.beginFill(dimColor(event.layer.color, darkMode.value, 30))
          } else {
            g.beginFill(event.layer.color, 0.5)
          }
        } else {
          g.lineStyle(1, dimColor(event.layer.color, darkMode.value))
          g.beginFill(dimColor(event.layer.color, darkMode.value, 25))
        }
        if (event.layer.groupsOnly) {
          g.drawRect(0, -LAYER_SIZE / 2, size - 1, LAYER_SIZE - 1)
        } else {
          // Some adjustements were made on the vertical position and size to snap border pixels to the screen's grid (LoDPI)
          g.drawRoundedRect(-GROUP_SIZE, -GROUP_SIZE + 0.5, size + GROUP_SIZE * 2, GROUP_SIZE * 2 - 1, GROUP_SIZE)
        }

        // Title
        if (event.layer.groupsOnly && event.title && size > 32) {
          let t = event.groupT
          if (!t) {
            t = event.groupT = new PIXI.Text(`${event.title} ${event.subtitle}`, {
              fontSize: 10,
              fill: darkMode.value ? 0xffffff : 0
            })
            t.y = -t.height / 2
            event.container.addChild(t)

            // Mask
            const mask = new PIXI.Graphics()
            event.container.addChild(mask)
            t.mask = mask
          }

          const mask = t.mask as PIXI.Graphics
          mask.clear()
          mask.beginFill(0)
          mask.drawRect(0, -LAYER_SIZE / 2, size - 1, LAYER_SIZE - 1)
        } else if (event.groupT) {
          const mask = event.groupT.mask as PIXI.Graphics
          mask?.destroy()
          event.groupT.destroy()
          event.groupT = null
        }
      }
    }

    // Time cursor

    const { cursorTime } = useCursor()

    let timeCursor: PIXI.Graphics

    onMounted(() => {
      timeCursor = new PIXI.Graphics()
      timeCursor.visible = false
      drawTimeCursor()
      app.stage.addChild(timeCursor)
    })

    function drawTimeCursor () {
      timeCursor.clear()
      timeCursor.lineStyle(1, 0x888888, 0.2)
      timeCursor.moveTo(0.5, 0)
      timeCursor.lineTo(0.5, app.view.height)
    }

    function updateCursorPosition (event: MouseEvent) {
      const { offsetX } = event
      timeCursor.x = offsetX
      timeCursor.visible = true
      cursorTime.value = offsetX / app.view.width * (endTime.value - startTime.value) + startTime.value
    }

    function clearCursor () {
      timeCursor.visible = false
      cursorTime.value = null
    }

    // Time grid

    let timeGrid: PIXI.Graphics

    onMounted(() => {
      timeGrid = new PIXI.Graphics()
      timeGrid.visible = SharedData.timelineTimeGrid
      drawTimeGrid()
      app.stage.addChild(timeGrid)
    })

    function drawTimeGrid () {
      if (!timeGrid.visible || !app.view.width) return

      const ratio = (endTime.value - startTime.value) / app.view.width
      let timeInterval = 10
      let width = timeInterval / ratio

      while (width < 20) {
        timeInterval *= 10
        width *= 10
      }

      const offset = startTime.value % timeInterval / ratio

      timeGrid.clear()
      timeGrid.lineStyle(1, 0x888888, 0.075)
      for (let x = -offset; x < app.view.width; x += width) {
        timeGrid.moveTo(x + 0.5, 0)
        timeGrid.lineTo(x + 0.5, app.view.height)
      }
    }

    watch(() => SharedData.timelineTimeGrid, value => {
      timeGrid.visible = value
      if (value) {
        drawTimeGrid()
      }
    })

    // Camera

    let cameraUpdateQueued = false

    function queueCameraUpdate () {
      if (cameraUpdateQueued) return
      cameraUpdateQueued = true
      Vue.nextTick(() => {
        updateCamera()
        cameraUpdateQueued = false
      })
    }

    function updateCamera () {
      horizontalScrollingContainer.x = -(startTime.value - minTime.value) / (endTime.value - startTime.value) * app.view.width
      drawLayerBackgroundEffects()
      drawTimeGrid()
    }

    watch(startTime, () => queueCameraUpdate())
    watch(endTime, () => queueCameraUpdate())

    onMounted(() => {
      queueCameraUpdate()
    })

    function onMouseWheel (event: WheelEvent) {
      const size = endTime.value - startTime.value
      const viewWidth = wrapper.value.offsetWidth

      if (event.ctrlKey || event.metaKey) {
        // Zoom
        // Firefox doesn't block the event https://bugzilla.mozilla.org/show_bug.cgi?id=1632465
        event.preventDefault()

        const centerRatio = event.offsetX / viewWidth
        const center = size * centerRatio + startTime.value

        let newSize = size + event.deltaY / viewWidth * size * 2
        if (newSize < 10) {
          newSize = 10
        }

        let start = center - newSize * centerRatio
        let end = center + newSize * (1 - centerRatio)
        if (start < minTime.value) {
          start = minTime.value
        }
        if (end > maxTime.value) {
          end = maxTime.value
        }
        startTime.value = start
        endTime.value = end
      } else {
        let deltaX = event.deltaX

        if (deltaX === 0 && event.shiftKey && event.deltaY !== 0) {
          // Horitonzal scroll with vertical mouse wheel and shift key
          deltaX = event.deltaY
        }

        if (deltaX !== 0) {
          // Horizontal scroll
          const delta = deltaX / viewWidth * size
          let start = startTime.value += delta
          if (start < minTime.value) {
            start = minTime.value
          } else if (start + size >= maxTime.value) {
            start = maxTime.value - size
          }
          startTime.value = start
          endTime.value = start + size
        } else if (event.deltaY !== 0) {
          // Vertical scroll
          const layersScroller = document.querySelector('[data-scroller="layers"]')
          if (layersScroller) {
            const speed = SharedData.menuStepScrolling ? 1 : LAYER_SIZE * 4
            if (event.deltaY < 0) {
              layersScroller.scrollTop -= speed
            } else {
              layersScroller.scrollTop += speed
            }
          }
        }
      }
    }

    // Vertical scroll

    function updateVScroll () {
      if (verticalScrollingContainer) {
        verticalScrollingContainer.y = -vScroll.value
      }
    }

    watch(vScroll, () => {
      updateVScroll()
    })

    onMounted(() => {
      updateVScroll()
    })

    // Resize

    function onResize () {
      app.view.style.opacity = '0'
      app.queueResize()
      queueEventsUpdate()
      drawLayerBackgroundEffects()
      drawTimeCursor()
      drawTimeGrid()
    }

    // Events

    function onMouseMove (event: MouseEvent) {
      updateLayerHover(event)
      updateCursorPosition(event)
    }

    function onMouseOut () {
      clearLayerHover()
      clearCursor()
    }

    return {
      wrapper,
      onMouseWheel,
      onMouseMove,
      onMouseOut,
      onResize
    }
  }
})
</script>

<template>
  <div
    ref="wrapper"
    class="relative overflow-hidden"
    data-id="timeline-view-wrapper"
    @wheel="onMouseWheel"
    @mousemove="onMouseMove"
    @mouseout="onMouseOut"
  >
    <resize-observer @notify="onResize" />
  </div>
</template>


Refactor: use Btn in homepage

Refactor: use Btn in homepage

<template>

      <logo :scale="1.5"></logo>

uiv

Bootstrap 3 Components implemented by Vue 2.

        <btn to="/getting-started" size="lg">Getting Started</btn>
        <btn href="https://github.com/wxsms/uiv" size="lg">Github</btn>

</template>

<template>

      <logo :scale="1.5"></logo>

uiv

Bootstrap 3 Components implemented by Vue 2.

        <router-link to="/getting-started" class="btn btn-default btn-lg">Getting Started</router-link>
        Github

</template>


Dialog: Fix close bug (#15000) (#15544)

Dialog: Fix close bug (#15000) (#15544)

<template>
  <transition
    name="dialog-fade"
    @after-enter="afterEnter"
    @after-leave="afterLeave">

          <slot name="title">
            {{ title }}
          </slot>
          <button
            type="button"
            class="el-dialog__headerbtn"
            aria-label="Close"
            v-if="showClose"
            @click="handleClose">

          </button>

<slot></slot>

          <slot name="footer"></slot>

  </transition>
</template>


Import version

Import version


chore: 👷 revert change to modulesend

chore: 👷 revert change to modulesend

<template>
  <mew-module
    class="d-flex flex-grow-1 pt-6"
    title="Send"
    :has-elevation="true"
    :has-indicator="true"
  >
    <template #moduleBody>

      <v-row class="mt-5">
        <v-col cols="12" sm="6" class="pr-sm-1 pt-0 pb-0 pb-sm-4">

            <app-button-balance
              :balance="selectedBalance"
              :loading="!showSelectedBalance"
              class="d-sm-none"
            />
            <mew-select
              ref="mewSelect"
              label="Token"
              :items="tokens"
              :is-custom="true"
              :value="selectedCurrency"
              @input="setCurrency"
            />

        </v-col>
        <v-col cols="12" sm="6" class="pl-sm-1 pt-0 pb-2 pb-sm-4">

            <app-button-balance
              :balance="selectedBalance"
              :loading="!showSelectedBalance"
              class="d-none d-sm-block"
            />
            <mew-input
              label="Amount"
              placeholder="0"
              :value="amount"
              type="number"
              :persistent-hint="true"
              :error-messages="amountErrorMessage"
              :max-btn-obj="{
                title: 'Max',
                disabled: false,
                method: setEntireBal
              }"
              :buy-more-str="buyMore"
              @input="setAmount"
            />

        </v-col>

        <v-col v-if="showBalanceNotice" cols="12" class="pt-0 pb-4">
          <send-low-balance-notice
            :address="address"
            :currency-name="currencyName"
            class="pa-3"
          />
        </v-col>

        <v-col cols="12" class="pt-4 pb-2">
          <module-address-book ref="addressInput" @setAddress="setAddress" />
        </v-col>

        <v-col cols="12" class="py-0 mb-8">
          <app-transaction-fee
            :show-fee="showSelectedBalance"
            :getting-fee="!txFeeIsReady"
            :error="feeError"
            :total-cost="totalCost"
            :tx-fee="txFee"
            :total-gas-limit="gasLimit"
            :message="feeError"
            :not-enough-eth="!hasEnoughEth"
            :from-eth="isFromNetworkCurrency"
            @onLocalGasPrice="handleLocalGasPrice"
          />
        </v-col>

        <v-col cols="12" class="py-4">
          <mew-expand-panel
            ref="expandPanel"
            is-toggle
            has-dividers
            :panel-items="expandPanel"
            @toggled="closeToggle"
          >
            <template #panelBody1>

                  <v-icon class="textBlack2--text mew-body mr-1">
                    mdi-alert-outline</v-icon
                  >For advanced users only

                  Please don’t edit these fields unless you are an expert user &
                  know what you’re doing. Entering the wrong information could
                  result in your transaction failing or getting stuck.

                  Reset to default: {{ formattedDefaultGasLimit }}

              <mew-input
                :value="gasLimit"
                :label="$t('common.gas.limit')"
                placeholder=""
                :error-messages="gasLimitError"
                type="number"
                @input="setGasLimit"
              />

              <mew-input
                v-show="!isToken"
                v-model="data"
                :label="$t('sendTx.add-data')"
                placeholder="0x..."
                :rules="dataRules"
                class="mb-8"
              />
            </template>
          </mew-expand-panel>
        </v-col>
      </v-row>

          <mew-button
            title="Next"
            :has-full-width="false"
            btn-size="xlarge"
            :disabled="isDisabledNextBtn"
            @click.native="send()"
          />

          <mew-button
            :title="$t('common.clear-all')"
            :has-full-width="false"
            btn-size="small"
            btn-style="transparent"
            @click.native="clear()"
          />

    </template>
  </mew-module>
</template>


fix(MdHighlightText): avoid printing 'null' as a value (#1352)

fix #1351

<script>
  import Vue from 'vue'
  import MdComponent from 'core/MdComponent'

  function getHTMLOutput (str) {
    let text = str

    if (!text) {
      text = '$&'
    }

    return `<span class="md-highlight-text-match">${text}</span>`
  }

  function fuzzyHighlight (text, term) {
    if (term.length === 0) {
      return text
    }

    const offset = text.toLowerCase().indexOf(term[0].toLowerCase())

    if (offset === -1) {
      return ''
    }

    let last = 0
    for (let i = 1; i < term.length; i++) {
      if (text[offset + i] !== term[i]) {
        break
      }

      last = i
    }

    let before = text.slice(0, offset)
    let match = getHTMLOutput(text.slice(offset, offset + last + 1))
    let after = fuzzyHighlight(text.slice(offset + last + 1), term.slice(last + 1))

    return before + match + after
  }

  function normalHighlight (text, term) {
    const matches = new RegExp(term + '(?!([^<]+)?<)', 'gi')

    return text.replace(matches, getHTMLOutput())
  }

  function generateHighlight ({ text }, term, isFuzzy) {
    if (text && term && term[0]) {
      if (isFuzzy) {
        return fuzzyHighlight(text, term) || text
      }

      return normalHighlight(text, term)
    }

    return text
  }

  export default new MdComponent({
    name: 'MdHighlightText',
    abstract: true,
    props: {
      mdTerm: String,
      mdFuzzySearch: {
        type: Boolean,
        default: true
      }
    },
    render (createElement) {
      try {
        const defaultSlot = this.$slots.default

        if (!defaultSlot) {
          return null
        }

        if (defaultSlot.length > 1 || defaultSlot[0].tag) {
          throw new Error()
        }

        const HTMLContent = generateHighlight(defaultSlot[0], this.mdTerm, this.mdFuzzySearch)

        return createElement('div', {
          staticClass: 'md-highlight-text',
          class: this.$mdActiveTheme,
          domProps: {
            innerHTML: HTMLContent
          }
        })
      } catch (e) {
        Vue.util.warn('MdHighlightText can only render text nodes.', this)
      }

      return null
    }
  })
</script>

<style lang="scss">
  .md-highlight-text {
    flex: 1;
  }

  .md-highlight-text-match {
    font-weight: 500;
  }
</style>

<script>
  import Vue from 'vue'
  import MdComponent from 'core/MdComponent'

  function getHTMLOutput (str) {
    let text = str

    if (!text) {
      text = '$&'
    }

    return `<span class="md-highlight-text-match">${text}</span>`
  }

  function fuzzyHighlight (text, term) {
    if (term.length === 0) {
      return text
    }

    const offset = text.toLowerCase().indexOf(term[0].toLowerCase())

    if (offset === -1) {
      return null
    }

    let last = 0
    for (let i = 1; i < term.length; i++) {
      if (text[offset + i] !== term[i]) {
        break
      }

      last = i
    }

    let before = text.slice(0, offset)
    let match = getHTMLOutput(text.slice(offset, offset + last + 1))
    let after = fuzzyHighlight(text.slice(offset + last + 1), term.slice(last + 1))

    return before + match + after
  }

  function normalHighlight (text, term) {
    const matches = new RegExp(term + '(?!([^<]+)?<)', 'gi')

    return text.replace(matches, getHTMLOutput())
  }

  function generateHighlight ({ text }, term, isFuzzy) {
    if (text && term && term[0]) {
      if (isFuzzy) {
        return fuzzyHighlight(text, term) || text
      }

      return normalHighlight(text, term)
    }

    return text
  }

  export default new MdComponent({
    name: 'MdHighlightText',
    abstract: true,
    props: {
      mdTerm: String,
      mdFuzzySearch: {
        type: Boolean,
        default: true
      }
    },
    render (createElement) {
      try {
        const defaultSlot = this.$slots.default

        if (!defaultSlot) {
          return null
        }

        if (defaultSlot.length > 1 || defaultSlot[0].tag) {
          throw new Error()
        }

        const HTMLContent = generateHighlight(defaultSlot[0], this.mdTerm, this.mdFuzzySearch)

        return createElement('div', {
          staticClass: 'md-highlight-text',
          class: this.$mdActiveTheme,
          domProps: {
            innerHTML: HTMLContent
          }
        })
      } catch (e) {
        Vue.util.warn('MdHighlightText can only render text nodes.', this)
      }

      return null
    }
  })
</script>

<style lang="scss">
  .md-highlight-text {
    flex: 1;
  }

  .md-highlight-text-match {
    font-weight: 500;
  }
</style>


Ensure there is a focused password menu item in the web client as well

<template>
  <modal-overlay @cancel="$emit('cancel')"
                 @keydown.native.arrow-down="advanceFocus(true)"
                 @keydown.native.arrow-up="advanceFocus(false)"
  >

      {{ $t("to_document") }}

      {{ $t("to_clipboard") }}

      {{ $t("show_qrcode") }}

      {{ $t(password.notes ? "edit_notes" : "add_notes") }}

      {{ $t("upgrade_password") }}

      {{ $t("make_generated") }}

      {{ $t("bump_revision") }}

      {{ $t("remove_password") }}

  </modal-overlay>
</template>


notes style update

notes style update

<template>

		<template v-if="isOperation('view')">
			<NotesList :notes="localNotes" @deleteClick="deleteNote" @editClick="editNote" confirmDelete editable v-if="operation==='view'"/>

 Brak notatek

			<Button @click="addNote" class="btn btn-primary">Dodaj Notatkę</Button>
		</template>
		<template v-if="isOperation('edit')">
			<NoteEditor :confirmEdit="noteToEdit.id" :note="noteToEdit" @discardChanges="discardEditChanges" @saveEditedNote="noteChangesHandler" auto-focus/>
		</template>

</template>

<template>
	<div class="calendar-notes">
		<template v-if="isOperation('view')">
			<NotesList :notes="localNotes" @deleteClick="deleteNote" @editClick="editNote" confirmDelete editable v-if="operation==='view'"/>
			<Button @click="addNote">Dodaj Notatkę</Button>
		</template>
		<template v-if="isOperation('edit')">
			<NoteEditor :confirmEdit="noteToEdit.id" :note="noteToEdit" @discardChanges="discardEditChanges" @saveEditedNote="noteChangesHandler" auto-focus/>
		</template>
	</div>
</template>

<script lang="ts">
    import {Component, Prop, Vue} from 'vue-property-decorator';
    import BootstrapPopup from "@/components/BootstrapPopup.vue";
    import {NoteInterface} from "@/components/Note.vue";
    import NoteEditor from "@/components/NoteEditor.vue";
    import NotesList from "@/components/NotesList.vue";

    @Component({
        components: {NoteEditor, NotesList, BootstrapPopup}
    })
    export default class CalendarNotes extends Vue {

        @Prop() onNoteUpdate: Function | undefined;
        @Prop() onNoteDelete: Function | undefined;
        @Prop() onNoteAdd: Function | undefined;
        @Prop({default: () => []}) notes!: NoteInterface[];
        @Prop({default: () => false}) editable!: boolean;

        private noteToEdit: NoteInterface | null = null;
        private operation: 'view' | 'edit' = 'view';
        private localNotes: NoteInterface[] = [];

        mounted(): void {
            this.localNotes = [...this.notes];
        }

        private isOperation(name: string): boolean {
            return name === this.operation;
        }

        private editNote(noteToEdit: NoteInterface): void {
            this.noteToEdit = noteToEdit;
            this.operation = "edit";
        }

        private addNote() {
            this.operation = "edit";
            this.noteToEdit = {id: null, content: ''};
        }

        private async deleteNote(noteToDel: NoteInterface): Promise<void> {
            const success = typeof this.onNoteUpdate === "function" ? await this.onNoteDelete!(noteToDel) : true;
            if (!success) return;
            this.removeNoteFromList(noteToDel);
        }

        private noteChangesHandler(note: NoteInterface): void {
            if (note.id) {
                this.saveEditedNote(note);
            } else {
                this.addAndSaveNote(note);
            }
            this.noteToEdit = {id: null, content: ''};
            this.operation = "view";
        }

        private async addAndSaveNote(note: NoteInterface): Promise<void> {
            const noteId = typeof this.onNoteAdd === "function" ? await this.onNoteAdd!(note) : true;
            if (!noteId) return;
            this.addNoteToList({id: noteId, content: note.content});
        }

        private addNoteToList(newNote: NoteInterface) {
            this.localNotes.push(newNote);
        }

        private async saveEditedNote(newNote: NoteInterface): Promise<void> {
            const success = typeof this.onNoteUpdate === "function" ? await this.onNoteUpdate!(newNote) : true;
            if (!success) return;
            this.updateNoteInList(newNote);
        }

        private updateNoteInList(newNote: NoteInterface): void {
            this.localNotes = this.localNotes.map((note: NoteInterface) => {
                if (note.id === newNote.id) {
                    return {
                        ...note,
                        content: newNote.content
                    }
                } else {
                    return note;
                }
            })
        }

        private removeNoteFromList(noteToDel: NoteInterface): void {
            this.localNotes = this.localNotes.filter((note: NoteInterface) => note.id !== noteToDel.id);
        }

        private discardEditChanges(): void {
            this.noteToEdit = {id: null, content: ''};
            this.operation = "view";
        }
    }
</script>

<style lang="less">
	.title-date {
		font-weight: bold;
	}
</style>


Add wrapper to resize sunburst chart on mobile but so that it still works in IE

<template>

{{ name }}

            {{ item.name }}
            {{ styledNumber(item.size) }}km

</template>


Revert "update chevron to asset from figma design sytem"

This reverts commit 5131f2629c96997db10ea3ab316f8780d31f71ed.

<template>

 1
        ? `ml-${depth + 3} pl-2 border-l-2 border-gray-200`
        : 'px-7 pb-4'
    "
    class="space-y-1"
    :data-test="folder"
  >
    <button
      class="group w-full flex items-center text-left text-lg font-bold text-gray-600 hover:text-gray-900 hover:bg-gray-50 focus:outline-none"
      @click="toggleSection"
    >
      {{ label }}

      <svg
        :class="isOpen ? 'text-gray-400 rotate-90' : 'text-gray-300'"
        class="ml-auto h-5 w-5 transform group-hover:text-gray-400 transition-colors ease-in-out duration-150"
        viewBox="0 0 20 20"
        aria-hidden="true"
      >
        <path d="M6 6L14 10L6 14V6Z" fill="currentColor" />
      </svg>
    </button>

    <ul
      :class="isOpen ? '' : 'hidden'"
      class="space-y-1 m-0"
      :data-test="`${label}-children`"
    >
      <div
        v-for="(child, index) in children"
        :key="`collapsible-sidebar-section-${index}`"
      >
        <CollapsibleSidebarSection
          v-if="child.children"
          :label="child.title"
          :folder="child.slug"
          :section="
            parentSection ? section.concat(`/`).concat(parentSection) : section
          "
          :parent-section="parentSection"
          :children="child.children"
          :initial-is-open="path.endsWith(`${folder}/${child.slug}`)"
          :path="path"
          :name="child.slug"
          :depth="depth + 1"
        />

          <nuxt-link
            :to="child.redirect || `/${section}/${folder}/${child.slug}`"
            :class="getSidebarItemClass(folder, child)"
            class="rounded-md group w-full flex items-center pl-3 pr-2 py-1 text-md font-medium hover:text-green transition-colors hover:bg-gray-50"
          >
            {{ child.title }}
          </nuxt-link>

            {{ child.title }}

</template>

<template>

 1
        ? `ml-${depth + 3} pl-2 border-l-2 border-gray-200`
        : 'px-7 pb-4'
    "
    class="space-y-1"
    :data-test="folder"
  >
    <button
      class="group w-full flex items-center text-left text-lg font-bold text-gray-600 hover:text-gray-900 hover:bg-gray-50 focus:outline-none"
      @click="toggleSection"
    >
      {{ label }}

      <svg :class="isOpen ? 'text-gray-700 rotate-180' : 'text-gray-900'"
        class="ml-auto h-5 w-5 transform group-hover:text-gray-500 transition-colors ease-in-out duration-150"
        aria-hidden="true"
        width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"
      >
        <path d="M4 6L8 10L12 6" stroke="#9095AD" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
      </svg>
    </button>

    <ul
      :class="isOpen ? '' : 'hidden'"
      class="space-y-1 m-0"
      :data-test="`${label}-children`"
    >
      <div
        v-for="(child, index) in children"
        :key="`collapsible-sidebar-section-${index}`"
      >
        <CollapsibleSidebarSection
          v-if="child.children"
          :label="child.title"
          :folder="child.slug"
          :section="
            parentSection ? section.concat(`/`).concat(parentSection) : section
          "
          :parent-section="parentSection"
          :children="child.children"
          :initial-is-open="path.endsWith(`${folder}/${child.slug}`)"
          :path="path"
          :name="child.slug"
          :depth="depth + 1"
        />

          <nuxt-link
            :to="child.redirect || `/${section}/${folder}/${child.slug}`"
            :class="getSidebarItemClass(folder, child)"
            class="rounded-md group w-full flex items-center pl-3 pr-2 py-1 text-md font-medium hover:text-green transition-colors hover:bg-gray-50"
          >
            {{ child.title }}
          </nuxt-link>

            {{ child.title }}

</template>


Async validators.

Async validators.


Make sure that the type is being passed to the backend

<template>

      <autocomplete
        :key="autocompleteKey"
        v-model="autoCompleteResults"
        :placeholder="autocompletePlaceholder"
        :autocomplete-callback="autocompleteCallback"
        :error-messages="autocompleteErrorMessages"
        @submit="emitZoomToEvent"
      />

</template>


Added toggle method on KWidget

Added toggle method on KWidget

<template>
  <q-fixed-position :corner="currentCorner" :offset="currentOffset">
    <q-popover
      ref="popover"
      :anchor-click="false"
      anchor="center middle"
      :self="currentSelf"
      max-height="100%"
      :style="currentCss">

          <template v-for="action in toolbar">
            <q-btn :id="action.name" v-bind:key="action.name" flat round small color="primary" @click="action.handler">
              <q-icon :name="action.icon" />
              <q-tooltip v-if="action.label">{{action.label}}</q-tooltip>
            </q-btn>
          </template>

          <slot name="widget-content" />

    </q-popover>
  </q-fixed-position>
</template>


Moar aggressive garbage stripping in activate-id of the Materialize Dropdown

<template>

      <slot></slot>

</template>

<template>

      <slot></slot>

</template>


Revert "fix: 修复 text 组件首尾换行解析"

Revert "fix: 修复 text 组件首尾换行解析"

This reverts commit 2b65398bd4fc79caecaf989e9ed636f02607a523.

<script>
const SPACE_UNICODE = {
  ensp: '\u2002',
  emsp: '\u2003',
  nbsp: '\u00a0'
}

export default {
  name: 'Text',
  props: {
    selectable: {
      type: [Boolean, String],
      default: false
    },
    space: {
      type: String,
      default: ''
    },
    decode: {
      type: [Boolean, String],
      default: false
    }
  },
  methods: {
    _decodeHtml (htmlString) {
      if (this.space && SPACE_UNICODE[this.space]) {
        htmlString = htmlString.replace(/ /g, SPACE_UNICODE[this.space])
      }

      if (this.decode) {
        htmlString = htmlString.replace(/ /g, SPACE_UNICODE.nbsp).replace(/ /g, SPACE_UNICODE.ensp).replace(
          / /g, SPACE_UNICODE.emsp).replace(/</g,
          '<').replace(/>/g, '>').replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'")
      }

      return htmlString
    }
  },
  render (createElement) {
    const nodeList = []
    this.$slots.default && this.$slots.default.forEach(vnode => {
      if (vnode.text) {
        // 处理可能出现的多余的转义字符
        const nodeText = vnode.text.replace(/\\n/g, '\n')
        const texts = nodeText.split('\n')
        texts.forEach((text, index) => {
          nodeList.push(this._decodeHtml(text))
          if (index !== (texts.length - 1)) {
            nodeList.push(createElement('br'))
          }
        })
      } else {
        if (vnode.componentOptions && vnode.componentOptions.tag !== 'v-uni-text') {
          console.warn('<text> 组件内只支持嵌套 <text>，不支持其它组件或自定义组件，否则会引发在不同平台的渲染差异。')
        }
        nodeList.push(vnode)
      }
    })
    return createElement('uni-text', {
      on: this.$listeners,
      attrs: {
        selectable: !!this.selectable
      }
    }, [
      createElement('span', {}, nodeList)
    ])
  }
}
</script>
<style>
	uni-text[selectable] {
    cursor: auto;
		user-select: text;
		-webkit-user-select: text;
	}
</style>

<script>
const SPACE_UNICODE = {
  ensp: '\u2002',
  emsp: '\u2003',
  nbsp: '\u00a0'
}

export default {
  name: 'Text',
  props: {
    selectable: {
      type: [Boolean, String],
      default: false
    },
    space: {
      type: String,
      default: ''
    },
    decode: {
      type: [Boolean, String],
      default: false
    }
  },
  methods: {
    _decodeHtml (htmlString) {
      if (this.space && SPACE_UNICODE[this.space]) {
        htmlString = htmlString.replace(/ /g, SPACE_UNICODE[this.space])
      }

      if (this.decode) {
        htmlString = htmlString.replace(/ /g, SPACE_UNICODE.nbsp).replace(/ /g, SPACE_UNICODE.ensp).replace(
          / /g, SPACE_UNICODE.emsp).replace(/</g,
          '<').replace(/>/g, '>').replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'")
      }

      return htmlString
    }
  },
  render (createElement) {
    const nodeList = []
    this.$slots.default && this.$slots.default.forEach(vnode => {
      if (vnode.text) {
        // 处理可能出现的多余的转义字符
        const nodeText = vnode.text.replace(/\\n/g, '\n')
        const texts = this._decodeHtml(nodeText).trim().split('\n')
        texts.forEach((text, index) => {
          nodeList.push(text)
          if (index !== (texts.length - 1)) {
            nodeList.push(createElement('br'))
          }
        })
      } else {
        if (vnode.componentOptions && vnode.componentOptions.tag !== 'v-uni-text') {
          console.warn('<text> 组件内只支持嵌套 <text>，不支持其它组件或自定义组件，否则会引发在不同平台的渲染差异。')
        }
        nodeList.push(vnode)
      }
    })
    return createElement('uni-text', {
      on: this.$listeners,
      attrs: {
        selectable: !!this.selectable
      }
    }, [
      createElement('span', {}, nodeList)
    ])
  }
}
</script>
<style>
	uni-text[selectable] {
    cursor: auto;
		user-select: text;
		-webkit-user-select: text;
	}
</style>


EBEAST: b/contextmenu.vue: emit `close` event after menu closes

Signed-off-by: Tim Janik <6ec6ff229c4e1c5bbd5933ee92e6c5832ff023bb@gnu.org>

<!-- This Source Code Form is licensed MPL-2.0: http://mozilla.org/MPL/2.0 -->

<docs>
  # B-CONTEXTMENU
  A modal popup that displays contextmenu choices, see [B-MENUITEM](#b-menuitem),
  [B-MENUSEPARATOR](#b-menuseparator).
  Using the `popup()` method, the menu can be popped up from the parent component,
  and setting up an `onclick` handler can be used to handle menuitem actions. Example:
  ```html
  <div @contextmenu.prevent="$refs.cmenu.popup">
    <b-contextmenu ref="cmenu" @click="menuactivation">...</b-contextmenu>
  </div>
  ```
  ## Props:
  *xscale*
  : Consider a wider area than the context menu width for popup positioning.
  *yscale*
  : Consider a taller area than the context menu height for popup positioning.
  ## Events:
  *click (uri)*
  : Event signaling activation of a submenu item, the `uri` of the submenu is provided as argument.
  *close*
  : Event signaling closing of a menu, regardless of whether menu item activation occoured or not.
  ## Methods:
  *popup (event, { origin, tieclass })*
  : Popup the contextmenu, the `event` coordinates are used for positioning, the `origin` is a
  : reference DOM element to use for drop-down positioning.
  *close()*
  : Hide the contextmenu.
</docs>

<style lang="scss">
  @import 'mixins.scss';
  body div.b-contextmenu-area {				    //* constraint area for context menu placement */
    position: fixed; right: 0; bottom: 0;		    //* fixed bottom right */
    display: flex; flex-direction: column; align-items: flex-start;
    //* transition */
    $timing: 0.1s;
    &.v-enter-active	{ transition: opacity $timing ease-out, transform $timing linear; }
    &.v-leave-active	{ transition: opacity $timing ease-in,  transform $timing linear; }
    &.v-enter	 	{ opacity: 0.3; transform: translateX(-0.3vw) translateY(-1vh); }
    &.v-leave-to	{ opacity: 0; transform: translateX(-0%) translateY(-0%) scale(1); }
    &.b-contextmenu-notransitions { transition: none !important; }
  }
  body .b-contextmenu-shield {
    background: #0001;
    transition: background 0.1s;
  }
  body .b-contextmenu-area .b-contextmenu {	//* since menus are often embedded, this needs high specificity */
    //* The template uses flex+start for layouting to scroll without vertical shrinking */
    position: relative; max-width: 100%; max-height: 100%;  //* constrain to .b-contextmenu-area */
    overflow-y: auto; overflow-x: hidden;		    //* scroll if necessary */
    padding: $b-menu-padding 0;
    background-color: $b-menu-background; border: 1px outset darken($b-menu-background, 20%);
    color: $b-menu-foreground;
    box-shadow: $b-menu-box-shadow;
    [disabled], [disabled] * { pointer-events: none; }
  }
</style>

<template>
  <transition>
    <div class='b-contextmenu-area' :class='cmenu_class' ref='contextmenuarea' v-if='visible' >
      <b-vflex class='b-contextmenu' ref='cmenu' start >
	<slot />
      </b-vflex>
    </div>
  </transition>
</template>

<script>
const menuitem_onclick = function (event) {
  this.$emit ('click', event, this.uri);
  if (!event.defaultPrevented)
    {
      if (this.uri !== undefined && this.menudata.clicked)
	this.menudata.clicked (this.uri);
      else if (this.menudata.close)
	this.menudata.close();
    }
  event.stopPropagation();
  event.preventDefault(); // avoid submit, etc
};

const menuitem_isdisabled = function () {
  if (this.uri && undefined !== this.menudata.checkeduris[this.uri])
    return !this.menudata.checkeduris[this.uri];
  if (this.disabled == "" || !!this.disabled ||
      (this.$attrs && (this.$attrs['disabled'] == "" || !!this.$attrs['disabled'])))
    return true;
  return false;
};

export default {
  name: 'b-contextmenu',
  props: { notransitions: { default: false },
	   xscale: { default: 1, },
	   yscale: { default: 1, }, },
  computed: {
    cmenu_class() { return this.notransitions !== false ? 'b-contextmenu-notransitions' : ''; },
  },
  data_tmpl: { visible: false, doc_x: undefined, doc_y: undefined,
	       resize_observer: undefined, checkeduris: {},
	       showicons: true, showaccels: true, popup_options: {},
	       onclick: menuitem_onclick, isdisabled: menuitem_isdisabled, },
  provide: Util.fwdprovide ('b-contextmenu.menudata',	// context for menuitem descendants
			    [ 'checkeduris', 'showicons', 'showaccels', 'clicked', 'close', 'onclick', 'isdisabled' ]),
  methods: {
    dom_update () {
      if (!this.resize_observer)
	{
	  this.resize_observer = new Util.ResizeObserver ((e, ro) => {
	    if (this.resize_timer)
	      return;
	    this.resize_timer = setTimeout (() => {
	      this.resize_timer = 0;
	      this.position_popup();
	    }, 1);
	  });
	}
      else if (this.resize_timer)
	{
	  clearTimeout (this.resize_timer);
	  this.resize_timer = 0;
	}
      this.resize_observer.disconnect();
      this.update_shield();
      if (this.$refs.cmenu)
	{
	  this.checkitems();
	  this.position_popup();
	  this.resize_observer.observe (document.body);
	  if (this.popup_options?.origin)
	    this.resize_observer.observe (this.popup_options.origin.$el || this.popup_options.origin);
	}
      else if (this.tieclass)
	{
	  this.tieclass.element.classList.remove (this.tieclass.class);
	  this.tieclass = undefined;
	}
    },
    dom_destroy () {
      this.clear_dragging();
      if (this.tieclass)
	{
	  this.tieclass.element.classList.remove (this.tieclass.class);
	  this.tieclass = undefined;
	}
      this.resize_observer.disconnect();
      this.resize_observer = undefined;
      if (this.resize_timer)
	clearTimeout (this.resize_timer);
      this.resize_timer = 0;
      if (this.shield)
	this.shield.destroy (false);
      this.shield = undefined;
    },
    position_popup() {
      const area_el = this.$refs.contextmenuarea;
      if (area_el && area_el.getBoundingClientRect) // ignore Vue placeholder (html comment)
	{
	  const menu_el = this.$refs.cmenu;
	  // unset size constraints before calculating desired size, otherwise resizing
	  // can take dozens of resize_observer/popup_position frame iterations
	  area_el.style.left = '0px';
	  area_el.style.top = '0px';
	  const p = Util.popup_position (menu_el, { x: this.doc_x, xscale: this.xscale,
						    y: this.doc_y, yscale: this.yscale,
						    origin: this.popup_options.origin?.$el || this.popup_options.origin });
	  area_el.style.left = p.x + "px";
	  area_el.style.top = p.y + "px";
	}
    },
    checkitems() {
      if (!this.popup_options.checker)
	return;
      const checkrecursive = component => {
	if (component instanceof Element &&
	    component.__vue__)
	  component = component.__vue__;
	if (component.$options && component.$options.propsData && component.$options.propsData.uri)
	  {
	    let async_check = async () => {
	      let result = this.popup_options.checker.call (null, component.$options.propsData.uri, component);
	      result = await result;
	      if ('boolean' !== typeof result)
		result = undefined;
	      if (result != this.checkeduris[component.$options.propsData.uri])
		{
		  this.$set (this.checkeduris, component.$options.propsData.uri, result); // Vue reactivity
		  component.$forceUpdate();
		}
	    };
	    async_check();
	  }
	if (component.$children)
	  for (let child of component.$children)
	    checkrecursive (child);
	else if (component.children) // DOM element, possibly a function component
	  for (let child of component.children)
	    checkrecursive (child);
      };
      if (this.$refs.cmenu)
	checkrecursive (this.$refs.cmenu);
    },
    update_shield() {
      const contextmenu = this.$refs.cmenu;
      if (!contextmenu && this.shield)
	{
	  this.shield.destroy (false);
	  this.shield = undefined;
	}
      if (contextmenu && this.visible && !this.shield)
	this.shield = Util.modal_shield (contextmenu, { class: 'b-contextmenu-shield',
							root: this.$refs.contextmenuarea,
							close: this.close });
    },
    popup (event, options) {
      this.popup_options = Object.assign ({}, options || {});
      this.visible = false;
      if (this.tieclass)
	this.tieclass.element.classList.remove (this.tieclass.class);
      this.tieclass = this.popup_options.tieclass;
      this.clear_dragging();
      if (event && event.pageX && event.pageY)
	{
	  this.doc_x = event.pageX;
	  this.doc_y = event.pageY;
	}
      else
	this.doc_x = this.doc_y = undefined;
      if (event.type == "mousedown")
	{
	  console.assert (!this.dragging);
	  this.dragging = {
	    button: event.button,
	    ignoreclick: true,
	    timer: setTimeout (() => { if (this.dragging) this.dragging.ignoreclick = false; }, 500),
	    handler: (e) => this.drag_event (e),
	    evpassive: { capture: true, passive: true },
	    evactive: { capture: false, passive: false }
	  };
	  window.addEventListener ('mouseup',   this.dragging.handler, this.dragging.evactive);
	  window.addEventListener ('mousedown', this.dragging.handler, this.dragging.evpassive);
	  window.addEventListener ('keydown',   this.dragging.handler, this.dragging.evpassive);
	}
      if (this.tieclass)
	this.tieclass.element.classList.add (this.tieclass.class);
      this.visible = true;
    },
    clear_dragging() {
      if (!this.dragging)
	return;
      if (this.dragging.ignoreclick)
	clearTimeout (this.dragging.timer);
      window.removeEventListener ('mouseup',   this.dragging.handler, this.dragging.evactive);
      window.removeEventListener ('mousedown', this.dragging.handler, this.dragging.evpassive);
      window.removeEventListener ('keydown',   this.dragging.handler, this.dragging.evpassive);
      this.dragging = undefined;
    },
    drag_event (event) {
      console.assert (this.dragging);
      let clickit = null;
      if (this.visible && !this.dragging.ignoreclick &&
	  event.type == 'mouseup' && event.button == this.dragging.button &&
	  this.$refs.cmenu.contains (document.activeElement) &&
	  document.activeElement.contains (event.target))
	{
	  clickit = document.activeElement;
	  event.stopPropagation(); // avoid other 'mouseup' handlers
	  event.preventDefault();  // avoid generating 'click' from 'mouseup'
	}
      // clean up `dragging` state
      this.clear_dragging();
      // activate due to valid drag-selection
      if (clickit)
	Util.keyboard_click (clickit);
    },
    close () {
      this.clear_dragging();
      if (!this.visible)
	return;
      this.visible = false;
      // take down shield immediately, to remove focus guards
      if (this.shield)
	this.shield.destroy (false);
      this.shield = undefined;
      this.$emit ('close');
    },
    clicked (uri) {
      this.$emit ('click', uri);
      this.close();
    },
  },
};
</script>

<!-- This Source Code Form is licensed MPL-2.0: http://mozilla.org/MPL/2.0 -->

<docs>
  # B-CONTEXTMENU
  A modal popup that displays contextmenu choices, see [B-MENUITEM](#b-menuitem),
  [B-MENUSEPARATOR](#b-menuseparator).
  Using the `popup()` method, the menu can be popped up from the parent component,
  and setting up an `onclick` handler can be used to handle menuitem actions. Example:
  ```html
  <div @contextmenu.prevent="$refs.cmenu.popup">
    <b-contextmenu ref="cmenu" @click="menuactivation">...</b-contextmenu>
  </div>
  ```
  ## Props:
  *xscale*
  : Consider a wider area than the context menu width for popup positioning.
  *yscale*
  : Consider a taller area than the context menu height for popup positioning.
  ## Events:
  *click (uri)*
  : Event signaling activation of a submenu item, the `uri` of the submenu is provided as argument.
  ## Methods:
  *popup (event, { origin, tieclass })*
  : Popup the contextmenu, the `event` coordinates are used for positioning, the `origin` is a
  : reference DOM element to use for drop-down positioning.
  *close()*
  : Hide the contextmenu.
</docs>

<style lang="scss">
  @import 'mixins.scss';
  body div.b-contextmenu-area {				    //* constraint area for context menu placement */
    position: fixed; right: 0; bottom: 0;		    //* fixed bottom right */
    display: flex; flex-direction: column; align-items: flex-start;
    //* transition */
    $timing: 0.1s;
    &.v-enter-active	{ transition: opacity $timing ease-out, transform $timing linear; }
    &.v-leave-active	{ transition: opacity $timing ease-in,  transform $timing linear; }
    &.v-enter	 	{ opacity: 0.3; transform: translateX(-0.3vw) translateY(-1vh); }
    &.v-leave-to	{ opacity: 0; transform: translateX(-0%) translateY(-0%) scale(1); }
    &.b-contextmenu-notransitions { transition: none !important; }
  }
  body .b-contextmenu-shield {
    background: #0001;
    transition: background 0.1s;
  }
  body .b-contextmenu-area .b-contextmenu {	//* since menus are often embedded, this needs high specificity */
    //* The template uses flex+start for layouting to scroll without vertical shrinking */
    position: relative; max-width: 100%; max-height: 100%;  //* constrain to .b-contextmenu-area */
    overflow-y: auto; overflow-x: hidden;		    //* scroll if necessary */
    padding: $b-menu-padding 0;
    background-color: $b-menu-background; border: 1px outset darken($b-menu-background, 20%);
    color: $b-menu-foreground;
    box-shadow: $b-menu-box-shadow;
    [disabled], [disabled] * { pointer-events: none; }
  }
</style>

<template>
  <transition>
    <div class='b-contextmenu-area' :class='cmenu_class' ref='contextmenuarea' v-if='visible' >
      <b-vflex class='b-contextmenu' ref='cmenu' start >
	<slot />
      </b-vflex>
    </div>
  </transition>
</template>

<script>
const menuitem_onclick = function (event) {
  this.$emit ('click', event, this.uri);
  if (!event.defaultPrevented)
    {
      if (this.uri !== undefined && this.menudata.clicked)
	this.menudata.clicked (this.uri);
      else if (this.menudata.close)
	this.menudata.close();
    }
  event.stopPropagation();
  event.preventDefault(); // avoid submit, etc
};

const menuitem_isdisabled = function () {
  if (this.uri && undefined !== this.menudata.checkeduris[this.uri])
    return !this.menudata.checkeduris[this.uri];
  if (this.disabled == "" || !!this.disabled ||
      (this.$attrs && (this.$attrs['disabled'] == "" || !!this.$attrs['disabled'])))
    return true;
  return false;
};

export default {
  name: 'b-contextmenu',
  props: { notransitions: { default: false },
	   xscale: { default: 1, },
	   yscale: { default: 1, }, },
  computed: {
    cmenu_class() { return this.notransitions !== false ? 'b-contextmenu-notransitions' : ''; },
  },
  data_tmpl: { visible: false, doc_x: undefined, doc_y: undefined,
	       resize_observer: undefined, checkeduris: {},
	       showicons: true, showaccels: true, popup_options: {},
	       onclick: menuitem_onclick, isdisabled: menuitem_isdisabled, },
  provide: Util.fwdprovide ('b-contextmenu.menudata',	// context for menuitem descendants
			    [ 'checkeduris', 'showicons', 'showaccels', 'clicked', 'close', 'onclick', 'isdisabled' ]),
  methods: {
    dom_update () {
      if (!this.resize_observer)
	{
	  this.resize_observer = new Util.ResizeObserver ((e, ro) => {
	    if (this.resize_timer)
	      return;
	    this.resize_timer = setTimeout (() => {
	      this.resize_timer = 0;
	      this.position_popup();
	    }, 1);
	  });
	}
      else if (this.resize_timer)
	{
	  clearTimeout (this.resize_timer);
	  this.resize_timer = 0;
	}
      this.resize_observer.disconnect();
      this.update_shield();
      if (this.$refs.cmenu)
	{
	  this.checkitems();
	  this.position_popup();
	  this.resize_observer.observe (document.body);
	  if (this.popup_options?.origin)
	    this.resize_observer.observe (this.popup_options.origin.$el || this.popup_options.origin);
	}
      else if (this.tieclass)
	{
	  this.tieclass.element.classList.remove (this.tieclass.class);
	  this.tieclass = undefined;
	}
    },
    dom_destroy () {
      this.clear_dragging();
      if (this.tieclass)
	{
	  this.tieclass.element.classList.remove (this.tieclass.class);
	  this.tieclass = undefined;
	}
      this.resize_observer.disconnect();
      this.resize_observer = undefined;
      if (this.resize_timer)
	clearTimeout (this.resize_timer);
      this.resize_timer = 0;
      if (this.shield)
	this.shield.destroy (false);
      this.shield = undefined;
    },
    position_popup() {
      const area_el = this.$refs.contextmenuarea;
      if (area_el && area_el.getBoundingClientRect) // ignore Vue placeholder (html comment)
	{
	  const menu_el = this.$refs.cmenu;
	  // unset size constraints before calculating desired size, otherwise resizing
	  // can take dozens of resize_observer/popup_position frame iterations
	  area_el.style.left = '0px';
	  area_el.style.top = '0px';
	  const p = Util.popup_position (menu_el, { x: this.doc_x, xscale: this.xscale,
						    y: this.doc_y, yscale: this.yscale,
						    origin: this.popup_options.origin?.$el || this.popup_options.origin });
	  area_el.style.left = p.x + "px";
	  area_el.style.top = p.y + "px";
	}
    },
    checkitems() {
      if (!this.popup_options.checker)
	return;
      const checkrecursive = component => {
	if (component instanceof Element &&
	    component.__vue__)
	  component = component.__vue__;
	if (component.$options && component.$options.propsData && component.$options.propsData.uri)
	  {
	    let async_check = async () => {
	      let result = this.popup_options.checker.call (null, component.$options.propsData.uri, component);
	      result = await result;
	      if ('boolean' !== typeof result)
		result = undefined;
	      if (result != this.checkeduris[component.$options.propsData.uri])
		{
		  this.$set (this.checkeduris, component.$options.propsData.uri, result); // Vue reactivity
		  component.$forceUpdate();
		}
	    };
	    async_check();
	  }
	if (component.$children)
	  for (let child of component.$children)
	    checkrecursive (child);
	else if (component.children) // DOM element, possibly a function component
	  for (let child of component.children)
	    checkrecursive (child);
      };
      if (this.$refs.cmenu)
	checkrecursive (this.$refs.cmenu);
    },
    update_shield() {
      const contextmenu = this.$refs.cmenu;
      if (!contextmenu && this.shield)
	{
	  this.shield.destroy (false);
	  this.shield = undefined;
	}
      if (contextmenu && this.visible && !this.shield)
	this.shield = Util.modal_shield (contextmenu, { class: 'b-contextmenu-shield',
							root: this.$refs.contextmenuarea,
							close: this.close });
    },
    popup (event, options) {
      this.popup_options = Object.assign ({}, options || {});
      this.visible = false;
      if (this.tieclass)
	this.tieclass.element.classList.remove (this.tieclass.class);
      this.tieclass = this.popup_options.tieclass;
      this.clear_dragging();
      if (event && event.pageX && event.pageY)
	{
	  this.doc_x = event.pageX;
	  this.doc_y = event.pageY;
	}
      else
	this.doc_x = this.doc_y = undefined;
      if (event.type == "mousedown")
	{
	  console.assert (!this.dragging);
	  this.dragging = {
	    button: event.button,
	    ignoreclick: true,
	    timer: setTimeout (() => { if (this.dragging) this.dragging.ignoreclick = false; }, 500),
	    handler: (e) => this.drag_event (e),
	    evpassive: { capture: true, passive: true },
	    evactive: { capture: false, passive: false }
	  };
	  window.addEventListener ('mouseup',   this.dragging.handler, this.dragging.evactive);
	  window.addEventListener ('mousedown', this.dragging.handler, this.dragging.evpassive);
	  window.addEventListener ('keydown',   this.dragging.handler, this.dragging.evpassive);
	}
      if (this.tieclass)
	this.tieclass.element.classList.add (this.tieclass.class);
      this.visible = true;
    },
    clear_dragging() {
      if (!this.dragging)
	return;
      if (this.dragging.ignoreclick)
	clearTimeout (this.dragging.timer);
      window.removeEventListener ('mouseup',   this.dragging.handler, this.dragging.evactive);
      window.removeEventListener ('mousedown', this.dragging.handler, this.dragging.evpassive);
      window.removeEventListener ('keydown',   this.dragging.handler, this.dragging.evpassive);
      this.dragging = undefined;
    },
    drag_event (event) {
      console.assert (this.dragging);
      let clickit = null;
      if (this.visible && !this.dragging.ignoreclick &&
	  event.type == 'mouseup' && event.button == this.dragging.button &&
	  this.$refs.cmenu.contains (document.activeElement) &&
	  document.activeElement.contains (event.target))
	{
	  clickit = document.activeElement;
	  event.stopPropagation(); // avoid other 'mouseup' handlers
	  event.preventDefault();  // avoid generating 'click' from 'mouseup'
	}
      // clean up `dragging` state
      this.clear_dragging();
      // activate due to valid drag-selection
      if (clickit)
	Util.keyboard_click (clickit);
    },
    close () {
      this.clear_dragging();
      if (!this.visible)
	return;
      this.visible = false;
      // take down shield immediately, to remove focus guards
      if (this.shield)
	this.shield.destroy (false);
      this.shield = undefined;
    },
    clicked (uri) {
      debug ("contextmenu: click:", uri);
      this.$emit ('click', uri);
      this.close();
    },
  },
};
</script>