fix: 修复 editor 组件粘贴文字报错的问题 question/102948

<template>
  <uni-editor
    :id="id"
    class="ql-container"
    v-on="$listeners"
  />
</template>

<script>
import {
  subscriber,
  emitter,
  keyboard
} from 'uni-mixins'
import HTMLParser from 'uni-helpers/html-parser'
import * as formats from './formats'

export default {
  name: 'Editor',
  mixins: [subscriber, emitter, keyboard],
  props: {
    id: {
      type: String,
      default: ''
    },
    readOnly: {
      type: [Boolean, String],
      default: false
    },
    placeholder: {
      type: String,
      default: ''
    },
    showImgSize: {
      type: [Boolean, String],
      default: false
    },
    showImgToolbar: {
      type: [Boolean, String],
      default: false
    },
    showImgResize: {
      type: [Boolean, String],
      default: false
    }
  },
  data () {
    return {
      quillReady: false
    }
  },
  computed: {
  },
  watch: {
    readOnly (value) {
      if (this.quillReady) {
        const quill = this.quill
        quill.enable(!value)
        if (!value) {
          quill.blur()
        }
      }
    },
    placeholder (value) {
      if (this.quillReady) {
        this.quill.root.setAttribute('data-placeholder', value)
      }
    }
  },
  mounted () {
    const imageResizeModules = []
    if (this.showImgSize) {
      imageResizeModules.push('DisplaySize')
    }
    if (this.showImgToolbar) {
      imageResizeModules.push('Toolbar')
    }
    if (this.showImgResize) {
      imageResizeModules.push('Resize')
    }
    this.loadQuill(() => {
      if (imageResizeModules.length) {
        this.loadImageResizeModule(() => {
          this.initQuill(imageResizeModules)
        })
      } else {
        this.initQuill(imageResizeModules)
      }
    })
  },
  methods: {
    _handleSubscribe ({
      type,
      data
    }) {
      const { options, callbackId } = data
      const quill = this.quill
      const Quill = window.Quill
      let res
      let range
      let errMsg
      if (this.quillReady) {
        switch (type) {
          case 'format':
            {
              let { name = '', value = false } = options
              range = quill.getSelection(true)
              let format = quill.getFormat(range)[name] || false
              if (['bold', 'italic', 'underline', 'strike', 'ins'].includes(name)) {
                value = !format
              } else if (name === 'direction') {
                value = value === 'rtl' && format ? false : value
                const align = quill.getFormat(range).align
                if (value === 'rtl' && !align) {
                  quill.format('align', 'right', Quill.sources.USER)
                } else if (!value && align === 'right') {
                  quill.format('align', false, Quill.sources.USER)
                }
              } else if (name === 'indent') {
                const rtl = quill.getFormat(range).direction === 'rtl'
                value = value === '+1'
                if (rtl) {
                  value = !value
                }
                value = value ? '+1' : '-1'
              } else {
                if (name === 'list') {
                  value = value === 'check' ? 'unchecked' : value
                  format = format === 'checked' ? 'unchecked' : format
                }
                value = ((format && format !== (value || false)) || (!format && value)) ? value : !format
              }
              quill.format(name, value, Quill.sources.USER)
            }
            break
          case 'insertDivider':
            range = quill.getSelection(true)
            quill.insertText(range.index, '\n', Quill.sources.USER)
            quill.insertEmbed(range.index + 1, 'divider', true, Quill.sources.USER)
            quill.setSelection(range.index + 2, Quill.sources.SILENT)
            break
          case 'insertImage':
            {
              range = quill.getSelection(true)
              const { src = '', alt = '', width = '', height = '', extClass = '', data = {} } = options
              const path = this.$getRealPath(src)
              quill.insertEmbed(range.index, 'image', path, Quill.sources.USER)
              const local = /^(file|blob):/.test(path) ? path : false
              quill.formatText(range.index, 1, 'data-local', local)
              quill.formatText(range.index, 1, 'alt', alt)
              quill.formatText(range.index, 1, 'width', width)
              quill.formatText(range.index, 1, 'height', height)
              quill.formatText(range.index, 1, 'class', extClass)
              quill.formatText(range.index, 1, 'data-custom', Object.keys(data).map(key => `${key}=${data[key]}`).join('&'))
              quill.setSelection(range.index + 1, Quill.sources.SILENT)
            }
            break
          case 'insertText':
            {
              range = quill.getSelection(true)
              const { text = '' } = options
              quill.insertText(range.index, text, Quill.sources.USER)
              quill.setSelection(range.index + text.length, 0, Quill.sources.SILENT)
            }
            break
          case 'setContents':
            {
              const { delta, html } = options
              if (typeof delta === 'object') {
                quill.setContents(delta, Quill.sources.SILENT)
              } else if (typeof html === 'string') {
                quill.setContents(this.html2delta(html), Quill.sources.SILENT)
              } else {
                errMsg = 'contents is missing'
              }
            }
            break
          case 'getContents':
            res = this.getContents()
            break
          case 'clear':
            quill.setContents([])
            break
          case 'removeFormat':
            {
              range = quill.getSelection(true)
              const parchment = Quill.import('parchment')
              if (range.length) {
                quill.removeFormat(range, Quill.sources.USER)
              } else {
                Object.keys(quill.getFormat(range)).forEach(key => {
                  if (parchment.query(key, parchment.Scope.INLINE)) {
                    quill.format(key, false)
                  }
                })
              }
            }
            break
          case 'undo':
            quill.history.undo()
            break
          case 'redo':
            quill.history.redo()
            break
          default:
            break
        }
        this.updateStatus(range)
      } else {
        errMsg = 'not ready'
      }
      if (callbackId) {
        UniViewJSBridge.publishHandler('onEditorMethodCallback', {
          callbackId,
          data: Object.assign({}, res, {
            errMsg: `${type}:${errMsg ? 'fail ' + errMsg : 'ok'}`
          })
        }, this.$page.id)
      }
    },
    loadQuill (callback) {
      if (typeof window.Quill === 'function') {
        if (typeof callback === 'function') {
          callback()
        }
        return
      }
      const script = document.createElement('script')
      script.src = window.plus ? './__uniappquill.js' : 'https://unpkg.com/quill@1.3.7/dist/quill.min.js'
      document.body.appendChild(script)
      script.onload = callback
    },
    loadImageResizeModule (callback) {
      if (typeof window.ImageResize === 'function') {
        if (typeof callback === 'function') {
          callback()
        }
        return
      }
      const script = document.createElement('script')
      script.src = window.plus ? './__uniappquillimageresize.js' : 'https://unpkg.com/quill-image-resize-mp@3.0.1/image-resize.min.js'
      document.body.appendChild(script)
      script.onload = callback
    },
    initQuill (imageResizeModules) {
      const Quill = window.Quill
      formats.register(Quill)
      const options = {
        toolbar: false,
        readOnly: this.readOnly,
        placeholder: this.placeholder,
        modules: {}
      }
      if (imageResizeModules.length) {
        Quill.register('modules/ImageResize', window.ImageResize.default)
        options.modules.ImageResize = {
          modules: imageResizeModules
        }
      }
      const quill = this.quill = new Quill(this.$el, options)
      const $el = quill.root
      const events = ['focus', 'blur', 'input']
      events.forEach(name => {
        $el.addEventListener(name, ($event) => {
          if (name === 'input') {
            $event.stopPropagation()
          } else {
            this.$trigger(name, $event, this.getContents())
          }
        })
      })
      quill.on(Quill.events.TEXT_CHANGE, () => {
        this.$trigger('input', {}, this.getContents())
      })
      quill.on(Quill.events.SELECTION_CHANGE, this.updateStatus.bind(this))
      quill.on(Quill.events.SCROLL_OPTIMIZE, () => {
        const range = quill.selection.getRange()[0]
        this.updateStatus(range)
      })
      quill.clipboard.addMatcher(Node.ELEMENT_NODE, (node, delta) => {
        if (this.skipMatcher) {
          return delta
        }
        delta.ops = delta.ops.filter(({ insert }) => typeof insert === 'string').map(({ insert }) => ({ insert }))
        return delta
      })
      this.initKeyboard($el)
      this.quillReady = true
      this.$trigger('ready', event, {})
    },
    getContents () {
      const quill = this.quill
      const html = quill.root.innerHTML
      const text = quill.getText()
      const delta = quill.getContents()
      return {
        html,
        text,
        delta
      }
    },
    html2delta (html) {
      const tags = ['span', 'strong', 'b', 'ins', 'em', 'i', 'u', 'a', 'del', 's', 'sub', 'sup', 'img', 'div', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'ol', 'ul', 'li', 'br']
      let content = ''
      let disable
      HTMLParser(html, {
        start: function (tag, attrs, unary) {
          if (!tags.includes(tag)) {
            disable = !unary
            return
          }
          disable = false
          const arrts = attrs.map(({ name, value }) => `${name}="${value}"`).join(' ')
          const start = `<${tag} ${arrts} ${unary ? '/' : ''}>`
          content += start
        },
        end: function (tag) {
          if (!disable) {
            content += `</${tag}>`
          }
        },
        chars: function (text) {
          if (!disable) {
            content += text
          }
        }
      })
      this.skipMatcher = true
      const delta = this.quill.clipboard.convert(content)
      this.skipMatcher = false
      return delta
    },
    updateStatus (range) {
      const status = range ? this.quill.getFormat(range) : {}
      const keys = Object.keys(status)
      if (keys.length !== Object.keys(this.__status || {}).length || keys.find(key => status[key] !== this.__status[key])) {
        this.__status = status
        this.$trigger('statuschange', {}, status)
      }
    }
  }
}
</script>

<style src="./editor.css"></style>
<style>
</style>

<template>
  <uni-editor
    :id="id"
    class="ql-container"
    v-on="$listeners"
  />
</template>

<script>
import {
  subscriber,
  emitter,
  keyboard
} from 'uni-mixins'
import HTMLParser from 'uni-helpers/html-parser'
import * as formats from './formats'

export default {
  name: 'Editor',
  mixins: [subscriber, emitter, keyboard],
  props: {
    id: {
      type: String,
      default: ''
    },
    readOnly: {
      type: [Boolean, String],
      default: false
    },
    placeholder: {
      type: String,
      default: ''
    },
    showImgSize: {
      type: [Boolean, String],
      default: false
    },
    showImgToolbar: {
      type: [Boolean, String],
      default: false
    },
    showImgResize: {
      type: [Boolean, String],
      default: false
    }
  },
  data () {
    return {
      quillReady: false
    }
  },
  computed: {
  },
  watch: {
    readOnly (value) {
      if (this.quillReady) {
        const quill = this.quill
        quill.enable(!value)
        if (!value) {
          quill.blur()
        }
      }
    },
    placeholder (value) {
      if (this.quillReady) {
        this.quill.root.setAttribute('data-placeholder', value)
      }
    }
  },
  mounted () {
    const imageResizeModules = []
    if (this.showImgSize) {
      imageResizeModules.push('DisplaySize')
    }
    if (this.showImgToolbar) {
      imageResizeModules.push('Toolbar')
    }
    if (this.showImgResize) {
      imageResizeModules.push('Resize')
    }
    this.loadQuill(() => {
      if (imageResizeModules.length) {
        this.loadImageResizeModule(() => {
          this.initQuill(imageResizeModules)
        })
      } else {
        this.initQuill(imageResizeModules)
      }
    })
  },
  methods: {
    _handleSubscribe ({
      type,
      data
    }) {
      const { options, callbackId } = data
      const quill = this.quill
      const Quill = window.Quill
      let res
      let range
      let errMsg
      if (this.quillReady) {
        switch (type) {
          case 'format':
            {
              let { name = '', value = false } = options
              range = quill.getSelection(true)
              let format = quill.getFormat(range)[name] || false
              if (['bold', 'italic', 'underline', 'strike', 'ins'].includes(name)) {
                value = !format
              } else if (name === 'direction') {
                value = value === 'rtl' && format ? false : value
                const align = quill.getFormat(range).align
                if (value === 'rtl' && !align) {
                  quill.format('align', 'right', Quill.sources.USER)
                } else if (!value && align === 'right') {
                  quill.format('align', false, Quill.sources.USER)
                }
              } else if (name === 'indent') {
                const rtl = quill.getFormat(range).direction === 'rtl'
                value = value === '+1'
                if (rtl) {
                  value = !value
                }
                value = value ? '+1' : '-1'
              } else {
                if (name === 'list') {
                  value = value === 'check' ? 'unchecked' : value
                  format = format === 'checked' ? 'unchecked' : format
                }
                value = ((format && format !== (value || false)) || (!format && value)) ? value : !format
              }
              quill.format(name, value, Quill.sources.USER)
            }
            break
          case 'insertDivider':
            range = quill.getSelection(true)
            quill.insertText(range.index, '\n', Quill.sources.USER)
            quill.insertEmbed(range.index + 1, 'divider', true, Quill.sources.USER)
            quill.setSelection(range.index + 2, Quill.sources.SILENT)
            break
          case 'insertImage':
            {
              range = quill.getSelection(true)
              const { src = '', alt = '', width = '', height = '', extClass = '', data = {} } = options
              const path = this.$getRealPath(src)
              quill.insertEmbed(range.index, 'image', path, Quill.sources.USER)
              const local = /^(file|blob):/.test(path) ? path : false
              quill.formatText(range.index, 1, 'data-local', local)
              quill.formatText(range.index, 1, 'alt', alt)
              quill.formatText(range.index, 1, 'width', width)
              quill.formatText(range.index, 1, 'height', height)
              quill.formatText(range.index, 1, 'class', extClass)
              quill.formatText(range.index, 1, 'data-custom', Object.keys(data).map(key => `${key}=${data[key]}`).join('&'))
              quill.setSelection(range.index + 1, Quill.sources.SILENT)
            }
            break
          case 'insertText':
            {
              range = quill.getSelection(true)
              const { text = '' } = options
              quill.insertText(range.index, text, Quill.sources.USER)
              quill.setSelection(range.index + text.length, 0, Quill.sources.SILENT)
            }
            break
          case 'setContents':
            {
              const { delta, html } = options
              if (typeof delta === 'object') {
                quill.setContents(delta, Quill.sources.SILENT)
              } else if (typeof html === 'string') {
                quill.setContents(this.html2delta(html), Quill.sources.SILENT)
              } else {
                errMsg = 'contents is missing'
              }
            }
            break
          case 'getContents':
            res = this.getContents()
            break
          case 'clear':
            quill.setContents([])
            break
          case 'removeFormat':
            {
              range = quill.getSelection(true)
              const parchment = Quill.import('parchment')
              if (range.length) {
                quill.removeFormat(range, Quill.sources.USER)
              } else {
                Object.keys(quill.getFormat(range)).forEach(key => {
                  if (parchment.query(key, parchment.Scope.INLINE)) {
                    quill.format(key, false)
                  }
                })
              }
            }
            break
          case 'undo':
            quill.history.undo()
            break
          case 'redo':
            quill.history.redo()
            break
          default:
            break
        }
        this.updateStatus(range)
      } else {
        errMsg = 'not ready'
      }
      if (callbackId) {
        UniViewJSBridge.publishHandler('onEditorMethodCallback', {
          callbackId,
          data: Object.assign({}, res, {
            errMsg: `${type}:${errMsg ? 'fail ' + errMsg : 'ok'}`
          })
        }, this.$page.id)
      }
    },
    loadQuill (callback) {
      if (typeof window.Quill === 'function') {
        if (typeof callback === 'function') {
          callback()
        }
        return
      }
      const script = document.createElement('script')
      script.src = window.plus ? './__uniappquill.js' : 'https://unpkg.com/quill@1.3.7/dist/quill.min.js'
      document.body.appendChild(script)
      script.onload = callback
    },
    loadImageResizeModule (callback) {
      if (typeof window.ImageResize === 'function') {
        if (typeof callback === 'function') {
          callback()
        }
        return
      }
      const script = document.createElement('script')
      script.src = window.plus ? './__uniappquillimageresize.js' : 'https://unpkg.com/quill-image-resize-mp@3.0.1/image-resize.min.js'
      document.body.appendChild(script)
      script.onload = callback
    },
    initQuill (imageResizeModules) {
      const Quill = window.Quill
      formats.register(Quill)
      const options = {
        toolbar: false,
        readOnly: this.readOnly,
        placeholder: this.placeholder,
        modules: {}
      }
      if (imageResizeModules.length) {
        Quill.register('modules/ImageResize', window.ImageResize.default)
        options.modules.ImageResize = {
          modules: imageResizeModules
        }
      }
      const quill = this.quill = new Quill(this.$el, options)
      const $el = quill.root
      const events = ['focus', 'blur', 'input']
      events.forEach(name => {
        $el.addEventListener(name, ($event) => {
          if (name === 'input') {
            $event.stopPropagation()
          } else {
            this.$trigger(name, $event, this.getContents())
          }
        })
      })
      quill.on(Quill.events.TEXT_CHANGE, () => {
        this.$trigger('input', {}, this.getContents())
      })
      quill.on(Quill.events.SELECTION_CHANGE, this.updateStatus.bind(this))
      quill.on(Quill.events.SCROLL_OPTIMIZE, () => {
        const range = quill.selection.getRange()[0]
        this.updateStatus(range)
      })
      quill.clipboard.addMatcher(Node.ELEMENT_NODE, (node, delta) => {
        if (this.skipMatcher) {
          return delta
        }
        return {
          ops: delta.ops.filter(({ insert }) => typeof insert === 'string').map(({ insert }) => ({ insert }))
        }
      })
      this.initKeyboard($el)
      this.quillReady = true
      this.$trigger('ready', event, {})
    },
    getContents () {
      const quill = this.quill
      const html = quill.root.innerHTML
      const text = quill.getText()
      const delta = quill.getContents()
      return {
        html,
        text,
        delta
      }
    },
    html2delta (html) {
      const tags = ['span', 'strong', 'b', 'ins', 'em', 'i', 'u', 'a', 'del', 's', 'sub', 'sup', 'img', 'div', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'ol', 'ul', 'li', 'br']
      let content = ''
      let disable
      HTMLParser(html, {
        start: function (tag, attrs, unary) {
          if (!tags.includes(tag)) {
            disable = !unary
            return
          }
          disable = false
          const arrts = attrs.map(({ name, value }) => `${name}="${value}"`).join(' ')
          const start = `<${tag} ${arrts} ${unary ? '/' : ''}>`
          content += start
        },
        end: function (tag) {
          if (!disable) {
            content += `</${tag}>`
          }
        },
        chars: function (text) {
          if (!disable) {
            content += text
          }
        }
      })
      this.skipMatcher = true
      const delta = this.quill.clipboard.convert(content)
      this.skipMatcher = false
      return delta
    },
    updateStatus (range) {
      const status = range ? this.quill.getFormat(range) : {}
      const keys = Object.keys(status)
      if (keys.length !== Object.keys(this.__status || {}).length || keys.find(key => status[key] !== this.__status[key])) {
        this.__status = status
        this.$trigger('statuschange', {}, status)
      }
    }
  }
}
</script>

<style src="./editor.css"></style>
<style>
</style>


feat: swiper 组件支持 disable-touch（App、H5）

feat: swiper 组件支持 disable-touch（App、H5）

fix: swiper disableTouch update

<script>
import touchtrack from 'uni-mixins/touchtrack'

export default {
  name: 'Swiper',
  mixins: [touchtrack],
  props: {
    indicatorDots: {
      type: [Boolean, String],
      default: false
    },
    vertical: {
      type: [Boolean, String],
      default: false
    },
    autoplay: {
      type: [Boolean, String],
      default: false
    },
    circular: {
      type: [Boolean, String],
      default: false
    },
    interval: {
      type: [Number, String],
      default: 5e3
    },
    duration: {
      type: [Number, String],
      default: 500
    },
    current: {
      type: [Number, String],
      default: 0
    },
    indicatorColor: {
      type: String,
      default: ''
    },
    indicatorActiveColor: {
      type: String,
      default: ''
    },
    previousMargin: {
      type: String,
      default: ''
    },
    nextMargin: {
      type: String,
      default: ''
    },
    currentItemId: {
      type: String,
      default: ''
    },
    skipHiddenItemLayout: {
      type: [Boolean, String],
      default: false
    },
    displayMultipleItems: {
      type: [Number, String],
      default: 1
    },
    disableTouch: {
      type: [Boolean, String],
      default: false
    }
  },
  data () {
    return {
      currentSync: Math.round(this.current) || 0,
      currentItemIdSync: this.currentItemId || '',
      userTracking: false,
      currentChangeSource: '',
      items: []
    }
  },
  computed: {
    intervalNumber () {
      const interval = Number(this.interval)
      return isNaN(interval) ? 5e3 : interval
    },
    durationNumber () {
      const duration = Number(this.duration)
      return isNaN(duration) ? 500 : duration
    },
    displayMultipleItemsNumber () {
      const displayMultipleItems = Math.round(this.displayMultipleItems)
      return isNaN(displayMultipleItems) ? 1 : displayMultipleItems
    },
    slidesStyle () {
      var style = {}
      if (this.nextMargin || this.previousMargin) {
        style = this.vertical ? {
          left: 0,
          right: 0,
          top: this._upx2px(this.previousMargin),
          bottom: this._upx2px(this.nextMargin)
        } : {
          top: 0,
          bottom: 0,
          left: this._upx2px(this.previousMargin),
          right: this._upx2px(this.nextMargin)
        }
      }
      return style
    },
    slideFrameStyle () {
      var value = Math.abs(100 / this.displayMultipleItemsNumber) + '%'
      return {
        width: this.vertical ? '100%' : value,
        height: !this.vertical ? '100%' : value
      }
    },
    circularEnabled () {
      return this.circular && this.items.length > this.displayMultipleItemsNumber
    }
  },
  watch: {
    vertical () {
      this._resetLayout()
    },
    circular () {
      this._resetLayout()
    },
    intervalNumber (val) {
      if (this._timer) {
        this._cancelSchedule()
        this._scheduleAutoplay()
      }
    },
    current (val) {
      this._currentCheck()
    },
    currentSync (val) {
      this._currentChanged(val)
      this.$emit('update:current', val)
    },
    currentItemId (val) {
      this._currentCheck()
    },
    currentItemIdSync (val) {
      this.$emit('update:currentItemId', val)
    },
    displayMultipleItemsNumber () {
      this._resetLayout()
    }
  },
  created () {
    this._invalid = true
    this._viewportPosition = 0
    this._viewportMoveRatio = 1
    this._animating = null
    this._requestedAnimation = false
    this._userDirectionChecked = false
    this._contentTrackViewport = 0
    this._contentTrackSpeed = 0
    this._contentTrackT = 0
  },
  mounted () {
    this._currentCheck()
    this.touchtrack(this.$refs.slidesWrapper, '_handleContentTrack', true)
    this._resetLayout()
    this.$watch(() => {
      return this.autoplay && !this.userTracking
    }, this._inintAutoplay)
    this._inintAutoplay(this.autoplay && !this.userTracking)
    this.$watch('items.length', this._resetLayout)
  },
  beforeDestroy () {
    this._cancelSchedule()
    cancelAnimationFrame(this._animationFrame)
  },
  methods: {
    _inintAutoplay (enable) {
      if (enable) {
        this._scheduleAutoplay()
      } else {
        this._cancelSchedule()
      }
    },
    /**
     * 页面变更检查和同步
     */
    _currentCheck () {
      var current = -1
      if (this.currentItemId) {
        for (let i = 0, items = this.items; i < items.length; i++) {
          let componentInstance = items[i].componentInstance
          if (componentInstance && componentInstance.itemId === this.currentItemId) {
            current = i
            break
          }
        }
      }
      if (current < 0) {
        current = Math.round(this.current) || 0
      }
      current = current < 0 ? 0 : current
      if (this.currentSync !== current) {
        this.currentChangeSource = ''
        this.currentSync = current
      }
    },
    _itemReady (vnode, callback) {
      if (vnode.componentInstance && vnode.componentInstance._isMounted) {
        callback()
      } else {
        vnode._callbacks = vnode._callbacks || []
        vnode._callbacks.push(callback)
      }
    },
    /**
     * 当前页面变更
     */
    _currentChanged (current) {
      var source = this.currentChangeSource
      this.currentChangeSource = ''
      if (!source) {
        this._animateViewport(current, '', 0)
      }
      var item = this.items[current]
      if (item) {
        this._itemReady(item, () => {
          var currentItemId = this.currentItemIdSync = item.componentInstance.itemId || ''
          this.$trigger('change', {}, {
            current: this.currentSync,
            currentItemId,
            source
          })
        })
      }
    },
    /**
     * 自动播放
     */
    _scheduleAutoplay () {
      var self = this
      this._cancelSchedule()
      function timer () {
        self._timer = null
        self.currentChangeSource = 'autoplay'
        if (self.circularEnabled) {
          self.currentSync = self._normalizeCurrentValue(self.currentSync + 1)
        } else {
          self.currentSync = self.currentSync + self.displayMultipleItemsNumber < self.items.length ? self.currentSync + 1 : 0
        }
        self._animateViewport(self.currentSync, 'autoplay', self.circularEnabled ? 1 : 0)
        self._timer = setTimeout(timer, self.intervalNumber)
      }
      if (!(!this._isMounted || this._invalid || this.items.length <= this.displayMultipleItemsNumber)) {
        this._timer = setTimeout(timer, this.intervalNumber)
      }
    },
    /**
     * 清除定时器
     */
    _cancelSchedule () {
      if (this._timer) {
        clearTimeout(this._timer)
        this._timer = null
      }
    },
    /**
     * 检查当前页值
     */
    _normalizeCurrentValue (current) {
      var length = this.items.length
      if (!length) {
        return -1
      }
      var index = (Math.round(current) % length + length) % length
      if (this.circularEnabled) {
        if (length <= this.displayMultipleItemsNumber) {
          return 0
        }
      } else if (index > length - this.displayMultipleItemsNumber) {
        return length - this.displayMultipleItemsNumber
      }
      return index
    },
    _upx2px (val) {
      if (/\d+[ur]px$/i.test(val)) {
        val.replace(/\d+[ur]px$/i, text => {
          return `${uni.upx2px(parseFloat(text))}px`
        })
      }
      return val || ''
    },
    /**
     * 重新布局
     */
    _resetLayout () {
      if (this._isMounted) {
        this._cancelSchedule()
        this._endViewportAnimation()
        var items = this.items

        for (var i = 0; i < items.length; i++) {
          this._updateItemPos(i, i)
        }
        this._viewportMoveRatio = 1
        if (this.displayMultipleItemsNumber === 1 && items.length) {
          var itemRect = items[0].componentInstance.$el.getBoundingClientRect()
          var slideFrameRect = this.$refs.slideFrame.getBoundingClientRect()
          this._viewportMoveRatio = itemRect.width / slideFrameRect.width
          if (!(this._viewportMoveRatio > 0 && this._viewportMoveRatio < 1)) {
            this._viewportMoveRatio = 1
          }
        }
        var position = this._viewportPosition
        this._viewportPosition = -2
        var current = this.currentSync
        if (current >= 0) {
          this._invalid = false
          if (this.userTracking) {
            this._updateViewport(position + current - this._contentTrackViewport)
            this._contentTrackViewport = current
          } else {
            this._updateViewport(current)
            if (this.autoplay) {
              this._scheduleAutoplay()
            }
          }
        } else {
          this._invalid = true
          this._updateViewport(-this.displayMultipleItemsNumber - 1)
        }
      }
    },
    _checkCircularLayout (e) {
      if (!this._invalid) {
        for (var items = this.items, n = items.length, i = e + this.displayMultipleItemsNumber, r = 0; r < n; r++) {
          var item = items[r]
          var _position = item._position
          var s = Math.floor(e / n) * n + r
          var l = s + n
          var c = s - n
          var u = Math.max(e - (s + 1), s - i, 0)
          var d = Math.max(e - (l + 1), l - i, 0)
          var h = Math.max(e - (c + 1), c - i, 0)
          var p = Math.min(u, d, h)
          var f = [s, l, c][[u, d, h].indexOf(p)]
          if (_position !== f) {
            this._updateItemPos(r, f)
          }
        }
      }
    },
    _updateItemPos (current, position) {
      var x = this.vertical ? '0' : 100 * position + '%'
      var y = this.vertical ? 100 * position + '%' : '0'
      var transform = 'translate(' + x + ', ' + y + ') translateZ(0)'
      var item = this.items[current]
      this._itemReady(item, () => {
        var el = item.componentInstance.$el
        el.style['-webkit-transform'] = transform
        el.style.transform = transform
        el._position = position
      })
    },
    _updateViewport (index) {
      if (!(Math.floor(2 * this._viewportPosition) === Math.floor(2 * index) && Math.ceil(2 * this._viewportPosition) === Math.ceil(2 * index))) {
        if (this.circularEnabled) {
          this._checkCircularLayout(index)
        }
      }

      var x = this.vertical ? '0' : 100 * -index * this._viewportMoveRatio + '%'
      var y = this.vertical ? 100 * -index * this._viewportMoveRatio + '%' : '0'
      var transform = 'translate(' + x + ', ' + y + ') translateZ(0)'
      var slideFrame = this.$refs.slideFrame
      if (slideFrame) {
        slideFrame.style['-webkit-transform'] = transform
        slideFrame.style.transform = transform
      }
      this._viewportPosition = index
      if (!this._transitionStart) {
        if (index % 1 === 0) {
          return
        }
        this._transitionStart = index
      }
      index -= Math.floor(this._transitionStart)
      if (index <= -(this.items.length - 1)) {
        index += this.items.length
      } else if (index >= this.items.length) {
        index -= this.items.length
      }
      index = this._transitionStart % 1 > 0.5 || this._transitionStart < 0 ? index - 1 : index
      this.$trigger('transition', {}, {
        dx: this.vertical ? 0 : index * slideFrame.offsetWidth,
        dy: this.vertical ? index * slideFrame.offsetHeight : 0
      })
    },
    _animateFrameFuncProto () {
      if (!this._animating) {
        this._requestedAnimation = false
        return
      }
      var _animating = this._animating
      var toPos = _animating.toPos
      var acc = _animating.acc
      var endTime = _animating.endTime
      var source = _animating.source
      var time = endTime - Date.now()
      if (time <= 0) {
        this._updateViewport(toPos)
        this._animating = null
        this._requestedAnimation = false
        this._transitionStart = null
        var item = this.items[this.currentSync]
        if (item) {
          this._itemReady(item, () => {
            var currentItemId = item.componentInstance.itemId || ''
            this.$trigger('animationfinish', {}, {
              current: this.currentSync,
              currentItemId,
              source
            })
          })
        }
        return
      }
      var s = acc * time * time / 2
      var l = toPos + s
      this._updateViewport(l)
      this._animationFrame = requestAnimationFrame(this._animateFrameFuncProto.bind(this))
    },
    _animateViewport (current, source, n) {
      this._cancelViewportAnimation()
      var duration = this.durationNumber
      var length = this.items.length
      var position = this._viewportPosition
      if (this.circularEnabled) {
        if (n < 0) {
          for (; position < current;) {
            position += length
          }
          for (; position - length > current;) {
            position -= length
          }
        } else if (n > 0) {
          for (; position > current;) {
            position -= length
          }
          for (; position + length < current;) {
            position += length
          }
        } else {
          for (; position + length < current;) {
            position += length
          }
          for (; position - length > current;) {
            position -= length
          }
          if (position + length - current < current - position) {
            position += length
          }
        }
      }

      this._animating = {
        toPos: current,
        acc: 2 * (position - current) / (duration * duration),
        endTime: Date.now() + duration,
        source: source
      }
      if (!this._requestedAnimation) {
        this._requestedAnimation = true
        this._animationFrame = requestAnimationFrame(this._animateFrameFuncProto.bind(this))
      }
    },
    _cancelViewportAnimation () {
      this._animating = null
    },
    /**
     * 结束动画
     */
    _endViewportAnimation () {
      if (this._animating) {
        this._updateViewport(this._animating.toPos)
        this._animating = null
      }
    },
    _handleTrackStart () {
      this._cancelSchedule()
      this._contentTrackViewport = this._viewportPosition
      this._contentTrackSpeed = 0
      this._contentTrackT = Date.now()
      this._cancelViewportAnimation()
    },
    _handleTrackMove (data) {
      var self = this
      var contentTrackT = this._contentTrackT
      this._contentTrackT = Date.now()
      var length = this.items.length
      var other = length - this.displayMultipleItemsNumber
      function calc (val) {
        return 0.5 - 0.25 / (val + 0.5)
      }

      function move (oldVal, newVal) {
        var val = self._contentTrackViewport + oldVal
        self._contentTrackSpeed = 0.6 * self._contentTrackSpeed + 0.4 * newVal
        if (!self.circularEnabled) {
          if (val < 0 || val > other) {
            if (val < 0) {
              val = -calc(-val)
            } else {
              if (val > other) {
                val = other + calc(val - other)
              }
            }
            self._contentTrackSpeed = 0
          }
        }
        self._updateViewport(val)
      }
      var time = (this._contentTrackT - contentTrackT) || 1
      if (this.vertical) {
        move(-data.dy / this.$refs.slideFrame.offsetHeight, -data.ddy / time)
      } else {
        move(-data.dx / this.$refs.slideFrame.offsetWidth, -data.ddx / time)
      }
    },
    _handleTrackEnd (isCancel) {
      this.userTracking = false
      var t = this._contentTrackSpeed / Math.abs(this._contentTrackSpeed)
      var n = 0
      if (!isCancel && Math.abs(this._contentTrackSpeed) > 0.2) {
        n = 0.5 * t
      }
      var current = this._normalizeCurrentValue(this._viewportPosition + n)
      if (isCancel) {
        this._updateViewport(this._contentTrackViewport)
      } else {
        this.currentChangeSource = 'touch'
        this.currentSync = current
        this._animateViewport(current, 'touch', n !== 0 ? n : current === 0 && (this.circularEnabled && this._viewportPosition >= 1) ? 1 : 0)
      }
    },
    _handleContentTrack (e) {
      if (this.disableTouch) {
        return
      }
      if (!this._invalid) {
        if (e.detail.state === 'start') {
          this.userTracking = true
          this._userDirectionChecked = false
          return this._handleTrackStart()
        }
        // fixed by xxxxxx
        if (e.detail.state === 'end') {
          return this._handleTrackEnd(false)
        }
        if (e.detail.state === 'cancel') {
          return this._handleTrackEnd(true)
        }
        if (this.userTracking) {
          if (!this._userDirectionChecked) {
            this._userDirectionChecked = true
            var t = Math.abs(e.detail.dx)
            var n = Math.abs(e.detail.dy)
            if (t >= n && this.vertical) {
              this.userTracking = false
            } else {
              if (t <= n && !this.vertical) {
                this.userTracking = false
              }
            }
            if (!this.userTracking) {
              if (this.autoplay) {
                this._scheduleAutoplay()
              }
              return
            }
          }
          this._handleTrackMove(e.detail)
          return false
        }
      }
    }
  },
  render (createElement) {
    var slidesDots = []
    var swiperItems = []
    if (this.$slots.default) {
      this.$slots.default.forEach(vnode => {
        if (vnode.componentOptions && vnode.componentOptions.tag === 'v-uni-swiper-item') {
          swiperItems.push(vnode)
        }
      })
    }
    for (let index = 0, length = swiperItems.length; index < length; index++) {
      let currentSync = this.currentSync
      slidesDots.push(createElement('div', {
        class: {
          'uni-swiper-dot': true,
          'uni-swiper-dot-active': (index < currentSync + this.displayMultipleItemsNumber && index >= currentSync) || (index < currentSync + this.displayMultipleItemsNumber - length)
        },
        style: {
          'background': index === currentSync ? this.indicatorActiveColor : this.indicatorColor
        }
      }))
    }
    this.items = swiperItems
    var slidesWrapperChild = [createElement('div', {
      ref: 'slides',
      style: this.slidesStyle,
      'class': 'uni-swiper-slides'
    }, [
      createElement('div', {
        ref: 'slideFrame',
        class: 'uni-swiper-slide-frame',
        style: this.slideFrameStyle
      }, swiperItems)
    ])]
    if (this.indicatorDots) {
      slidesWrapperChild.push(createElement('div', {
        ref: 'slidesDots',
        'class': ['uni-swiper-dots', this.vertical ? 'uni-swiper-dots-vertical' : 'uni-swiper-dots-horizontal']
      }, slidesDots))
    }

    return createElement(
      'uni-swiper', {
        on: this.$listeners
      }, [createElement('div', {
        ref: 'slidesWrapper',
        'class': 'uni-swiper-wrapper'
      }, slidesWrapperChild)]
    )
  }
}
</script>
<style>
uni-swiper {
  display: block;
  height: 150px;
}

uni-swiper[hidden] {
  display: none;
}

uni-swiper .uni-swiper-wrapper {
  overflow: hidden;
  position: relative;
  width: 100%;
  height: 100%;
  -webkit-transform: translateZ(0);
  transform: translateZ(0);
}

uni-swiper .uni-swiper-slides {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  bottom: 0;
}

uni-swiper .uni-swiper-slide-frame {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  will-change: transform;
}

uni-swiper .uni-swiper-dots {
  position: absolute;
  font-size: 0;
}

uni-swiper .uni-swiper-dots-horizontal {
  left: 50%;
  bottom: 10px;
  text-align: center;
  white-space: nowrap;
  -webkit-transform: translate(-50%, 0);
  transform: translate(-50%, 0);
}

uni-swiper .uni-swiper-dots-horizontal .uni-swiper-dot {
  margin-right: 8px;
}

uni-swiper .uni-swiper-dots-horizontal .uni-swiper-dot:last-child {
  margin-right: 0;
}

uni-swiper .uni-swiper-dots-vertical {
  right: 10px;
  top: 50%;
  text-align: right;
  -webkit-transform: translate(0, -50%);
  transform: translate(0, -50%);
}

uni-swiper .uni-swiper-dots-vertical .uni-swiper-dot {
  display: block;
  margin-bottom: 9px;
}

uni-swiper .uni-swiper-dots-vertical .uni-swiper-dot:last-child {
  margin-bottom: 0;
}

uni-swiper .uni-swiper-dot {
  display: inline-block;
  width: 8px;
  height: 8px;
  cursor: pointer;
  transition-property: background-color;
  transition-timing-function: ease;
  background: rgba(0, 0, 0, 0.3);
  border-radius: 50%;
}

uni-swiper .uni-swiper-dot-active {
  background-color: #000000;
}
</style>

<script>
import touchtrack from 'uni-mixins/touchtrack'

export default {
  name: 'Swiper',
  mixins: [touchtrack],
  props: {
    indicatorDots: {
      type: [Boolean, String],
      default: false
    },
    vertical: {
      type: [Boolean, String],
      default: false
    },
    autoplay: {
      type: [Boolean, String],
      default: false
    },
    circular: {
      type: [Boolean, String],
      default: false
    },
    interval: {
      type: [Number, String],
      default: 5e3
    },
    duration: {
      type: [Number, String],
      default: 500
    },
    current: {
      type: [Number, String],
      default: 0
    },
    indicatorColor: {
      type: String,
      default: ''
    },
    indicatorActiveColor: {
      type: String,
      default: ''
    },
    previousMargin: {
      type: String,
      default: ''
    },
    nextMargin: {
      type: String,
      default: ''
    },
    currentItemId: {
      type: String,
      default: ''
    },
    skipHiddenItemLayout: {
      type: [Boolean, String],
      default: false
    },
    displayMultipleItems: {
      type: [Number, String],
      default: 1
    }
  },
  data () {
    return {
      currentSync: Math.round(this.current) || 0,
      currentItemIdSync: this.currentItemId || '',
      userTracking: false,
      currentChangeSource: '',
      items: []
    }
  },
  computed: {
    intervalNumber () {
      const interval = Number(this.interval)
      return isNaN(interval) ? 5e3 : interval
    },
    durationNumber () {
      const duration = Number(this.duration)
      return isNaN(duration) ? 500 : duration
    },
    displayMultipleItemsNumber () {
      const displayMultipleItems = Math.round(this.displayMultipleItems)
      return isNaN(displayMultipleItems) ? 1 : displayMultipleItems
    },
    slidesStyle () {
      var style = {}
      if (this.nextMargin || this.previousMargin) {
        style = this.vertical ? {
          left: 0,
          right: 0,
          top: this._upx2px(this.previousMargin),
          bottom: this._upx2px(this.nextMargin)
        } : {
          top: 0,
          bottom: 0,
          left: this._upx2px(this.previousMargin),
          right: this._upx2px(this.nextMargin)
        }
      }
      return style
    },
    slideFrameStyle () {
      var value = Math.abs(100 / this.displayMultipleItemsNumber) + '%'
      return {
        width: this.vertical ? '100%' : value,
        height: !this.vertical ? '100%' : value
      }
    },
    circularEnabled () {
      return this.circular && this.items.length > this.displayMultipleItemsNumber
    }
  },
  watch: {
    vertical () {
      this._resetLayout()
    },
    circular () {
      this._resetLayout()
    },
    intervalNumber (val) {
      if (this._timer) {
        this._cancelSchedule()
        this._scheduleAutoplay()
      }
    },
    current (val) {
      this._currentCheck()
    },
    currentSync (val) {
      this._currentChanged(val)
      this.$emit('update:current', val)
    },
    currentItemId (val) {
      this._currentCheck()
    },
    currentItemIdSync (val) {
      this.$emit('update:currentItemId', val)
    },
    displayMultipleItemsNumber () {
      this._resetLayout()
    }
  },
  created () {
    this._invalid = true
    this._viewportPosition = 0
    this._viewportMoveRatio = 1
    this._animating = null
    this._requestedAnimation = false
    this._userDirectionChecked = false
    this._contentTrackViewport = 0
    this._contentTrackSpeed = 0
    this._contentTrackT = 0
  },
  mounted () {
    this._currentCheck()
    this.touchtrack(this.$refs.slidesWrapper, '_handleContentTrack', true)
    this._resetLayout()
    this.$watch(() => {
      return this.autoplay && !this.userTracking
    }, this._inintAutoplay)
    this._inintAutoplay(this.autoplay && !this.userTracking)
    this.$watch('items.length', this._resetLayout)
  },
  beforeDestroy () {
    this._cancelSchedule()
    cancelAnimationFrame(this._animationFrame)
  },
  methods: {
    _inintAutoplay (enable) {
      if (enable) {
        this._scheduleAutoplay()
      } else {
        this._cancelSchedule()
      }
    },
    /**
     * 页面变更检查和同步
     */
    _currentCheck () {
      var current = -1
      if (this.currentItemId) {
        for (let i = 0, items = this.items; i < items.length; i++) {
          let componentInstance = items[i].componentInstance
          if (componentInstance && componentInstance.itemId === this.currentItemId) {
            current = i
            break
          }
        }
      }
      if (current < 0) {
        current = Math.round(this.current) || 0
      }
      current = current < 0 ? 0 : current
      if (this.currentSync !== current) {
        this.currentChangeSource = ''
        this.currentSync = current
      }
    },
    _itemReady (vnode, callback) {
      if (vnode.componentInstance && vnode.componentInstance._isMounted) {
        callback()
      } else {
        vnode._callbacks = vnode._callbacks || []
        vnode._callbacks.push(callback)
      }
    },
    /**
     * 当前页面变更
     */
    _currentChanged (current) {
      var source = this.currentChangeSource
      this.currentChangeSource = ''
      if (!source) {
        this._animateViewport(current, '', 0)
      }
      var item = this.items[current]
      if (item) {
        this._itemReady(item, () => {
          var currentItemId = this.currentItemIdSync = item.componentInstance.itemId || ''
          this.$trigger('change', {}, {
            current: this.currentSync,
            currentItemId,
            source
          })
        })
      }
    },
    /**
     * 自动播放
     */
    _scheduleAutoplay () {
      var self = this
      this._cancelSchedule()
      function timer () {
        self._timer = null
        self.currentChangeSource = 'autoplay'
        if (self.circularEnabled) {
          self.currentSync = self._normalizeCurrentValue(self.currentSync + 1)
        } else {
          self.currentSync = self.currentSync + self.displayMultipleItemsNumber < self.items.length ? self.currentSync + 1 : 0
        }
        self._animateViewport(self.currentSync, 'autoplay', self.circularEnabled ? 1 : 0)
        self._timer = setTimeout(timer, self.intervalNumber)
      }
      if (!(!this._isMounted || this._invalid || this.items.length <= this.displayMultipleItemsNumber)) {
        this._timer = setTimeout(timer, this.intervalNumber)
      }
    },
    /**
     * 清除定时器
     */
    _cancelSchedule () {
      if (this._timer) {
        clearTimeout(this._timer)
        this._timer = null
      }
    },
    /**
     * 检查当前页值
     */
    _normalizeCurrentValue (current) {
      var length = this.items.length
      if (!length) {
        return -1
      }
      var index = (Math.round(current) % length + length) % length
      if (this.circularEnabled) {
        if (length <= this.displayMultipleItemsNumber) {
          return 0
        }
      } else if (index > length - this.displayMultipleItemsNumber) {
        return length - this.displayMultipleItemsNumber
      }
      return index
    },
    _upx2px (val) {
      if (/\d+[ur]px$/i.test(val)) {
        val.replace(/\d+[ur]px$/i, text => {
          return `${uni.upx2px(parseFloat(text))}px`
        })
      }
      return val || ''
    },
    /**
     * 重新布局
     */
    _resetLayout () {
      if (this._isMounted) {
        this._cancelSchedule()
        this._endViewportAnimation()
        var items = this.items

        for (var i = 0; i < items.length; i++) {
          this._updateItemPos(i, i)
        }
        this._viewportMoveRatio = 1
        if (this.displayMultipleItemsNumber === 1 && items.length) {
          var itemRect = items[0].componentInstance.$el.getBoundingClientRect()
          var slideFrameRect = this.$refs.slideFrame.getBoundingClientRect()
          this._viewportMoveRatio = itemRect.width / slideFrameRect.width
          if (!(this._viewportMoveRatio > 0 && this._viewportMoveRatio < 1)) {
            this._viewportMoveRatio = 1
          }
        }
        var position = this._viewportPosition
        this._viewportPosition = -2
        var current = this.currentSync
        if (current >= 0) {
          this._invalid = false
          if (this.userTracking) {
            this._updateViewport(position + current - this._contentTrackViewport)
            this._contentTrackViewport = current
          } else {
            this._updateViewport(current)
            if (this.autoplay) {
              this._scheduleAutoplay()
            }
          }
        } else {
          this._invalid = true
          this._updateViewport(-this.displayMultipleItemsNumber - 1)
        }
      }
    },
    _checkCircularLayout (e) {
      if (!this._invalid) {
        for (var items = this.items, n = items.length, i = e + this.displayMultipleItemsNumber, r = 0; r < n; r++) {
          var item = items[r]
          var _position = item._position
          var s = Math.floor(e / n) * n + r
          var l = s + n
          var c = s - n
          var u = Math.max(e - (s + 1), s - i, 0)
          var d = Math.max(e - (l + 1), l - i, 0)
          var h = Math.max(e - (c + 1), c - i, 0)
          var p = Math.min(u, d, h)
          var f = [s, l, c][[u, d, h].indexOf(p)]
          if (_position !== f) {
            this._updateItemPos(r, f)
          }
        }
      }
    },
    _updateItemPos (current, position) {
      var x = this.vertical ? '0' : 100 * position + '%'
      var y = this.vertical ? 100 * position + '%' : '0'
      var transform = 'translate(' + x + ', ' + y + ') translateZ(0)'
      var item = this.items[current]
      this._itemReady(item, () => {
        var el = item.componentInstance.$el
        el.style['-webkit-transform'] = transform
        el.style.transform = transform
        el._position = position
      })
    },
    _updateViewport (index) {
      if (!(Math.floor(2 * this._viewportPosition) === Math.floor(2 * index) && Math.ceil(2 * this._viewportPosition) === Math.ceil(2 * index))) {
        if (this.circularEnabled) {
          this._checkCircularLayout(index)
        }
      }

      var x = this.vertical ? '0' : 100 * -index * this._viewportMoveRatio + '%'
      var y = this.vertical ? 100 * -index * this._viewportMoveRatio + '%' : '0'
      var transform = 'translate(' + x + ', ' + y + ') translateZ(0)'
      var slideFrame = this.$refs.slideFrame
      if (slideFrame) {
        slideFrame.style['-webkit-transform'] = transform
        slideFrame.style.transform = transform
      }
      this._viewportPosition = index
      if (!this._transitionStart) {
        if (index % 1 === 0) {
          return
        }
        this._transitionStart = index
      }
      index -= Math.floor(this._transitionStart)
      if (index <= -(this.items.length - 1)) {
        index += this.items.length
      } else if (index >= this.items.length) {
        index -= this.items.length
      }
      index = this._transitionStart % 1 > 0.5 || this._transitionStart < 0 ? index - 1 : index
      this.$trigger('transition', {}, {
        dx: this.vertical ? 0 : index * slideFrame.offsetWidth,
        dy: this.vertical ? index * slideFrame.offsetHeight : 0
      })
    },
    _animateFrameFuncProto () {
      if (!this._animating) {
        this._requestedAnimation = false
        return
      }
      var _animating = this._animating
      var toPos = _animating.toPos
      var acc = _animating.acc
      var endTime = _animating.endTime
      var source = _animating.source
      var time = endTime - Date.now()
      if (time <= 0) {
        this._updateViewport(toPos)
        this._animating = null
        this._requestedAnimation = false
        this._transitionStart = null
        var item = this.items[this.currentSync]
        if (item) {
          this._itemReady(item, () => {
            var currentItemId = item.componentInstance.itemId || ''
            this.$trigger('animationfinish', {}, {
              current: this.currentSync,
              currentItemId,
              source
            })
          })
        }
        return
      }
      var s = acc * time * time / 2
      var l = toPos + s
      this._updateViewport(l)
      this._animationFrame = requestAnimationFrame(this._animateFrameFuncProto.bind(this))
    },
    _animateViewport (current, source, n) {
      this._cancelViewportAnimation()
      var duration = this.durationNumber
      var length = this.items.length
      var position = this._viewportPosition
      if (this.circularEnabled) {
        if (n < 0) {
          for (; position < current;) {
            position += length
          }
          for (; position - length > current;) {
            position -= length
          }
        } else if (n > 0) {
          for (; position > current;) {
            position -= length
          }
          for (; position + length < current;) {
            position += length
          }
        } else {
          for (; position + length < current;) {
            position += length
          }
          for (; position - length > current;) {
            position -= length
          }
          if (position + length - current < current - position) {
            position += length
          }
        }
      }

      this._animating = {
        toPos: current,
        acc: 2 * (position - current) / (duration * duration),
        endTime: Date.now() + duration,
        source: source
      }
      if (!this._requestedAnimation) {
        this._requestedAnimation = true
        this._animationFrame = requestAnimationFrame(this._animateFrameFuncProto.bind(this))
      }
    },
    _cancelViewportAnimation () {
      this._animating = null
    },
    /**
     * 结束动画
     */
    _endViewportAnimation () {
      if (this._animating) {
        this._updateViewport(this._animating.toPos)
        this._animating = null
      }
    },
    _handleTrackStart () {
      this._cancelSchedule()
      this._contentTrackViewport = this._viewportPosition
      this._contentTrackSpeed = 0
      this._contentTrackT = Date.now()
      this._cancelViewportAnimation()
    },
    _handleTrackMove (data) {
      var self = this
      var contentTrackT = this._contentTrackT
      this._contentTrackT = Date.now()
      var length = this.items.length
      var other = length - this.displayMultipleItemsNumber
      function calc (val) {
        return 0.5 - 0.25 / (val + 0.5)
      }

      function move (oldVal, newVal) {
        var val = self._contentTrackViewport + oldVal
        self._contentTrackSpeed = 0.6 * self._contentTrackSpeed + 0.4 * newVal
        if (!self.circularEnabled) {
          if (val < 0 || val > other) {
            if (val < 0) {
              val = -calc(-val)
            } else {
              if (val > other) {
                val = other + calc(val - other)
              }
            }
            self._contentTrackSpeed = 0
          }
        }
        self._updateViewport(val)
      }
      var time = (this._contentTrackT - contentTrackT) || 1
      if (this.vertical) {
        move(-data.dy / this.$refs.slideFrame.offsetHeight, -data.ddy / time)
      } else {
        move(-data.dx / this.$refs.slideFrame.offsetWidth, -data.ddx / time)
      }
    },
    _handleTrackEnd (isCancel) {
      this.userTracking = false
      var t = this._contentTrackSpeed / Math.abs(this._contentTrackSpeed)
      var n = 0
      if (!isCancel && Math.abs(this._contentTrackSpeed) > 0.2) {
        n = 0.5 * t
      }
      var current = this._normalizeCurrentValue(this._viewportPosition + n)
      if (isCancel) {
        this._updateViewport(this._contentTrackViewport)
      } else {
        this.currentChangeSource = 'touch'
        this.currentSync = current
        this._animateViewport(current, 'touch', n !== 0 ? n : current === 0 && (this.circularEnabled && this._viewportPosition >= 1) ? 1 : 0)
      }
    },
    _handleContentTrack (e) {
      if (!this._invalid) {
        if (e.detail.state === 'start') {
          this.userTracking = true
          this._userDirectionChecked = false
          return this._handleTrackStart()
        }
        // fixed by xxxxxx
        if (e.detail.state === 'end') {
          return this._handleTrackEnd(false)
        }
        if (e.detail.state === 'cancel') {
          return this._handleTrackEnd(true)
        }
        if (this.userTracking) {
          if (!this._userDirectionChecked) {
            this._userDirectionChecked = true
            var t = Math.abs(e.detail.dx)
            var n = Math.abs(e.detail.dy)
            if (t >= n && this.vertical) {
              this.userTracking = false
            } else {
              if (t <= n && !this.vertical) {
                this.userTracking = false
              }
            }
            if (!this.userTracking) {
              if (this.autoplay) {
                this._scheduleAutoplay()
              }
              return
            }
          }
          this._handleTrackMove(e.detail)
          return false
        }
      }
    }
  },
  render (createElement) {
    var slidesDots = []
    var swiperItems = []
    if (this.$slots.default) {
      this.$slots.default.forEach(vnode => {
        if (vnode.componentOptions && vnode.componentOptions.tag === 'v-uni-swiper-item') {
          swiperItems.push(vnode)
        }
      })
    }
    for (let index = 0, length = swiperItems.length; index < length; index++) {
      let currentSync = this.currentSync
      slidesDots.push(createElement('div', {
        class: {
          'uni-swiper-dot': true,
          'uni-swiper-dot-active': (index < currentSync + this.displayMultipleItemsNumber && index >= currentSync) || (index < currentSync + this.displayMultipleItemsNumber - length)
        },
        style: {
          'background': index === currentSync ? this.indicatorActiveColor : this.indicatorColor
        }
      }))
    }
    this.items = swiperItems
    var slidesWrapperChild = [createElement('div', {
      ref: 'slides',
      style: this.slidesStyle,
      'class': 'uni-swiper-slides'
    }, [
      createElement('div', {
        ref: 'slideFrame',
        class: 'uni-swiper-slide-frame',
        style: this.slideFrameStyle
      }, swiperItems)
    ])]
    if (this.indicatorDots) {
      slidesWrapperChild.push(createElement('div', {
        ref: 'slidesDots',
        'class': ['uni-swiper-dots', this.vertical ? 'uni-swiper-dots-vertical' : 'uni-swiper-dots-horizontal']
      }, slidesDots))
    }

    return createElement(
      'uni-swiper', {
        on: this.$listeners
      }, [createElement('div', {
        ref: 'slidesWrapper',
        'class': 'uni-swiper-wrapper'
      }, slidesWrapperChild)]
    )
  }
}
</script>
<style>
uni-swiper {
  display: block;
  height: 150px;
}

uni-swiper[hidden] {
  display: none;
}

uni-swiper .uni-swiper-wrapper {
  overflow: hidden;
  position: relative;
  width: 100%;
  height: 100%;
  -webkit-transform: translateZ(0);
  transform: translateZ(0);
}

uni-swiper .uni-swiper-slides {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  bottom: 0;
}

uni-swiper .uni-swiper-slide-frame {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  will-change: transform;
}

uni-swiper .uni-swiper-dots {
  position: absolute;
  font-size: 0;
}

uni-swiper .uni-swiper-dots-horizontal {
  left: 50%;
  bottom: 10px;
  text-align: center;
  white-space: nowrap;
  -webkit-transform: translate(-50%, 0);
  transform: translate(-50%, 0);
}

uni-swiper .uni-swiper-dots-horizontal .uni-swiper-dot {
  margin-right: 8px;
}

uni-swiper .uni-swiper-dots-horizontal .uni-swiper-dot:last-child {
  margin-right: 0;
}

uni-swiper .uni-swiper-dots-vertical {
  right: 10px;
  top: 50%;
  text-align: right;
  -webkit-transform: translate(0, -50%);
  transform: translate(0, -50%);
}

uni-swiper .uni-swiper-dots-vertical .uni-swiper-dot {
  display: block;
  margin-bottom: 9px;
}

uni-swiper .uni-swiper-dots-vertical .uni-swiper-dot:last-child {
  margin-bottom: 0;
}

uni-swiper .uni-swiper-dot {
  display: inline-block;
  width: 8px;
  height: 8px;
  cursor: pointer;
  transition-property: background-color;
  transition-timing-function: ease;
  background: rgba(0, 0, 0, 0.3);
  border-radius: 50%;
}

uni-swiper .uni-swiper-dot-active {
  background-color: #000000;
}
</style>


Don't show sample content subheading if there are no sample nodes

<template>

    <LoadingText v-if="loading" />

      <DetailsRow
        :label="$tr('creationHeading')"
        :text="createdDate"
      />
      <DetailsRow
        :label="$tr('sizeHeading')"
        :text="sizeText"
      />
      <DetailsRow :label="$tr('resourceHeading')">
        <template v-slot>

{{ $formatNumber(details.resource_count) }}

          <VDataTable
            v-if="kindCount.length"
            :items="kindCount"
            hide-actions
            hide-headers
          >
            <template v-slot:items="props">

                <ContentNodeIcon :kind="props.item.kind_id" />
                {{ translateConstant(props.item.kind_id) }}

              {{ $formatNumber(props.item.count) }}

            </template>
          </VDataTable>
        </template>
      </DetailsRow>
      <DetailsRow :label="$tr('containsHeading')">
        <template v-slot>
          <VChip v-if="details.includes.coach_content" color="grey lighten-2 tag">
            {{ $tr('coachHeading') }}
          </VChip>
          <VChip v-if="details.includes.exercises" color="grey lighten-2 tag">
            {{ $tr('assessmentsIncludedText') }}
          </VChip>
        </template>
      </DetailsRow>
      <DetailsRow
        :label="$tr('coachHeading')"
        :text="$formatNumber(details.includes.coach_content || 0)"
        :definition="$tr('coachDescription')"
      />
      <DetailsRow :label="$tr('tagsHeading')">
        <template v-slot>

            {{ $tr('defaultNoItemsText') }}

          <VChip
            v-for="tag in sortedTags"
            v-else
            :key="tag.tag_name"
            color="grey lighten-2 tag"
          >
            {{ tag.tag_name }}
          </VChip>
        </template>
      </DetailsRow>
      <DetailsRow :label="$tr('languagesHeading')">
        <template v-slot>
          <ExpandableList
            :noItemsText="$tr('defaultNoItemsText')"
            :items="details.languages"
            inline
          />
        </template>
      </DetailsRow>
      <DetailsRow :label="$tr('subtitlesHeading')">
        <template v-slot>
          <ExpandableList
            :noItemsText="$tr('defaultNoItemsText')"
            :items="details.accessible_languages"
            inline
          />
        </template>
      </DetailsRow>

      <DetailsRow
        :label="$tr('authorsLabel')"
        :definition="$tr('authorToolTip')"
      >
        <template v-slot>
          <ExpandableList
            :noItemsText="$tr('defaultNoItemsText')"
            :items="details.authors"
            inline
          />
        </template>
      </DetailsRow>
      <DetailsRow
        :label="$tr('providersLabel')"
        :definition="$tr('providerToolTip')"
      >
        <template v-slot>
          <ExpandableList
            :noItemsText="$tr('defaultNoItemsText')"
            :items="details.providers"
            inline
          />
        </template>
      </DetailsRow>
      <DetailsRow
        :label="$tr('aggregatorsLabel')"
        :definition="$tr('aggregatorToolTip')"
      >
        <template v-slot>
          <ExpandableList
            :noItemsText="$tr('defaultNoItemsText')"
            :items="details.aggregators"
            inline
          />
        </template>
      </DetailsRow>

      <DetailsRow :label="$tr('licensesLabel')">
        <template v-slot>
          <VTooltip v-for="license in details.licenses" :key="license" top>
            <template v-slot:activator="{ on }">
              <VChip color="grey lighten-2 tag" v-on="on">
                {{ translateConstant(license) }}
              </VChip>
            </template>
            {{ translateConstant(license + '_description') }}
          </VTooltip>
        </template>
      </DetailsRow>
      <DetailsRow :label="$tr('copyrightHoldersLabel')">
        <template v-slot>
          <ExpandableList
            :items="details.copyright_holders"
            :no-items-text="$tr('defaultNoItemsText')"
            inline
          />
        </template>
      </DetailsRow>

      <DetailsRow
        v-if="details.original_channels.length"
        :label="$tr('containsContentHeading')"
      >
        <template v-slot>
          <VLayout
            v-for="channel in details.original_channels"
            :key="channel.id"
            class="preview-row"
            align-center
            row
          >
            <VFlex style="max-width: 200px">
              <Thumbnail :src="channel.thumbnail" />
            </VFlex>
            <VFlex v-if="libraryMode" class="notranslate subheading font-weight-bold px-4">
              {{ channel.name }}
            </VFlex>
            <VBtn
              v-else
              :href="`/channels/${channel.id}/view`"
              target="_blank"
              flat
              color="primary"
              large
              class="notranslate"
            >
              {{ channel.name }}
            </VBtn>
          </VLayout>
        </template>
      </DetailsRow>

        {{ isChannel? $tr('sampleFromChannelHeading') : $tr('sampleFromTopicHeading') }}

      <VLayout row wrap class="sample-nodes pt-1">
        <VFlex v-for="node in details.sample_nodes" :key="node.node_id" xs12 sm3>
          <VCard height="100%" flat>
            <Thumbnail :src="node.thumbnail" />
            <VCardText class="notranslate">

                {{ node.title }}

            </VCardText>
          </VCard>
        </VFlex>
      </VLayout>

</template>


update 'checkbox' page to be more accurate

<template>

  <PageTemplate>

    <PageSection title="Usage guidelines" anchor="#usage">

This is a standard checkbox component:

      <Show>
        <KCheckbox
          label="I agree"
          :checked="true"
        />
      </Show>

Layout and usage:

Always left-aligned

When used in a group, checkboxes should always be vertically stacked

Should not be used to filter tables or lists

Should not be nested

Language:

The labels should be short and concise. They should not be sentences

Should represent positive values

    </PageSection>
    <PageSection title="API" anchor="#api">
      <ComponentDocs :api="kCheckboxApi" />
    </PageSection>
    <PageSection title="Code example" anchor="#example" fullwidth>
      <VueExample :code="exampleCode">
        <Example />
      </VueExample>
    </PageSection>
    <PageSection title="Exceptions" anchor="#exceptions">

In the Facility Settings section, the checkbox labels are more verbose than in other places. They are generally of the form "Allow learners to sign in with no password", and are meant to be as explicit as possible to fully communicate complex ideas

    </PageSection>

  </PageTemplate>

</template>

<template>

  <PageTemplate>

    <PageSection title="Usage guidelines" anchor="#usage">

This is a standard 'checkbox' component:

      <Show>
        <KCheckbox
          label="I agree"
          :checked="true"
        />
      </Show>

Layout

Always left-aligned, with the checkbox to the left of the label

When in a group, always vertically stacked

Language

The labels should be short and concise. They should not be sentences.

Other notes

Should denote positives (yes/select)

Should not be used to filter tables or lists

Should not be nested

      <ComponentDocs :api="kCheckboxApi" />

    </PageSection>
    <PageSection title="Code example" anchor="#example" fullwidth>
      <VueExample :code="exampleCode">
        <Example />
      </VueExample>
    </PageSection>

  </PageTemplate>

</template>


views: fix jerky refresh behaviour in list view

Signed-off-by: Rohit Yadav <902b9eb5dd62819678163bd5e00fbf0cbc7c264b@shapeblue.com>

// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.

<template>

          <breadcrumb style="padding-top: 6px" />

              <template slot="title">
                {{ "Refresh" }}
              </template>

              <template slot="title">
                {{ $t(action.label) }}
              </template>
               0)) || (dataView && action.dataView)) &&
                  ('show' in action ? action.show(resource) : true)"
                :icon="action.icon"
                :type="action.icon === 'delete' ? 'danger' : (action.icon === 'plus' ? 'primary' : 'default')"
                shape="circle"
                style="margin-right: 5px"
                @click="execAction(action)"
              >

      <keep-alive v-if="currentAction.component">

          <component :is="currentAction.component" :resource="resource" :loading="loading" v-bind="{currentAction}" />

      </keep-alive>

          {{ $t(currentAction.label) }}

                    {{ opt }}

                    {{ opt.name || opt.description }}

                    {{ opt.name && opt.type ? opt.name + ' (' + opt.type + ')' : opt.name || opt.description }}

      <resource-view
        :resource="resource"
        :loading="loading"
        :tabs="$route.meta.tabs" />

      <list-view
        :loading="loading"
        :columns="columns"
        :items="items"
        @refresh="this.fetchData"
        v-if="!treeView" />
       `Total ${total} items`"
        :pageSizeOptions="['10', '20', '40', '80', '100']"
        @change="changePage"
        @showSizeChange="changePageSize"
        showSizeChanger
        v-if="!treeView" />
      <tree-view
        v-if="treeView"
        :treeData="treeData"
        :treeSelected="treeSelected"
        :loading="loading"
        :tabs="$route.meta.tabs"
        @change-resource="changeResource"
        :actionData="actionData"/>

</template>


disabled rather than hidden

disabled rather than hidden

<template>

  <KModal
    :title="$tr('selectLocalRemoteSourceTitle')"
    size="medium"
    :submitText="$tr('continue')"
    :cancelText="$tr('cancel')"
    :submitDisabled="formIsDisabled"
    @submit="goForward"
    @cancel="resetContentWizardState"
  >
    <UiAlert
      v-if="formIsDisabled && !initialDelay"
      type="info"
      :dismissible="false"
      class="delay"
    >
      {{ $tr('loadingMessage') }}
    </UiAlert>

    <div>
      <KRadioButton
        :label="$tr('network')"
        v-model="source"
        :value="ContentSources.KOLIBRI_STUDIO"
        :disabled="kolibriStudioIsOffline || formIsDisabled"
        :autofocus="!kolibriStudioIsOffline"
      />
      <KRadioButton
        :label="$tr('localNetworkOrInternet')"
        v-model="source"
        :value="ContentSources.PEER_KOLIBRI_SERVER"
        :disabled="formIsDisabled"
      />
      <KRadioButton
        :label="$tr('localDrives')"
        v-model="source"
        :value="ContentSources.LOCAL_DRIVE"
        :disabled="formIsDisabled"
      />
    </div>
  </KModal>

</template>

<script>

  import { mapActions, mapGetters, mapMutations } from 'vuex';
  import KRadioButton from 'kolibri.coreVue.components.KRadioButton';
  import { RemoteChannelResource } from 'kolibri.resources';
  import KModal from 'kolibri.coreVue.components.KModal';
  import UiAlert from 'keen-ui/src/UiAlert';
  import { ContentSources } from '../../../constants';

  export default {
    name: 'SelectImportSourceModal',
    components: {
      KRadioButton,
      KModal,
      UiAlert,
    },
    data() {
      return {
        source: ContentSources.KOLIBRI_STUDIO,
        initialDelay: true, // hide everything for a second to prevent flicker
        formIsDisabled: true,
        kolibriStudioIsOffline: false,
        ContentSources,
      };
    },
    computed: {
      ...mapGetters('manageContent/wizard', ['isImportingMore']),
    },
    created() {
      setTimeout(() => {
        this.initialDelay = false;
      }, 1000);
      RemoteChannelResource.getKolibriStudioStatus().then(({ entity }) => {
        if (entity.status === 'offline') {
          this.source = ContentSources.PEER_KOLIBRI_SERVER;
          this.kolibriStudioIsOffline = true;
        }
        this.formIsDisabled = false;
      });
    },
    $trs: {
      cancel: 'Cancel',
      continue: 'Continue',
      network: 'Kolibri Studio (online)',
      localNetworkOrInternet: 'Local network or internet',
      localDrives: 'Attached drive or memory card',
      selectLocalRemoteSourceTitle: 'Select a source',
      loadingMessage: 'Loading connections…',
    },
    methods: {
      ...mapActions('manageContent/wizard', ['goForwardFromSelectImportSourceModal']),
      ...mapMutations('manageContent/wizard', {
        resetContentWizardState: 'RESET_STATE',
      }),
      goForward() {
        if (!this.formIsDisabled) {
          this.goForwardFromSelectImportSourceModal(this.source);
        }
      },
    },
  };

</script>

<style lang="scss" scoped></style>

<template>

  <KModal
    :title="$tr('selectLocalRemoteSourceTitle')"
    size="medium"
    :submitText="$tr('continue')"
    :cancelText="$tr('cancel')"
    :submitDisabled="formIsDisabled"
    @submit="goForward"
    @cancel="resetContentWizardState"
  >
    <UiAlert
      v-if="formIsDisabled && !initialDelay"
      type="info"
      :dismissible="false"
      class="delay"
    >
      {{ $tr('loadingMessage') }}
    </UiAlert>

    <div v-if="!formIsDisabled">
      <KRadioButton
        :label="$tr('network')"
        v-model="source"
        :value="ContentSources.KOLIBRI_STUDIO"
        :disabled="kolibriStudioIsOffline"
        :autofocus="!kolibriStudioIsOffline"
      />
      <KRadioButton
        :label="$tr('localNetworkOrInternet')"
        v-model="source"
        :value="ContentSources.PEER_KOLIBRI_SERVER"
      />
      <KRadioButton
        :label="$tr('localDrives')"
        v-model="source"
        :value="ContentSources.LOCAL_DRIVE"
      />
    </div>
  </KModal>

</template>

<script>

  import { mapActions, mapGetters, mapMutations } from 'vuex';
  import KRadioButton from 'kolibri.coreVue.components.KRadioButton';
  import { RemoteChannelResource } from 'kolibri.resources';
  import KModal from 'kolibri.coreVue.components.KModal';
  import UiAlert from 'keen-ui/src/UiAlert';
  import { ContentSources } from '../../../constants';

  export default {
    name: 'SelectImportSourceModal',
    components: {
      KRadioButton,
      KModal,
      UiAlert,
    },
    data() {
      return {
        source: ContentSources.KOLIBRI_STUDIO,
        initialDelay: true, // hide everything for a second to prevent flicker
        formIsDisabled: true,
        kolibriStudioIsOffline: false,
        ContentSources,
      };
    },
    computed: {
      ...mapGetters('manageContent/wizard', ['isImportingMore']),
    },
    created() {
      setTimeout(() => {
        this.initialDelay = false;
      }, 1000);
      RemoteChannelResource.getKolibriStudioStatus().then(({ entity }) => {
        if (entity.status === 'offline') {
          this.source = ContentSources.PEER_KOLIBRI_SERVER;
          this.kolibriStudioIsOffline = true;
        }
        this.formIsDisabled = false;
      });
    },
    $trs: {
      cancel: 'Cancel',
      continue: 'Continue',
      network: 'Kolibri Studio (online)',
      localNetworkOrInternet: 'Local network or internet',
      localDrives: 'Attached drive or memory card',
      selectLocalRemoteSourceTitle: 'Select a source',
      loadingMessage: 'Loading connections…',
    },
    methods: {
      ...mapActions('manageContent/wizard', ['goForwardFromSelectImportSourceModal']),
      ...mapMutations('manageContent/wizard', {
        resetContentWizardState: 'RESET_STATE',
      }),
      goForward() {
        if (!this.formIsDisabled) {
          this.goForwardFromSelectImportSourceModal(this.source);
        }
      },
    },
  };

</script>

<style lang="scss" scoped></style>


another bootstrap class fix

another bootstrap class fix

<template>

        <draggable
            v-model="localAttributes"
            :clone="clone"
            :move="move"
            :options="dragOpts"
            @add="added"
            @end="dropped"
            @start="dragged">

                    <button v-if="onEdit" class="btn btn-info btn-fab rounded-circle">

                    </button>
                    <button v-if="onRemove" class="btn btn-danger btn-fab rounded-circle" @click="onRemove(attribute)">

                    </button>
                    <button v-if="onDelete" class="btn btn-danger btn-fab rounded-circle" @click="onDelete(attribute)">

                    </button>

                    {{concepts[attribute.thesaurus_url].label}}:

                        <i class="fas fa-fw fa-exclamation"
                        :class="{
                            'text-danger': localValues[attribute.id].possibility <= 25,
                            'text-warning': localValues[attribute.id].possibility <= 50,
                            'text-info': localValues[attribute.id].possibility <= 75,
                            'text-success': localValues[attribute.id].possibility > 75 || (!localValues[attribute.id].possibility && localValues[attribute.id].possibility !== 0)
                            }">

                <input class="form-control" :disabled="attribute.isDisabled" v-if="attribute.datatype == 'string'" type="text" :id="'attribute-'+attribute.id" v-model="localValues[attribute.id].value"/>
                <input class="form-control" :disabled="attribute.isDisabled" v-else-if="attribute.datatype == 'double'" type="number" step="any" min="0" placeholder="0.0" :id="'attribute-'+attribute.id" v-model="localValues[attribute.id].value"/>
                <input class="form-control" :disabled="attribute.isDisabled" v-else-if="attribute.datatype == 'integer'" type="number" step="1" placeholder="0" :id="'attribute-'+attribute.id" v-model="localValues[attribute.id].value"/>
                <input class="form-control" :disabled="attribute.isDisabled" v-else-if="attribute.datatype == 'boolean'" type="checkbox" :id="'attribute-'+attribute.id" v-model="localValues[attribute.id].value"/>
                <textarea class="form-control" :disabled="attribute.isDisabled" v-else-if="attribute.datatype == 'stringf'" :id="'attribute-'+attribute.id" v-model="localValues[attribute.id].value"></textarea>

                    <input class="form-control" :disabled="attribute.isDisabled" type="range" step="1" min="0" max="100" value="0" :id="'attribute-'+attribute.id" v-model="localValues[attribute.id].value"/>
                    {{ localValues[attribute.id].value }}%

                    <input class="form-control" :disabled="attribute.isDisabled" type="text" :id="'attribute-'+attribute.id" placeholder="Add WKT" v-model="localValues[attribute.id].value" />
                    <button type="button" class="btn btn-outline-secondary" :disabled="attribute.isDisabled" style="margin-top: 10px;" ng-click="$ctrl.openGeographyPlacer(attribute.id)">
                         Open Map
                    </button>

                    <context-search></context-search>

                    <input type="text" class="form-control" :disabled="attribute.isDisabled" :id="'attribute-'+attribute.id" v-model="localValues[attribute.id].value"  ng-model-options="{timezone:'utc'}"/>

                        <button type="button" class="btn btn-outline-secondary">

                        </button>

                    <multiselect
                        :customLabel="translateLabel"
                        label="concept_url"
                        track-by="id"
                        v-model="localValues[attribute.id].value"
                        :allowEmpty="true"
                        :closeOnSelect="false"
                        :disabled="attribute.isDisabled"
                        :hideSelected="true"
                        :multiple="true"
                        :options="localSelections[attribute.id] || []">
                    </multiselect>

                    <multiselect
                        :customLabel="translateLabel"
                        label="concept_url"
                        track-by="id"
                        v-model="localValues[attribute.id].value"
                        :allowEmpty="true"
                        :closeOnSelect="true"
                        :disabled="attribute.isDisabled"
                        :hideSelected="true"
                        :multiple="false"
                        :options="localSelections[attribute.id] || []">
                    </multiselect>

                            <button type="button" class="btn btn-outline-secondary" :disabled="attribute.isDisabled" v-on:click="toggleList(attribute.id)">

                                        ({{localValues[attribute.id].value.length}})

                            </button>

                        <input type="text" class="form-control" :disabled="attribute.isDisabled" v-model="inputs[attribute.id]" />

                            <button type="button" class="btn btn-success" v-on:click="addListEntry(attribute.id)">

                            </button>

                            {{ l }}

                            <button type="button" class="btn btn-outline-secondary dropdown-toggle" :disabled="attribute.isDisabled" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                                {{ localValues[attribute.id].value.startLabel }}
                            </button>

                                BC
                                AD

                        <input type="number" step="1" pattern="[0-9]+" class="form-control text-center" :disabled="attribute.isDisabled" aria-label="" v-model="localValues[attribute.id].value.start">

                            -

                        <input type="number" step="1" pattern="[0-9]+" class="form-control text-center" :disabled="attribute.isDisabled" aria-label="" v-model="localValues[attribute.id].value.end">

                            <button type="button" class="btn btn-outline-secondary dropdown-toggle" :disabled="attribute.isDisabled" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                                {{ localValues[attribute.id].value.endLabel }}
                            </button>

                                BC
                                AD

                    <multiselect class="pt-2"
                        label="name"
                        track-by="id"
                        v-model="localValues[attribute.id].value.epoch"
                        :closeOnSelect="false"
                        :disabled="attribute.isDisabled"
                        :hideSelected="true"
                        :multiple="false"
                        :options="localSelections[attribute.id] || []">
                    </multiselect>

                        <input type="number" class="form-control text-center" :disabled="attribute.isDisabled" min="0" max="9999" step="0.01" v-model="localValues[attribute.id].value" />

                            ×

                        <input type="number" class="form-control text-center" :disabled="attribute.isDisabled" min="0" max="9999" step="0.01" v-model="localValues[attribute.id].value" />

                            ×

                        <input type="number" class="form-control text-center" :disabled="attribute.isDisabled" min="0" max="9999" step="0.01" v-model="localValues[attribute.id].value" />

                            <button class="btn btn-outline-secondary dropdown-toggle" :disabled="attribute.isDisabled" type="button"     data-toggle="dropdown" aria-haspopup="true"     aria-expanded="false">m</button>

                                    {{ unit }}

                    table

                <input class="form-control" :disabled="attribute.isDisabled" v-else type="text" :id="'attribute-'+attribute.id" v-model="localValues[attribute.id].value"/>

        </draggable>

</template>


Handling error when image not can be loaded.

<template>
    <v-flex xs12>

                <v-text-field v-model="linkData.title"
                              :label="$t('common:title')"
                              :rules="[ruleFieldRequired($t('validation:fieldRequired')),
                                               ruleToManyChars($t('validation:toManyChars'), 140)]" :counter="140">
                </v-text-field>
                <v-textarea v-model="linkData.description" rows="4"
                              :label="$t('pages:question.answerDialog.answerDescriptionLink')"
                              :rules="[ruleToManyChars($t('validation:toManyChars'), 1000)]" :counter="1000">
                </v-textarea>

{{$t('pages:question.answerLinkDialog.typeOfWebsiteDescription')}}

                <v-radio-group v-model="linkData.pageType" column id="page-type-container"
                               :rules="[ruleSelectRequired($t('validation:fieldRequired'))]">
                    <v-radio :label="$t('pages:question.answerLinkDialog.typeArticle')" value="article" color="primary">
                    </v-radio>
                    <v-radio :label="$t('pages:question.answerLinkDialog.typeBlog')" value="blog" color="primary">
                    </v-radio>
                    <v-radio :label="$t('pages:question.answerLinkDialog.typeWebsite')" value="website" color="primary">
                    </v-radio>
                </v-radio-group>

    </v-flex>
</template>


Refactored magnet dialog

Refactored magnet dialog

<template lang="pug">
  div
    v-dialog(v-model='values.show', absolute, max-width='800', @keydown.esc='close()')
      v-card#magnet-modal
        v-card-title.pb-2.pt-2
          h2.mb-0.pa-2.headline.ellipsis Results for #[strong {{ values.title }}]
          v-spacer

          template(v-if='selected.length')
            v-tooltip(left, key='open')
              template(v-slot:activator='{ on }')
                v-btn.mr-2(
                  text, icon,
                  @click='openSelected',
                  v-if='selected.length',
                  v-on='on'
                )
                  v-icon open_in_new
              span Open all selected magnets
            v-tooltip(left, key='copy')
              template(v-slot:activator='{ on }')
                v-btn(
                  text, icon,
                  @click='copyToClipboard',
                  v-if='selected.length',
                  v-on='on'
                )
                  v-icon.copy-icon content_copy
              span Copy all selected magnets

        v-divider

        v-card-text.subtitle-1.pt-2
          v-expansion-panels(popout)
            v-expansion-panel(
              v-for='(name, index) in filteredNames',
              :key='index',
              ripple, lazy
            )
              v-expansion-panel-header
                v-row(justify='space-between', align='center')
                  .title {{ name }}
                  v-tooltip(left)
                    template(v-slot:activator='{ on }')
                      v-btn.ma-0.mr-3(
                        icon,
                        @click.stop='selectAll(name)',
                        v-on='on'
                      )
                        v-icon select_all
                    span (un)select these magnets

              v-expansion-panel-content
                v-row.ep-container(
                  v-for='(magnet, subIndex) in getLinks(name)',
                  :key='magnet.link'
                )
                  v-flex.pt-0.pb-0(xs11, d-flex, align='center', :class='{ bordered: subIndex > 0 }')
                    .episode {{ magnet.name }} - Ep. {{ magnet.nb }}
                  v-flex.pt-2.pb-0(xs1, d-flex, justify='center', align='center', :class='{ bordered: subIndex > 0 }')
                    v-checkbox.mt-0(v-model='selected', :value='magnet.link', hide-details, color='primary')
                  v-flex.pt-0.pb-2.magnet(xs9, offset-xs3, d-flex, justify-end)
                    a.ellipsis(:href='magnet.link') {{ magnet.link}}

        v-divider

        v-card-actions
          v-spacer
          v-btn.blue--text.darken-1(text, @click='close') Thanks!
</template>

<script>
export default {
  name: 'Magnets-Modal',

  data: () => ({
    selected: [],
    panels: []
  }),

  computed: {
    values () {
      return this.$store.state.downloader.modal
    },
    show () {
      return this.values.show
    },
    eol () {
      if (this.$store.state.platform === 'win32') {
        return '\r\n'
      } else {
        return '\n'
      }
    },
    filteredNames () {
      return [
        ...new Set(this.values.magnets.map(({ name }) => name))
      ]
    }
  },

  methods: {
    close () {
      // We erase data on close as it's not that long to get it
      // and there is no way to open this modal but to download
      // something.
      this.$store.commit('downloader/setModal', {
        show: false,
        title: '',
        magnets: []
      })
    },
    getLinks (name) {
      return this.values.magnets.filter((magnet) => magnet.name === name)
    },
    selectAll (name) {
      // Find all magnets with that name
      const magnets = this.values.magnets
        .filter((elem) => elem.name === name)
        .map((elem) => elem.link)

      // Checking if some of them are present in current selected array
      const selected = magnets.filter((magnet) => this.selected.includes(magnet))
      const allSelected = selected.length === magnets.length

      // Selecting or unselecting accordingly
      magnets.forEach((magnet) => {
        const index = this.selected.indexOf(magnet)

        if (allSelected) {
          this.selected.splice(index, 1)
        } else {
          if (index === -1) {
            this.selected.push(magnet)
          }
        }
      })
    },
    openSelected () {
      this.selected.forEach((magnet) => {
        this.$electron.shell.openExternal(magnet)
      })
    },
    copyToClipboard () {
      const copy = Array.from(this.selected)

      if (copy.length) {
        this.$electron.clipboard.writeText(copy.join(this.eol))

        this.$store.commit('setInfoSnackbar', 'All selected magnets were copied to clipboard!')
      }
    }
  },

  watch: {
    show () {
      this.show && this.$store.dispatch('player/play')
      this.selected = this.values.magnets.map((elem) => elem.link)
    }
  }
}
</script>

<style lang="stylus" scoped>
  .copy-icon
    display inline-block
    cursor copy

  .ep-container
    padding 0 3%

    .bordered
      border-top 0.02em solid rgba(255, 255, 255, 0.4)

    .episode
      padding 8px
      font-size 18px
      letter-spacing 1px

    .magnet
      font-size 16px
      letter-spacing 1px
      text-align right
</style>

<template lang="pug">
  div
    v-dialog(v-model='values.show', absolute, max-width='800', @keydown.esc='close()')
      v-card#magnet-modal
        v-card-title.pb-2.pt-2
          h2.mb-0.main-title.ellipsis Results for #[strong {{ values.title }}]
          v-spacer
          v-tooltip(left)
            template(v-slot:activator='{ on }')
              v-btn.mr-2(
                text, icon,
                @click='openSelected',
                v-if='selected.length',
                v-on='on'
              )
                v-icon open_in_new
            span Open all selected magnets
          v-tooltip(left)
            template(v-slot:activator='{ on }')
              v-btn(
                text, icon,
                @click='copyToClipboard',
                v-if='selected.length',
                v-on='on'
              )
                v-icon.copy-icon content_copy
            span Copy all selected magnets
        v-divider
        v-card-text.subtitle-1.pt-2
          v-expansion-panels(popout)
            v-expansion-panel(
              v-for='(name, index) in filteredNames',
              :key='index',
              ripple, lazy
            )
              v-expansion-panel-header
                .entry-title
                  span {{ name }}
                  v-tooltip(left)
                    template(v-slot:activator='{ on }')
                      v-btn.ma-0.mr-3(
                        icon,
                        @click.stop='selectAll(name)',
                        v-on='on'
                      )
                        v-icon select_all
                    span (un)select these magnets
              v-divider
              v-expansion-panel-content
                v-layout.ep-container(
                  row, wrap,
                  v-for='(magnet, subIndex) in getLinks(name)', :key='magnet.link'
                )
                  v-flex.pt-0.pb-0(xs11, d-flex, align-center, :class='{ bordered: subIndex > 0 }')
                    .episode {{ magnet.name }} - Ep. {{ magnet.nb }}
                  v-flex.pt-2.pb-0(xs1, d-flex, justify-center, align-center, :class='{ bordered: subIndex > 0 }')
                    v-checkbox.mt-0(v-model='selected', :value='magnet.link', hide-details, color='primary')
                  v-flex.pt-0.pb-2.magnet(xs9, offset-xs3, d-flex, justify-end)
                    a.ellipsis(:href='magnet.link') {{ magnet.link}}
        v-card-actions
          v-spacer
          v-btn.blue--text.darken-1(text, @click='close()') Thanks!
    v-snackbar(
      :timeout='2500',
      top,
      v-model='snack'
    ) All selected magnets were copied to clipboard!
      v-btn.pink--text(text, @click='snack = false') Thanks!
</template>

<script>
export default {
  name: 'Magnets-Modal',

  data () {
    return {
      snack: false,
      selected: [],
      panels: []
    }
  },

  computed: {
    values () {
      return this.$store.state.downloader.modal
    },
    show () {
      return this.values.show
    },
    eol () {
      if (this.$store.state.platform === 'win32') {
        return '\r\n'
      } else {
        return '\n'
      }
    },
    filteredNames () {
      return this.$_.uniq(this.values.magnets.map((magnet) => {
        return magnet.name
      }))
    }
  },

  methods: {
    close () {
      // We erase data on close as it's not that long to get it
      // and there is no way to open this modal but to download
      // something.
      this.$store.commit('downloader/setModal', {
        show: false,
        title: '',
        magnets: []
      })
    },
    getLinks (name) {
      return this.values.magnets.filter((magnet) => magnet.name === name)
    },
    selectAll (name) {
      // Find all magnets with that name
      const magnets = this.values.magnets
        .filter((elem) => elem.name === name)
        .map((elem) => elem.link)

      // Checking if some of them are present in current selected array
      const selected = magnets.filter((magnet) => this.selected.includes(magnet))
      const allSelected = selected.length === magnets.length

      // Selecting or unselecting accordingly
      magnets.forEach((magnet) => {
        const index = this.selected.indexOf(magnet)

        if (allSelected) {
          this.selected.splice(index, 1)
        } else {
          if (index === -1) {
            this.selected.push(magnet)
          }
        }
      })
    },
    openSelected () {
      this.$_.each(this.selected, (magnet) => {
        this.$electron.shell.openExternal(magnet)
      })
    },
    copyToClipboard () {
      const copy = Array.from(this.selected)

      if (copy.length) {
        this.$electron.clipboard.writeText(copy.join(this.eol))

        this.snack = true
      }
    }
  },

  watch: {
    show () {
      this.show && this.$store.dispatch('player/play')
      this.selected = this.values.magnets.map((elem) => elem.link)
    }
  }
}
</script>

<style lang="stylus" scoped>
  .copy-icon
    display inline-block
    cursor copy

  .main-title
    padding 8px
    font-size 24px
    font-weight 300
    letter-spacing 0.05em

  .entry-title
    display flex
    justify-content space-between

    span
      font-size 22px
      font-weight 400
      letter-spacing 0.04em

  .ep-container
    padding 0 3%

    .bordered
      border-top 0.02em solid rgba(255, 255, 255, 0.4)

    .episode
      padding 8px
      font-size 18px
      letter-spacing 1px

    .magnet
      font-size 16px
      letter-spacing 1px
      text-align right
</style>