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.

Ce script ne devrait pas être installé directement. C'est une librairie créée pour d'autres scripts. Elle doit être inclus avec la commande // @require https://update.greatest.deepsurf.us/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})
})()));