tinysort

TinySort is a small script that sorts HTML elements. It sorts by text- or attribute value, or by that of one of it's children.

此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.cn-greasyfork.org/scripts/545962/1642423/tinysort.js

// ==UserScript==
// @name        tinysort
// @version     3.2.7
// @namespace   http://www.ronvalstar.nl/
// @description TinySort is a small script that sorts HTML elements. It sorts by text- or attribute value, or by that of one of it's children.
// @author      Ron Valstar (http://www.ronvalstar.nl/)
// @license     MIT
// @exclude     *
// @grant       none
// ==/UserScript==
(function(root,tinysort){
  typeof define==='function'&&define.amd?define('tinysort',()=>tinysort):(root.tinysort = tinysort)
}(window||module||{},(_undef=>{
  const fls = !1
  const undef = _undef
  const nll = null
  const win = window
  const doc = win.document
  const parsefloat = parseFloat
  const regexLastNr = /(-?\d+\.?\d*)\s*$/g    // regex for testing strings ending on numbers
  const regexLastNrNoDash = /(\d+\.?\d*)\s*$/g  // regex for testing strings ending on numbers ignoring dashes
  const plugins = []
  const stringFromCharCode = i=>String.fromCharCode(i)
  const charsFrom = from=>Array.from(new Array(3),(o,i)=>stringFromCharCode(from+i))
  const charLow = charsFrom(0)
  const charHigh = charsFrom(0xFFF)
  /**{options}*/
  const defaults = {   // default settings
    selector: nll      // CSS selector to select the element to sort to
    ,order: 'asc'      // order: asc, desc or rand
    ,attr: nll         // order by attribute value
    ,data: nll         // use the data attribute for sorting
    ,useVal: fls       // use element value instead of text
    ,place: 'org'      // place ordered elements at position: start, end, org (original position), first, last
    ,returns: fls      // return all elements or only the sorted ones (true/false)
    ,cases: fls        // a case sensitive sort orders [aB,aa,ab,bb]
    ,natural: fls      // use natural sort order
    ,forceStrings:fls  // if false the string '2' will sort with the value 2, not the string '2'
    ,ignoreDashes:fls  // ignores dashes when looking for numerals
    ,sortFunction: nll // override the default sort function
    ,useFlex:fls
    ,emptyEnd:fls
    ,console
  }
  let numCriteria = 0
  let criteriumIndex = 0

  /**
   * Options object
   * @typedef {object} options
   * @property {string} [selector] A CSS selector to select the element to sort to.
   * @property {string} [order='asc'] The order of the sorting method. Possible values are 'asc', 'desc' and 'rand'.
   * @property {string} [attr=null] Order by attribute value (ie title, href, class)
   * @property {string} [data=null] Use the data attribute for sorting.
   * @property {string} [place='org'] Determines the placement of the ordered elements in respect to the unordered elements. Possible values 'start', 'end', 'first', 'last' or 'org'.
   * @property {boolean} [useVal=false] Use element value instead of text.
   * @property {boolean} [cases=false] A case sensitive sort (orders [aB,aa,ab,bb])
   * @property {boolean} [natural=false] Use natural sort order.
   * @property {boolean} [forceStrings=false] If false the string '2' will sort with the value 2, not the string '2'.
   * @property {boolean} [ignoreDashes=false] Ignores dashes when looking for numerals.
   * @property {function} [sortFunction=null] Override the default sort function. The parameters are of a type {elementObject}.
   * @property {boolean} [useFlex=true] If one parent and display flex, ordering is done by CSS (instead of DOM)
   * @property {boolean} [emptyEnd=true] Sort empty values to the end instead of the start
   * @property {object|boolean} [console] - an optional console implementation to prevent output to console
   */

  /**
   * TinySort is a small and simple script that will sort any nodeElement by it's text- or attribute value, or by that of one of it's children.
   * @memberof tinysort
   * @public
   * @param {NodeList|HTMLElement[]|String} nodeList The nodelist or array of elements to be sorted. If a string is passed it should be a valid CSS selector.
   * @param {options[]} [...optionsList] A list of options.
   * @returns {HTMLElement[]}
   */
  function tinysort(nodeList,...optionsList){
    const options = optionsList[0]||{}
    isString(nodeList) && (nodeList = doc.querySelectorAll(nodeList))

    const {console} = Object.assign({},defaults,options||{})
    nodeList.length===0 && console && console.warn && console.warn('No elements to sort')

    const fragment = doc.createDocumentFragment()
    /** both sorted and unsorted elements
     * @type {elementObject[]} */
    const elmObjsAll = []
    /** sorted elements
     * @type {elementObject[]} */
    const elmObjsSorted = []
    /** unsorted elements
     * @type {elementObject[]} */
    const elmObjsUnsorted = []
    /** sorted elements before sort
     * @type {elementObject[]} */
    const elmObjsSortedInitial = []
    /** @type {criteriumIndex[]} */
    const criteria = []
    /** @type {HTMLElement} */
    let parentNode
    let isSameParent = true
    let firstParent = nodeList.length&&nodeList[0].parentNode
    let isFragment = (firstParent && firstParent != undefined) ? firstParent.rootNode!==document : false;
    let isFlex = nodeList.length&&(options===undef||options.useFlex!==false)&&!isFragment&&getComputedStyle(firstParent,null).display.indexOf('flex')!==-1

    numCriteria = addCriteria(optionsList)
    initSortList()
    elmObjsSorted.sort(options.sortFunction||sortFunction)
    applyToDOM()

    /**
     * Create criteria list
     * @param {object[]} optionsList
     * @returns {number}
     */
    function addCriteria(optionsList){
      return optionsList.length===0
        &&addCriterium({}) // have at least one criterium
        ||loop(optionsList,param=>addCriterium(isString(param)?{selector:param}:param)).length
    }

    /**
     * A criterium is a combination of the selector, the options and the default options
     * @typedef {options} criterium
     * @property {boolean} hasSelector - options has a selector
     * @property {boolean} hasFilter - options has a filter
     * @property {boolean} hasAttr - options has an attribute selector
     * @property {boolean} hasData - options has a data selector
     * @property {number} sortReturnNumber - the sort function return number determined by options.order
     */

    /**
     * Adds a criterium
     * @memberof tinysort
     * @private
     * @param {Object} [options]
     * @returns {number}
     */
    function addCriterium(options){
      const hasSelector = !!options.selector
      const hasFilter = hasSelector&&options.selector[0]===':'
      const allOptions = extend(options||{},defaults)
      return criteria.push(extend({
        // has find, attr or data
        hasSelector
        ,hasAttr: !(allOptions.attr===nll||allOptions.attr==='')
        ,hasData: allOptions.data!==nll
        // filter
        ,hasFilter
        ,sortReturnNumber: allOptions.order==='asc'?1:-1
      },allOptions))
    }

    /**
     * The element object.
     * @typedef {Object} elementObject
     * @property {HTMLElement} elm - The element
     * @property {number} pos - original position
     * @property {number} posn - original position on the partial list
     */

    /**
     * Creates an elementObject and adds to lists.
     * Also checks if has one or more parents.
     * @memberof tinysort
     * @private
     */
    function initSortList(){
      loop(nodeList,(elm,pos)=>{
        if (!parentNode) parentNode = elm.parentNode
        else if (parentNode!==elm.parentNode) isSameParent = false
        const {hasFilter,selector} = criteria[0]
        const isPartial = !selector||(hasFilter&&elm.matches(selector))||(selector&&elm.querySelector(selector))
        const listPartial = isPartial?elmObjsSorted:elmObjsUnsorted
        const posn = listPartial.length
        const elementObject = {elm,pos,posn}
        elmObjsAll.push(elementObject)
        listPartial.push(elementObject)
      })
      elmObjsSortedInitial.splice(0,Number.MAX_SAFE_INTEGER,...elmObjsSorted)
    }

    /**
     * Compare strings using natural sort order
     * http://web.archive.org/web/20130826203933/http://my.opera.com/GreyWyvern/blog/show.dml/1671288
     */
    function naturalCompare(a, b, chunkify) {
      const aa = chunkify(a.toString())
      const bb = chunkify(b.toString())
      for (let x = 0; aa[x] && bb[x]; x++) {
        if (aa[x]!==bb[x]) {
          const c = Number(aa[x])
            ,d = Number(bb[x])
          if (c == aa[x] && d == bb[x]) {
            return c - d
          } else return aa[x]>bb[x]?1:-1
        }
      }
      return aa.length - bb.length
    }

    /**
     * Split a string into an array by type: numeral or string
     * @memberof tinysort
     * @private
     * @param {string} t
     * @returns {Array}
     */
    function chunkify(t) {
      const tz = []
      let x = 0, y = -1, n = 0, i, j
      while (i = (j = t.charAt(x++)).charCodeAt(0)) { // eslint-disable-line no-cond-assign
        const m = (i === 46 || (i >=48 && i <= 57))
        if (m !== n) {
          tz[++y] = ''
          n = m
        }
        tz[y] += j
      }
      return tz
    }

    /**
     * Sort all the things
     * @memberof tinysort
     * @private
     * @param {elementObject} a
     * @param {elementObject} b
     * @returns {number}
     */
    function sortFunction(a,b){
      let sortReturnNumber = 0
      if (criteriumIndex!==0) criteriumIndex = 0
      while (sortReturnNumber===0&&criteriumIndex<numCriteria) {
        /** @type {criterium} */
        const criterium = criteria[criteriumIndex]
        const regexLast = criterium.ignoreDashes?regexLastNrNoDash:regexLastNr
        //
        loop(plugins,plugin=>plugin.prepare && plugin.prepare(criterium))
        //
        let isNumeric = fls
        // prepare sort elements
        let valueA = getSortBy(a,criterium)
        let valueB = getSortBy(b,criterium)
        if (criterium.sortFunction) { // custom sort
          sortReturnNumber = criterium.sortFunction(a,b)
        } else if (criterium.order==='rand') { // random sort
          sortReturnNumber = Math.random()<0.5?1:-1
        } else { // regular sort
          if (valueA===valueB) {
            sortReturnNumber = 0
          } else {
            if (!criterium.forceStrings) {
              // cast to float if both strings are numeral (or end numeral)
              let valuesA = isString(valueA)?valueA&&valueA.match(regexLast):fls// todo: isString superfluous because getSortBy returns string|undefined
              let valuesB = isString(valueB)?valueB&&valueB.match(regexLast):fls
              if (valuesA&&valuesB) {
                const previousA = valueA.substr(0,valueA.length-valuesA[0].length)
                const previousB = valueB.substr(0,valueB.length-valuesB[0].length)
                if (previousA==previousB) {
                  isNumeric = !fls
                  valueA = parsefloat(valuesA[0])
                  valueB = parsefloat(valuesB[0])
                }
              }
            }
            if (!criterium.natural||(!isNaN(valueA)&&!isNaN(valueB))) {
              sortReturnNumber = valueA<valueB?-1:(valueA>valueB?1:0)
            } else {
              sortReturnNumber = naturalCompare(valueA, valueB, chunkify)
            }
          }
        }
        loop(plugins,({sort})=>sort && (sortReturnNumber = sort(criterium,isNumeric,valueA,valueB,sortReturnNumber)))
        sortReturnNumber *= criterium.sortReturnNumber // lastly assign asc/desc
        sortReturnNumber===0 && criteriumIndex++
      }
      sortReturnNumber===0 && (sortReturnNumber = a.pos>b.pos?1:-1)
      return sortReturnNumber
    }

    /**
     * Applies the sorted list to the DOM
     * @memberof tinysort
     * @private
     */
    function applyToDOM(){
      const numSorted = elmObjsSorted.length
      const hasSortedAll = numSorted===elmObjsAll.length
      const hasSortedAllSiblings = (parentNode && parentNode != undefined) ? numSorted===parentNode.children.length : false;
      const {place,console} = criteria[0]
      if (isSameParent&&hasSortedAll&&hasSortedAllSiblings) {
        if (isFlex) {
          elmObjsSorted.forEach((elmObj,i)=>elmObj.elm.style.order = i)
        } else {
          if (parentNode) parentNode.appendChild(sortedIntoFragment())
          else console && console.warn && console.warn('parentNode has been removed')
        }
      } else {
        const isPlaceOrg = place==='org'
        const isPlaceStart = place==='start'
        const isPlaceEnd = place==='end'
        const isPlaceFirst = place==='first'
        const isPlaceLast = place==='last'
        if (isPlaceOrg) {
          elmObjsSorted.forEach(addGhost)
          elmObjsSorted.forEach((elmObj,i)=>replaceGhost(elmObjsSortedInitial[i],elmObj.elm))
        } else if (isPlaceStart||isPlaceEnd) {
          let startElmObj = elmObjsSortedInitial[isPlaceStart?0:elmObjsSortedInitial.length-1]
          const startParent = startElmObj&&startElmObj.elm.parentNode
          const startElm = startParent&&(isPlaceStart&&startParent.firstChild||startParent.lastChild)
          if (startElm) {
            startElm!==startElmObj.elm && (startElmObj = {elm:startElm})
            addGhost(startElmObj)
            isPlaceEnd&&startParent.appendChild(startElmObj.ghost)
            replaceGhost(startElmObj,sortedIntoFragment())
          }
        } else if (isPlaceFirst||isPlaceLast) {
          const firstElmObj = elmObjsSortedInitial[isPlaceFirst?0:elmObjsSortedInitial.length-1]
          replaceGhost(addGhost(firstElmObj),sortedIntoFragment())
        }
      }
    }

    /**
     * Adds all sorted elements to the document fragment and returns it.
     * @memberof tinysort
     * @private
     * @returns {DocumentFragment}
     */
    function sortedIntoFragment(){
      elmObjsSorted.forEach(elmObj=>fragment.appendChild(elmObj.elm))
      return fragment
    }

    /**
     * Adds a temporary element before an element before reordering.
     * @memberof tinysort
     * @private
     * @param {elementObject} elmObj
     * @returns {elementObject}
     */
    function addGhost(elmObj){
      const element = elmObj.elm
        ,ghost = doc.createElement('div')
      elmObj.ghost = ghost
      element.parentNode.insertBefore(ghost,element)
      return elmObj
    }

    /**
     * Inserts an element before a ghost element and removes the ghost.
     * @memberof tinysort
     * @private
     * @param {elementObject} elmObjGhost
     * @param {HTMLElement} elm
     */
    function replaceGhost(elmObjGhost,elm){
      const ghost = elmObjGhost.ghost
        ,ghostParent = ghost.parentNode
      ghostParent.insertBefore(elm,ghost)
      ghostParent.removeChild(ghost)
      delete elmObjGhost.ghost
    }

    /**
     * Get the string/number to be sorted by checking the elementObject with the criterium.
     * @memberof tinysort
     * @private
     * @param {elementObject} elementObject
     * @param {criterium} criterium
     * @returns {String}
     * @todo memoize
     */
    function getSortBy(elementObject,criterium){
      let sortBy
          ,element = elementObject.elm
          ,{selector} = criterium
      // element
      if (selector) {
        if (criterium.hasFilter) {
          if (!element.matches(selector)) element = nll
        } else {
          element = element.querySelector(selector)
        }
      }
      // value
      if (criterium.hasAttr) sortBy = element.getAttribute(criterium.attr)
      else if (criterium.useVal) sortBy = element.value||element.getAttribute('value')
      else if (criterium.hasData) sortBy = element.getAttribute('data-'+criterium.data)
      else if (element) sortBy = element.textContent
      // strings should be ordered in lowercase (unless specified)
      if (isString(sortBy)) {
        if (!criterium.cases) sortBy = sortBy.toLowerCase()
        sortBy = sortBy.replace(/\s+/g,' ') // spaces/newlines
      }
      const noIndex = [undef,nll,''].indexOf(sortBy)
      if (noIndex!==-1) sortBy = (criterium.emptyEnd?charHigh:charLow)[noIndex]
      return sortBy
    }

    /*function memoize(fnc) {
      var oCache = {}
        , sKeySuffix = 0;
      return function () {
        var sKey = sKeySuffix + JSON.stringify(arguments); // todo: circular dependency on Nodes
        return (sKey in oCache)?oCache[sKey]:oCache[sKey] = fnc.apply(fnc,arguments);
      };
    }*/

    /**
     * Test if an object is a string
     * @memberOf tinysort
     * @method
     * @private
     * @param o
     * @returns {boolean}
     */
    function isString(o){
      return typeof o==='string'
    }

    return elmObjsSorted.map(o=>o.elm)
  }

  /**
   * Traverse an array, or array-like object
   * @memberOf tinysort
   * @method
   * @private
   * @param {Array} array The object or array
   * @param {Function} func Callback function with the parameters value and key.
   * @returns {Array}
   */
  function loop(array,func){
    const l = array.length
    let i = l
    while (i--) {
      const j = l-i-1
      func(array[j],j)
    }
    return array
  }

  /**
   * Extend an object
   * @memberOf tinysort
   * @method
   * @private
   * @param {Object} obj Subject.
   * @param {Object} fns Property object.
   * @param {boolean} [overwrite=false]  Overwrite properties.
   * @returns {Object} Subject.
   */
  function extend(obj,fns,overwrite){
    for (let s in fns) {
      if (overwrite||obj[s]===undef) {
        obj[s] = fns[s]
      }
    }
    return obj
  }

  /**
   * Public API method for plugins
   * @param {Function} prepare
   * @param {Function} sort
   * @param {object} sortBy
   * @returns {number}
   */
  function plugin(prepare,sort,sortBy){
    return plugins.push({prepare,sort,sortBy})
  }

  // Element.prototype.matches IE
  win.Element&&(elementPrototype=>elementPrototype.matches = elementPrototype.matches||elementPrototype.msMatchesSelector)(Element.prototype)

  // extend the plugin to expose stuff
  extend(plugin,{loop})

  return extend(tinysort,{plugin,defaults})
})()));