@1studio/utils
Version:
A collection of useful utilities.
707 lines (592 loc) • 17.3 kB
text/typescript
// 20Kb -> 1
import reduce from 'lodash/reduce';
import isMatch from 'lodash/isMatch';
// 2Kb -> 0
import PropTypes, { checkPropTypes } from '../propType';
// 90Kb
import Storage, { IMMORTAL_SESSIONTIME } from './storage';
import capitalizeFirstLetter from '../string/capitalizeFirstLetter';
/* !- Constants */
const ActionsType = PropTypes.objectOf(
PropTypes.arrayOf(
PropTypes.func,
),
);
const ConfigType = PropTypes.shape({
application: PropTypes.shape({
id: PropTypes.string.isRequired,
password: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.string,
]).isRequired,
}).isRequired,
});
const StoreType = PropTypes.shape({
dispatch: PropTypes.func.isRequired,
getState: PropTypes.func.isRequired,
replaceReducer: PropTypes.func.isRequired,
subscribe: PropTypes.func.isRequired,
});
/**
* https://developer.mozilla.org/en-US/docs/Web/Events
*/
const AVAILABLE_LISTENERS = [
'orientation',
'keydown',
'click',
'scroll',
'wheel',
'onscroll',
'touchmove',
'mousewheel',
];
type shortcutType =
{
keyCode: string,
handler: () => void,
description?: string,
};
export default (() => {
const privateProps = new WeakMap();
privateProps.add = (key, object) => {
privateProps.set(
key,
{
...privateProps.get(key),
...object,
},
);
};
const getMedia = () => {
const node = document.createElement('DIV');
node.setAttribute('id', 'respond-to');
let media = window
.getComputedStyle(document.body.appendChild(node))
.getPropertyValue('content');
media = media.replace(/"/g, '');
document.body.removeChild(node);
return media;
}
/**
* Read all html document config: Language, baseUrl, documentId
* @type {Object} { lang, regionName, regionCode, baseDir, pathName, pageName, title }
* @private
*/
const getDocumentConfig = () => {
if (typeof document === 'undefined') {
return {};
}
let title = '';
if (document.getElementsByTagName('title')[0]) {
title = document.getElementsByTagName('title')[0].innerHTML;
}
/**
* ISO 639 is a standardized nomenclature used to classify languages.
* ISO 639-1: two-letter codes.
* @type {String}
* @public
* @default [en]
*/
const lang = document.documentElement.lang || 'en';
/**
* Region Code.
* Language identifiers as specified by RFC 3066.
* @type {String}
* @public
* @default [hu]
* @example
*
* lang + '-' + regionCode.toUpperCase();
* // => For example, en-US (English, U.S.)
*
* this.locale
* // => en-US
*/
const regionCode = document.documentElement.dataset.regioncode || 'hu';
/**
* Region Name.
* Language identifiers as specified by RFC 3066.
* @type {String}
* @public
* @default [Hungary]
*/
const regionName = document.documentElement.dataset.regionname || 'Hungary';
/**
* Specify a default URL and a default target for all links on a page
* @type {String}
* @public
* @default [/]
*/
let baseDir = '/';
if (document.getElementsByTagName('base')[0]) {
baseDir = document.getElementsByTagName('base')[0].getAttribute('href');
}
/**
* Cleaned location.pathname. Remove baseDir and last slash
* @type {String}
* @public
*/
let pathName = location.pathname.replace(new RegExp(`^${baseDir}(.*)$`), '$1');
if (pathName[pathName.length - 1] === '/') {
pathName = pathName.substr(0, [pathName.length - 1]);
}
if (location.hash) {
pathName += location.hash;
}
/**
* Current page Id: document.id or pathnam
* @type {String}
* @public
*/
const pageName = document.body.id || pathName;
/**
* CSS responsive media: desktop, mobile, tablet.
* @type {String}
* @public
*/
const media = getMedia();
return {
lang,
regionName,
regionCode,
baseDir,
pathName,
pageName,
title,
media,
};
};
/**
* Application model
* Handles configurations
* Dispatching attached methods based page ID or pathname
*
* @example
* // React-Redux application loader
*
* // 94Kb
* import Application from '/utils/models/application';
* import Contact from './contact';
* import Vip from './vip';
*
* const init = () =>
* request
* .get('/app.json')
* .then((response) =>
* {
* const config = response.body;
* const App = new Application({
* store: store(),
* config,
* actions: {
* '.*': [Vip],
* 'contact': [Contact],
* },
* });
*
* if (config.application.global || process.env.NODE_ENV !== 'production')
* {
* window.App = App;
* }
* });
*
* window.onload = init;
*
* @since 3.7.0
* @class
* @example
*/
class Application {
register = {}
listeners = []
inactiveListeners = []
/**
* @constructs
* @private
* @param {Object} data full data Object
* @param {Object} [settings]
*/
constructor(
// data: {},
settings: {
store?: {},
actions?: {},
config?: {},
} = {},
) {
if (
checkPropTypes(
settings,
PropTypes.shape({
store: StoreType,
actions: ActionsType,
config: ConfigType,
}),
)
) {
throw new Error('Application settings error.');
}
// Add useragent to html
// if (typeof window !== 'undefined')
// {
// window.document.documentElement.setAttribute('data-useragent', (window.navigator || {}).userAgent);
// }
// Initial privateProps type
privateProps.set(this, {});
// config
const config = settings.config || {};
const document = getDocumentConfig();
this.config = {
...config,
document,
};
this.locale = `${document.lang}-${document.regionCode?.toUpperCase()}`;
if (settings.actions) {
this.setActions(settings.actions);
}
if (settings.store) {
this.store = settings.store;
}
this.register = new Storage(
{},
{
key: config.application ? config.application.id : undefined,
password: config.application ? config.application.password : undefined,
sessionTime: IMMORTAL_SESSIONTIME,
},
);
this.vendors = [];
if (typeof window !== 'undefined') {
window.addEventListener('orientationchange', this.OrientationChangeListener);
}
this.dispatch();
}
/* !- Getter Setter */
/**
* Return config object.
* Config is protected in production mode.
* @return {Object}
*/
get config(): {} {
if (process.env.NODE_ENV === 'production') {
throw new Error('Config is protected.');
}
return privateProps.get(this).config;
}
/**
* Set config in private storage.
* If config is defined, you cannot set new object.
* @param {Object} config
* @example
* const Model = new Application();
*
* Model.config({ name: 'John', thumbnail: 'john.jpg' });
*
* // or
*
* const Model = new Application({config: { name: 'John'}});
*/
set config(config: {}): void {
if (privateProps.get(this).config) {
throw new Error('Config defined yet.');
}
privateProps.add(this, { config });
}
/**
* Get name of page
* @return {String} pageName
*/
getPageName(): string {
return privateProps.get(this).config.document.pageName;
}
/**
* Return CSS media screen type
* @return {string} [desktop|mobile|tablet]
*/
getMedia(): string {
return getMedia();
}
static getOrientation(): string {
return window.matchMedia('(orientation: portrait)').matches ? 'portrait' : 'landscape';
}
/**
* Return html Title content
* @return {string} title
*/
getPageTitle(): string {
return privateProps.get(this).config.document.title;
}
/**
* Change HTML document title
* @param {string} title
* @todo modify privateProps value
*/
static setPageTitle(title: string) {
document.getElementsByTagName('title')[0].innerHTML = title;
}
setDocumentTitle = (title) => {
Application.setPageTitle(title);
}
setDocumentConfig = (props = {}) => {
Object.keys(props).forEach((propName) => {
if (
typeof this[`setDocument${capitalizeFirstLetter(propName)}`] === 'function'
&& ['title'].indexOf(propName) !== -1
) {
this[`setDocumentTitle`](props[propName]);
}
})
}
/**
* Return config.project[module]
* @param {string} [module] index of config.project
* @return {object} [description]
*/
getProjectConfig(module?: string): {} {
if (!module) {
return privateProps.get(this).config.project;
}
return privateProps.get(this).config.project[module];
}
/**
* Add actions, every action will be invoke when dispatching.
* If action match with pageName, pushed method execute.
* @param {Object} actions
* @example
* const Model = new Application({ actions: ['.*': [() => 'all']]});
* Model.setActions({'^$': [() => 'home']})
*
* //=> merge two action array
*/
setActions(actions: {}) {
if (
checkPropTypes(
actions,
ActionsType.isRequired,
)
) {
throw new Error('Invalid action type');
}
const currentActions = privateProps.get(this).actions || {};
privateProps.add(this, {
actions: {
...currentActions,
...actions,
}
});
}
addListener = (event: string, handler: void, extra: {}) => {
if (AVAILABLE_LISTENERS.indexOf(event) === -1) {
throw new Error(`${event} not available listeners (${AVAILABLE_LISTENERS.join(', ')})`);
}
document.addEventListener(event, handler);
this.listeners.push({ event, handler, ...extra });
}
removeListener = (handler: void): boolean => {
const index = this.listeners.findIndex(listenter => listenter.handler === handler);
if (index !== -1) {
const { event, handler } = this.listeners[index]; // eslint-disable-line no-shadow
document.removeEventListener(event, handler);
this.listeners = this.listeners.slice(0, index).concat(this.listeners.slice(index + 1));
return true;
}
return false;
}
listenerTest = (listener: {}, event: string, extra = {}): boolean =>
listener.event === event && Object.keys(extra).every(i => extra[i] === listener[i]);
inactivateListeners = (event: string, extra: {}) => {
this.listeners.forEach((listener) => {
if (this.listenerTest(listener, event, extra)) {
if (this.removeListener(listener.handler)) {
if (
this.inactiveListeners.findIndex(inactiveListener =>
Object.keys(listener).every(key => inactiveListener[key] && inactiveListener[key].toString() === listener[key].toString())
) === -1) {
this.inactiveListeners.push(listener);
}
}
}
});
}
activateListeners = (event: string, extra: {}) => {
const listeners = this.inactiveListeners.filter(listener => this.listenerTest(listener, event, extra));
listeners.forEach((listener) => {
this.addListener(listener.event, listener.handler, listener.extra);
});
this.inactiveListeners = this.inactiveListeners.filter(listener => !this.listenerTest(listener, event, extra));
}
/**
* Compare the keyboard event and key
* @param {string} keyCode shortcut: "CTRL+S", "CTRL+SHIFT+S"
*/
isShortcut(keyCode: string, event: KeyboardEvent): boolean // eslint-disable-line
{
if (!event || typeof event.key === 'undefined') {
return false;
}
// shiftKey: true
// ctrlKey: true
// altKey: false
// metaKey: false
// key: "S"
return keyCode
.split('+')
.map(key => key.toLowerCase())
.every(key => event[`${key}Key`] === true || event.key.toLowerCase() === key);
}
/**
* @param {Array} shortcuts
* @exmaple
addShortCuts([
{
keyCode: "CTRL+S",
handler: (e) => console.log(e),
description: 'Save you project',
},
{
keyCode: 'CTRL+Z',
handler: (else) => console.log(e),
description: 'Undo',
},
], 'collection');
*/
addShortcuts = (shortcuts: Array<shortcutType>, collection?: string): void => {
shortcuts.forEach((shortcut) => {
this.addListener(
'keydown',
event => this.isShortcut(shortcut.keyCode, event) && shortcut.handler(event),
{
keyCode: shortcut.keyCode,
collection,
},
);
});
}
/**
* Remove keyCode or Collection from shortcut listeners
* @param {array|string} shortcutsOrCollection ['CTRL+S'] or 'collectionName'
*
* @example
* removeShortcuts('collectionName');
* removeShortcuts(['CTRL+S']);
*/
removeShortcuts = (shortcutsOrCollection: Array<string> | string): void => {
/**
* Affected shortcuts
* @type {Array}
* @example
* [{ collection: 'collectionName'}]
* or
* [{ keyCode: 'CTRL+S'},...]
*/
const shortcuts = Array.isArray(shortcutsOrCollection) ?
shortcutsOrCollection.map(shortcut => ({ keyCode: shortcut }))
: [{ collection: shortcutsOrCollection }];
/**
* find and remove listener which affected
*/
this.listeners
.filter(listener =>
listener.event === 'keydown'
&& shortcuts.some(shortcut => isMatch(listener, shortcut)),
)
.forEach(listener => this.removeListener(listener.handler));
}
loadVendor(url, callback) {
const regex = /\.(js|css)(\?.*)?$/;
const matches = regex.exec(url);
if (matches === null) {
return false;
}
const extension = matches[1];
const vendor = this.vendors.find(vendor => vendor.url === url);
if (vendor) {
if (typeof callback === 'function') {
if (vendor.status) {
callback()
}
else {
vendor.callback.push(callback);
}
}
}
else {
this.vendors.push({
url,
extension,
callback: [callback]
});
const vendorListener = (event) => {
if (event.type === 'load') {
const vendor = this.vendors.find(vendor => vendor.url === url);
vendor.status === true;
vendor.callback.forEach((cb) => {
if (typeof cb === 'function') {
cb();
}
});
vendor.callback = [];
}
}
let element;
switch (extension) {
case 'js':
{
element = document.createElement('script');
element.type = 'text/javascript';
element.async = false; // Load in order
element.src = url;
break;
}
case 'css':
{
element = document.createElement('link');
element.href = url;
element.rel = 'stylesheet';
break;
}
}
element.onreadystatechange = vendorListener;
// page has finished loading.
element.addEventListener('load', vendorListener);
element.addEventListener('error', vendorListener);
document.getElementsByTagName('body')[0].appendChild(element);
}
}
/* !- Listeners */
OrientationChangeListener = () => {
if (this.listeners.orientation) {
this.listeners.orientation.forEach(listener => listener(this.getOrientation()));
}
}
/* !- Public methods */
/**
* Dispatching attached methods based page ID or pathname.
* If action match with pageName, pushed method execute.
* @return {void}
*/
dispatch(): void {
this._getActions().map(method => method(this));
}
/* !- Private methods */
/**
* Inspect the pageName match with aciton key
* @private
* @return {Array} matched actions
*/
_getActions(): [] {
const pageName = this.getPageName();
return reduce(
privateProps.get(this).actions || {},
(results, actions, pattern) => {
if (pageName.match(new RegExp(pattern))) {
return [...results, ...actions];
}
return results;
},
[],
);
}
}
return Application;
})();