splitting
Version:
Micro-library to split a DOM element's words & chars into elements populated with CSS vars.
543 lines (475 loc) • 13.6 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global.Splitting = factory());
}(this, (function () { 'use strict';
var root = document;
var createText = root.createTextNode.bind(root);
/**
* # setProperty
* Apply a CSS var
* @param {HTMLElement} el
* @param {string} varName
* @param {string|number} value
*/
function setProperty(el, varName, value) {
el.style.setProperty(varName, value);
}
/**
*
* @param {!HTMLElement} el
* @param {!HTMLElement} child
*/
function appendChild(el, child) {
return el.appendChild(child);
}
/**
*
* @param {!HTMLElement} parent
* @param {string} key
* @param {string} text
* @param {boolean} whitespace
*/
function createElement(parent, key, text, whitespace) {
var el = root.createElement('span');
key && (el.className = key);
if (text) {
!whitespace && el.setAttribute("data-" + key, text);
el.textContent = text;
}
return (parent && appendChild(parent, el)) || el;
}
/**
*
* @param {!HTMLElement} el
* @param {string} key
*/
function getData(el, key) {
return el.getAttribute("data-" + key)
}
/**
*
* @param {import('../types').Target} e
* @param {!HTMLElement} parent
* @returns {!Array<!HTMLElement>}
*/
function $(e, parent) {
return !e || e.length == 0
? // null or empty string returns empty array
[]
: e.nodeName
? // a single element is wrapped in an array
[e]
: // selector and NodeList are converted to Element[]
[].slice.call(e[0].nodeName ? e : (parent || root).querySelectorAll(e));
}
/**
* Creates and fills an array with the value provided
* @param {number} len
* @param {() => T} valueProvider
* @return {T}
* @template T
*/
function Array2D(len) {
var a = [];
for (; len--; ) {
a[len] = [];
}
return a;
}
/**
* A for loop wrapper used to reduce js minified size.
* @param {!Array<T>} items
* @param {function(T):void} consumer
* @template T
*/
function each(items, consumer) {
items && items.some(consumer);
}
/**
* @param {T} obj
* @return {function(string):*}
* @template T
*/
function selectFrom(obj) {
return function (key) {
return obj[key];
}
}
/**
* # Splitting.index
* Index split elements and add them to a Splitting instance.
*
* @param {HTMLElement} element
* @param {string} key
* @param {!Array<!HTMLElement> | !Array<!Array<!HTMLElement>>} items
*/
function index(element, key, items) {
var prefix = '--' + key;
var cssVar = prefix + "-index";
each(items, function (items, i) {
if (Array.isArray(items)) {
each(items, function(item) {
setProperty(item, cssVar, i);
});
} else {
setProperty(items, cssVar, i);
}
});
setProperty(element, prefix + "-total", items.length);
}
/**
* @type {Record<string, import('./types').ISplittingPlugin>}
*/
var plugins = {};
/**
* @param {string} by
* @param {string} parent
* @param {!Array<string>} deps
* @return {!Array<string>}
*/
function resolvePlugins(by, parent, deps) {
// skip if already visited this dependency
var index = deps.indexOf(by);
if (index == -1) {
// if new to dependency array, add to the beginning
deps.unshift(by);
// recursively call this function for all dependencies
var plugin = plugins[by];
if (!plugin) {
throw new Error("plugin not loaded: " + by);
}
each(plugin.depends, function(p) {
resolvePlugins(p, by, deps);
});
} else {
// if this dependency was added already move to the left of
// the parent dependency so it gets loaded in order
var indexOfParent = deps.indexOf(parent);
deps.splice(index, 1);
deps.splice(indexOfParent, 0, by);
}
return deps;
}
/**
* Internal utility for creating plugins... essentially to reduce
* the size of the library
* @param {string} by
* @param {string} key
* @param {string[]} depends
* @param {Function} split
* @returns {import('./types').ISplittingPlugin}
*/
function createPlugin(by, depends, key, split) {
return {
by: by,
depends: depends,
key: key,
split: split
}
}
/**
*
* @param {string} by
* @returns {import('./types').ISplittingPlugin[]}
*/
function resolve(by) {
return resolvePlugins(by, 0, []).map(selectFrom(plugins));
}
/**
* Adds a new plugin to splitting
* @param {import('./types').ISplittingPlugin} opts
*/
function add(opts) {
plugins[opts.by] = opts;
}
/**
* # Splitting.split
* Split an element's textContent into individual elements
* @param {!HTMLElement} el Element to split
* @param {string} key
* @param {string} splitOn
* @param {boolean} includePrevious
* @param {boolean} preserveWhitespace
* @return {!Array<!HTMLElement>}
*/
function splitText(el, key, splitOn, includePrevious, preserveWhitespace) {
// Combine any strange text nodes or empty whitespace.
el.normalize();
// Use fragment to prevent unnecessary DOM thrashing.
var elements = [];
var F = document.createDocumentFragment();
if (includePrevious) {
elements.push(el.previousSibling);
}
var allElements = [];
$(el.childNodes).some(function(next) {
if (next.tagName && !next.hasChildNodes()) {
// keep elements without child nodes (no text and no children)
allElements.push(next);
return;
}
// Recursively run through child nodes
if (next.childNodes && next.childNodes.length) {
allElements.push(next);
elements.push.apply(elements, splitText(next, key, splitOn, includePrevious, preserveWhitespace));
return;
}
// Get the text to split, trimming out the whitespace
/** @type {string} */
var wholeText = next.wholeText || '';
var contents = wholeText.trim();
// If there's no text left after trimming whitespace, continue the loop
if (contents.length) {
// insert leading space if there was one
if (wholeText[0] === ' ') {
allElements.push(createText(' '));
}
// Concatenate the split text children back into the full array
var useSegmenter = splitOn === "" && typeof Intl.Segmenter === "function";
each(useSegmenter ? Array.from(new Intl.Segmenter().segment(contents)).map(function(x){return x.segment}) : contents.split(splitOn), function (splitText, i) {
if (i && preserveWhitespace) {
allElements.push(createElement(F, "whitespace", " ", preserveWhitespace));
}
var splitEl = createElement(F, key, splitText);
elements.push(splitEl);
allElements.push(splitEl);
});
// insert trailing space if there was one
if (wholeText[wholeText.length - 1] === ' ') {
allElements.push(createText(' '));
}
}
});
each(allElements, function(el) {
appendChild(F, el);
});
// Clear out the existing element
el.innerHTML = "";
appendChild(el, F);
return elements;
}
/** an empty value */
var _ = 0;
function copy(dest, src) {
for (var k in src) {
dest[k] = src[k];
}
return dest;
}
var WORDS = 'words';
var wordPlugin = createPlugin(
/* by= */ WORDS,
/* depends= */ _,
/* key= */ 'word',
/* split= */ function(el) {
return splitText(el, 'word', /\s+/, 0, 1)
}
);
var CHARS = "chars";
var charPlugin = createPlugin(
/* by= */ CHARS,
/* depends= */ [WORDS],
/* key= */ "char",
/* split= */ function(el, options, ctx) {
var results = [];
each(ctx[WORDS], function(word, i) {
results.push.apply(results, splitText(word, "char", "", options.whitespace && i));
});
return results;
}
);
/**
* # Splitting
*
* @param {import('./types').ISplittingOptions} opts
* @return {!Array<*>}
*/
function Splitting (opts) {
opts = opts || {};
var key = opts.key;
return $(opts.target || '[data-splitting]').map(function(el) {
var ctx = el['🍌'];
if (!opts.force && ctx) {
return ctx;
}
ctx = el['🍌'] = { el: el };
var by = opts.by || getData(el, 'splitting');
if (!by || by == 'true') {
by = CHARS;
}
var items = resolve(by);
var opts2 = copy({}, opts);
each(items, function(plugin) {
if (plugin.split) {
var pluginBy = plugin.by;
var key2 = (key ? '-' + key : '') + plugin.key;
var results = plugin.split(el, opts2, ctx);
key2 && index(el, key2, results);
ctx[pluginBy] = results;
el.classList.add(pluginBy);
}
});
el.classList.add('splitting');
return ctx;
})
}
/**
* # Splitting.html
*
* @param {import('./types').ISplittingOptions} opts
*/
function html(opts) {
opts = opts || {};
var parent = opts.target = createElement();
parent.innerHTML = opts.content;
Splitting(opts);
return parent.outerHTML
}
Splitting.html = html;
Splitting.add = add;
/**
* Detects the grid by measuring which elements align to a side of it.
* @param {!HTMLElement} el
* @param {import('../core/types').ISplittingOptions} options
* @param {*} side
*/
function detectGrid(el, options, side) {
var items = $(options.matching || el.children, el);
var c = {};
each(items, function(w) {
var val = Math.round(w[side]);
(c[val] || (c[val] = [])).push(w);
});
return Object.keys(c).map(Number).sort(byNumber).map(selectFrom(c));
}
/**
* Sorting function for numbers.
* @param {number} a
* @param {number} b
* @return {number}
*/
function byNumber(a, b) {
return a - b;
}
var linePlugin = createPlugin(
/* by= */ 'lines',
/* depends= */ [WORDS],
/* key= */ 'line',
/* split= */ function(el, options, ctx) {
return detectGrid(el, { matching: ctx[WORDS] }, 'offsetTop')
}
);
var itemPlugin = createPlugin(
/* by= */ 'items',
/* depends= */ _,
/* key= */ 'item',
/* split= */ function(el, options) {
return $(options.matching || el.children, el)
}
);
var rowPlugin = createPlugin(
/* by= */ 'rows',
/* depends= */ _,
/* key= */ 'row',
/* split= */ function(el, options) {
return detectGrid(el, options, "offsetTop");
}
);
var columnPlugin = createPlugin(
/* by= */ 'cols',
/* depends= */ _,
/* key= */ "col",
/* split= */ function(el, options) {
return detectGrid(el, options, "offsetLeft");
}
);
var gridPlugin = createPlugin(
/* by= */ 'grid',
/* depends= */ ['rows', 'cols']
);
var LAYOUT = "layout";
var layoutPlugin = createPlugin(
/* by= */ LAYOUT,
/* depends= */ _,
/* key= */ _,
/* split= */ function(el, opts) {
// detect and set options
var rows = opts.rows = +(opts.rows || getData(el, 'rows') || 1);
var columns = opts.columns = +(opts.columns || getData(el, 'columns') || 1);
// Seek out the first <img> if the value is true
opts.image = opts.image || getData(el, 'image') || el.currentSrc || el.src;
if (opts.image) {
var img = $("img", el)[0];
opts.image = img && (img.currentSrc || img.src);
}
// add optional image to background
if (opts.image) {
setProperty(el, "background-image", "url(" + opts.image + ")");
}
var totalCells = rows * columns;
var elements = [];
var container = createElement(_, "cell-grid");
while (totalCells--) {
// Create a span
var cell = createElement(container, "cell");
createElement(cell, "cell-inner");
elements.push(cell);
}
// Append elements back into the parent
appendChild(el, container);
return elements;
}
);
var cellRowPlugin = createPlugin(
/* by= */ "cellRows",
/* depends= */ [LAYOUT],
/* key= */ "row",
/* split= */ function(el, opts, ctx) {
var rowCount = opts.rows;
var result = Array2D(rowCount);
each(ctx[LAYOUT], function(cell, i, src) {
result[Math.floor(i / (src.length / rowCount))].push(cell);
});
return result;
}
);
var cellColumnPlugin = createPlugin(
/* by= */ "cellColumns",
/* depends= */ [LAYOUT],
/* key= */ "col",
/* split= */ function(el, opts, ctx) {
var columnCount = opts.columns;
var result = Array2D(columnCount);
each(ctx[LAYOUT], function(cell, i) {
result[i % columnCount].push(cell);
});
return result;
}
);
var cellPlugin = createPlugin(
/* by= */ "cells",
/* depends= */ ['cellRows', 'cellColumns'],
/* key= */ "cell",
/* split= */ function(el, opt, ctx) {
// re-index the layout as the cells
return ctx[LAYOUT];
}
);
// install plugins
// word/char plugins
add(wordPlugin);
add(charPlugin);
add(linePlugin);
// grid plugins
add(itemPlugin);
add(rowPlugin);
add(columnPlugin);
add(gridPlugin);
// cell-layout plugins
add(layoutPlugin);
add(cellRowPlugin);
add(cellColumnPlugin);
add(cellPlugin);
return Splitting;
})));