fepper-ui
Version:
The client-side package that powers the Fepper UI
436 lines (365 loc) • 13.4 kB
JavaScript
export default class UiFns {
// Private class fields.
#fepperUi;
#root;
/**
* UI Functions. Various and sundry utility functions.
*
* @param {object} fepperUi - The Fepper UI instance.
* @param {object} root - `global` or `window`.
*/
constructor(fepperUi, root) {
this.#fepperUi = fepperUi;
this.#root = root;
this.$orgs = fepperUi.requerio.$orgs;
this.uiData = fepperUi.uiData;
}
/* GETTERS for fepperUi instance props in case they are undefined at instantiation. */
get codeViewer() {
return this.#fepperUi.codeViewer;
}
get dataSaver() {
return this.#fepperUi.dataSaver;
}
get uiProps() {
return this.#fepperUi.uiProps;
}
get viewerHandler() {
return this.#fepperUi.viewerHandler;
}
/* METHODS */
/**
* Close all panels. Remove "active" class. No params.
*/
closeAllPanels() {
this.$orgs['#sg-nav-target'].dispatchAction('removeClass', 'active');
this.$orgs['.sg-acc-handle'].dispatchAction('removeClass', 'active');
this.$orgs['.sg-acc-panel'].dispatchAction('removeClass', 'active');
this.$orgs['.sg-size'].dispatchAction('removeClass', 'active');
}
/**
* Close all but the targeted panel. Remove "active" class.
*
* @param {*} el - Any valid jQuery/Cheerio selector.
*/
closeOtherPanels(el) {
const $el = this.#root.$(el);
const $panel = $el.next('.sg-acc-panel');
const $panelGrandParent = $el.parent().parent();
// Close other panels if link isn't a subnavigation item.
if (!$panelGrandParent.hasClass('sg-acc-panel')) {
this.$orgs['.sg-acc-handle'].exclude($el).dispatchAction('removeClass', 'active');
this.$orgs['.sg-acc-panel'].exclude($panel).dispatchAction('removeClass', 'active');
// Not a nav item.
if (!$panelGrandParent.hasClass('sg-nav')) {
this.$orgs['#sg-nav-target'].dispatchAction('removeClass', 'active');
}
// Not a size label.
if (!$el.hasClass('sg-size-label')) {
this.$orgs['.sg-size'].dispatchAction('removeClass', 'active');
}
}
}
/**
* Debounce user actions that might otherwise trigger too many redundant events.
*
* @param {function} callback - The function that needs debouncing.
* @param {number} wait - The debounce period in milliseconds.
* @param {object} thisArg - What will be passed as the `this` keyword by the .apply() method.
* @returns {function} The callback set to only run if does not get called back again within the debounce period.
*/
debounce(callback, wait = this.uiProps.timeoutDefault, thisArg = null) {
let timeoutId = null;
let callbackArgs = null;
const later = () => callback.apply(thisArg, callbackArgs);
return function () {
callbackArgs = arguments;
clearTimeout(timeoutId);
timeoutId = setTimeout(later, wait);
};
}
/**
* Take breakpoint configurations from customizable sources and return a sorted object of key-value pairs.
*
* @returns {object} Breakpoints sorted from largest to smallest.
*/
getBreakpointsSorted() {
// Get breakpoint customations made to variables.styl.
const bpObj = {};
const bpObjTmp = {};
const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER;
// Iterate through `window` or `global` to check for properties set by variables.styl.
// If found, populate the tmp object for sorting.
// Replace -1 (or any negative value) with MAX_SAFE_INTEGER.
for (const globalVar in this.#root) {
if (globalVar.indexOf('bp_') === 0 && globalVar.indexOf('_max') === globalVar.length - 4) {
if (this.#root[globalVar] < 0) {
bpObjTmp[globalVar.slice(3, globalVar.length - 4)] = MAX_SAFE_INTEGER;
}
else {
bpObjTmp[globalVar.slice(3, globalVar.length - 4)] = this.#root[globalVar];
}
}
}
// Populate sorting array.
const bpArr = Object.values(bpObjTmp);
// Sort array from largest to smallest.
bpArr.sort((a, b) => b - a);
// Set gap to the distance between the 2nd and 3rd largest maxWidth breakpoints.
// This will be added to the minWidth of the largest.
// The sum will be the width rendered by the button for the largest.
let gap = 0;
if (bpArr[1] && bpArr[2]) {
// Subtract 1 so that the default configs render the lg viewport at 1280px wide, a common screen width.
// This variance of 1px is necessary so the sm maxWidth can be 767px and the md minWidth can be 768px.
// The md range of 768px - 1024px accommodates many devices, and can be styled within a single media query.
gap += bpArr[1] - bpArr[2] - 1;
}
let gapAdded = false;
// Construct bpObj with sorted breakpoints.
for (const bp of bpArr) {
const indexOfBp = Object.values(bpObjTmp).indexOf(bp);
if (indexOfBp > -1) {
if (gap && !gapAdded) {
bpObj[Object.keys(bpObjTmp)[indexOfBp]] = bpArr[1] + gap;
gapAdded = true;
}
else {
bpObj[Object.keys(bpObjTmp)[indexOfBp]] = bp;
}
}
}
return bpObj;
}
/**
* Get an unknown patternPartial from a known URL.
*
* @param {string} url - The known URL.
* @returns {string|undefined} The patternPartial or undefined.
*/
getPatternPartialFromUrl(url) {
for (const key of Object.keys(this.uiData.patternPaths)) {
if (url.endsWith(this.uiData.patternPaths[key])) {
return key;
}
}
}
/**
* Return a random number between min and max.
*
* @param {number} min - Start of range.
* @param {number} max - End of range.
* @returns {number} Random number.
*/
getRandom(min, max) {
return Math.floor(Math.random() * (max - min) + min);
}
/**
* Boilerplate for receiveIframeMessage functions.
*
* @param {object} event - Event object.
* @returns {object|undefined} Event data.
*/
receiveIframeMessageBoilerplate(event) {
// Does the origin sending the message match the current host? If not, dev/null the request.
if (
this.#root.location.protocol !== 'file:' &&
event.origin !== this.#root.location.protocol + '//' + this.#root.location.host
) {
return;
}
let data = {};
try {
data = (typeof event.data === 'string') ? JSON.parse(event.data) : event.data;
}
catch (err) {
// Fail gracefully.
}
return data;
}
/**
* Resize the iframe.
*
* @param {number} size - The target size of the iframe.
* @param {[boolean]} animate - For switching the CSS animation on or off.
* @param {[boolean]} wholeMode - In wholeMode, the iframe will dynamically resize when #sg-rightpull is dragged.
* @param {[boolean]} halfMode - Like wholeMode, but half. wholeMode has priority in the case of conflict.
*/
sizeIframe(size_, animate = true, wholeMode = false, halfMode_) {
let halfMode;
if (typeof halfMode_ === 'undefined') {
const halfModeCookie = this.dataSaver.findValue('halfMode');
if (typeof halfModeCookie === 'string') {
halfMode = halfModeCookie === 'true' ? true : false;
}
}
else {
halfMode = halfMode_;
}
// Conditionally remove CSS animation class from viewport.
if (animate === false) {
this.$orgs['#sg-gen-container'].dispatchAction('removeClass', 'vp-animate');
this.$orgs['#sg-viewport'].dispatchAction('removeClass', 'vp-animate');
}
else {
this.$orgs['#sg-gen-container'].dispatchAction('addClass', 'vp-animate');
this.$orgs['#sg-viewport'].dispatchAction('addClass', 'vp-animate');
}
const size = this.updateViewportWidth(Math.floor(size_));
const widthHalf = Math.floor(this.uiProps.sw / 2);
this.uiProps.wholeMode = wholeMode;
this.dataSaver.updateValue('wholeMode', this.uiProps.wholeMode);
if (!wholeMode) {
this.uiProps.halfMode = halfMode;
this.dataSaver.updateValue('halfMode', this.uiProps.halfMode);
}
/* istanbul ignore if */
if (!size) {
return;
}
// If the submitted iframe viewport is larger than half the browser viewport, and the dock is positioned left or
// right, reposition the dock to the bottom.
if (this.uiProps.dockPosition === 'left' || this.uiProps.dockPosition === 'right') {
if ((size + this.uiProps.sgRightpullWidth) > widthHalf) {
this.uiProps.halfMode = false;
this.dataSaver.updateValue('halfMode', this.uiProps.halfMode);
this.viewerHandler.dockBottom();
}
}
}
startDisco() {
this.uiProps.discoMode = true;
this.stopGrow();
this.$orgs['#sg-size-disco'].dispatchAction('focus');
this.sizeIframe(this.getRandom(this.uiProps.minViewportWidth, this.uiProps.sw));
this.uiProps.discoId = setInterval(() => {
this.sizeIframe(this.getRandom(this.uiProps.minViewportWidth, this.uiProps.sw));
}, 1000);
}
startGrow() {
let viewportWidth = this.uiProps.minViewportWidth;
this.uiProps.growMode = true;
this.stopDisco();
this.$orgs['#sg-gen-container'].dispatchAction('removeClass', 'vp-animate');
this.$orgs['#sg-viewport'].dispatchAction('removeClass', 'vp-animate');
this.$orgs['#sg-size-grow'].dispatchAction('focus');
this.sizeIframe(viewportWidth, false);
this.uiProps.growId = setInterval(() => {
if (viewportWidth < this.uiProps.sw) {
viewportWidth++;
this.sizeIframe(viewportWidth, false);
}
else {
this.stopGrow();
}
}, 20);
}
stopDisco() {
this.uiProps.discoMode = false;
this.uiProps.discoId = clearInterval(this.uiProps.discoId);
this.$orgs['#sg-size-disco'].dispatchAction('blur');
}
stopGrow() {
this.uiProps.growMode = false;
this.uiProps.growId = clearInterval(this.uiProps.growId);
this.$orgs['#sg-size-grow'].dispatchAction('blur');
}
toggleDisco() {
if (!this.uiProps.discoMode) {
this.startDisco();
}
else {
this.stopDisco();
}
}
toggleGrow() {
if (!this.uiProps.growMode) {
this.startGrow();
}
else {
this.stopGrow();
}
}
/**
* Update the URL path location of the iframe.
*
* @param {object} messageObj - The data being messaged to the iframe.
* @param {string} patternPartial - The pattern to update to.
*/
updatePath(messageObj, patternPartial) {
this.codeViewer.setPanelContent('feplet', patternPartial);
this.codeViewer.setPanelContent('markdown', patternPartial);
this.$orgs['#sg-viewport'][0].contentWindow.postMessage(messageObj, this.uiProps.targetOrigin);
}
/**
* Update the document title and "Open in new window" href per pattern.
*
* @param {string} patternPartial - The shorthand partials syntax for a given pattern.
* @param {string} path - The URL path to the pattern.
*/
updatePatternInfo(patternPartial, path) {
const titleSplit = this.#root.document.title.split(this.uiProps.titleSeparator);
this.#root.document.title = titleSplit[0] + this.uiProps.titleSeparator + patternPartial;
this.$orgs['#sg-raw'].dispatchAction('attr', {href: path});
}
/**
* Update Pixel and Em inputs.
*
* @param {number} size - The input number.
* @param {[string]} unit - The type of unit: either px or em. Default is px. Accepted values are "px" and "em".
* @param {[string]} target - What input to update.
*/
updateSizeReading(size, unit, target) {
const bodyFontSize = this.uiProps.bodyFontSize;
this.uiProps.vpWidth = size;
this.dataSaver.updateValue('vpWidth', size); // Save current viewport to cookie.
let emSize;
let pxSize;
if (unit === 'em') { // If size value is in em units.
emSize = size.toFixed(2);
pxSize = Math.round(size * bodyFontSize);
}
else { // If value is px or absent.
emSize = (size / bodyFontSize).toFixed(2);
pxSize = size;
}
// Empty or incorrect targets will dispatch action on both.
if (target !== 'updateEmInput' && pxSize) {
this.$orgs['#sg-size-px'].dispatchAction('val', pxSize.toString());
}
if (target !== 'updatePxInput' && emSize) {
this.$orgs['#sg-size-em'].dispatchAction('val', emSize.toString());
}
}
/**
* Update iframe width. With fewer bells and whistles than sizeIframe().
*
* @param {number} size - The size in px.
* @returns {number} The size as constrained by minViewportWidth and maxViewportWidth.
*/
updateViewportWidth(size_) {
/* istanbul ignore if */
if (!size_ || typeof size_ !== 'number' || Number.isNaN(size_)) {
return;
}
const maxViewportWidth = this.uiProps.maxViewportWidth;
const minViewportWidth = this.uiProps.minViewportWidth;
let size;
// If the entered size is larger than the max allowed viewport size, cap value at max vp size.
if (size_ > maxViewportWidth) {
size = maxViewportWidth;
}
// If the entered size is less than the minimum allowed viewport size, cap value at min vp size.
else if (size_ < minViewportWidth) {
size = minViewportWidth;
}
else {
size = size_;
}
this.$orgs['#sg-gen-container']
.dispatchAction('css', {width: (size + this.uiProps.sgRightpullWidth) + 'px'});
this.$orgs['#sg-viewport'].dispatchAction('css', {width: size + 'px'});
this.updateSizeReading(size);
return size;
}
}