369 lines
12 KiB
JavaScript
369 lines
12 KiB
JavaScript
/*eslint no-console: "off"*/
|
|
/*
|
|
Copyright (c) 2017-2021 Rik Schennink - All rights reserved.
|
|
|
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
|
and associated documentation files (the "Software"), to deal in the Software without restriction,
|
|
including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
|
|
subject to the following conditions:
|
|
|
|
The above copyright notice and this permission notice shall be included in all copies
|
|
or substantial portions of the Software.
|
|
|
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
|
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
|
PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
|
|
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
|
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
*/
|
|
export default ((w) => {
|
|
// no window, early exit
|
|
if (!w) { return; }
|
|
|
|
// node list to array helper method
|
|
const toArray = (nl) => [].slice.call(nl);
|
|
|
|
// states
|
|
const DrawState = {
|
|
IDLE: 0,
|
|
DIRTY_CONTENT: 1,
|
|
DIRTY_LAYOUT: 2,
|
|
DIRTY: 3,
|
|
};
|
|
|
|
// all active fitty elements
|
|
let fitties = [];
|
|
|
|
// group all redraw calls till next frame, we cancel each frame request when a new one comes in.
|
|
// If no support for request animation frame, this is an empty function and supports for fitty stops.
|
|
let redrawFrame = null;
|
|
const requestRedraw =
|
|
'requestAnimationFrame' in w
|
|
? (options = { sync: false }) => {
|
|
w.cancelAnimationFrame(redrawFrame);
|
|
|
|
const redrawFn = () => redraw(fitties.filter((f) => f.dirty && f.active));
|
|
|
|
if (options.sync) { return redrawFn(); }
|
|
|
|
redrawFrame = w.requestAnimationFrame(redrawFn);
|
|
}
|
|
: () => {};
|
|
|
|
// sets all fitties to dirty so they are redrawn on the next redraw loop, then calls redraw
|
|
const redrawAll = (type) => (options) => {
|
|
fitties.forEach((f) => (f.dirty = type));
|
|
requestRedraw(options);
|
|
};
|
|
|
|
// redraws fitties so they nicely fit their parent container
|
|
const redraw = (fitties) => {
|
|
// getting info from the DOM at this point should not trigger a reflow,
|
|
// let's gather as much intel as possible before triggering a reflow
|
|
|
|
// check if styles of all fitties have been computed
|
|
fitties
|
|
.filter((f) => !f.styleComputed)
|
|
.forEach((f) => {
|
|
f.styleComputed = computeStyle(f);
|
|
});
|
|
|
|
// restyle elements that require pre-styling, this triggers a reflow, please try to prevent by adding CSS rules (see docs)
|
|
fitties.filter(shouldPreStyle).forEach(applyStyle);
|
|
|
|
// we now determine which fitties should be redrawn
|
|
const fittiesToRedraw = fitties.filter(shouldRedraw);
|
|
|
|
// we calculate final styles for these fitties
|
|
fittiesToRedraw.forEach(calculateStyles);
|
|
|
|
// now we apply the calculated styles from our previous loop
|
|
fittiesToRedraw.forEach((f) => {
|
|
applyStyle(f);
|
|
markAsClean(f);
|
|
});
|
|
|
|
// now we dispatch events for all restyled fitties
|
|
fittiesToRedraw.forEach(dispatchFitEvent);
|
|
};
|
|
|
|
const markAsClean = (f) => (f.dirty = DrawState.IDLE);
|
|
|
|
const calculateStyles = (f) => {
|
|
if (f.vertical) {
|
|
// get available width from parent node
|
|
f.availableHeight = f.element.parentNode.clientHeight;
|
|
|
|
// the space our target element uses
|
|
f.currentHeight = f.element.scrollHeight;
|
|
|
|
// remember current font size
|
|
f.previousFontSize = f.currentFontSize;
|
|
|
|
// let's calculate the new font size
|
|
f.currentFontSize = Math.min(
|
|
Math.max(f.minSize, (f.availableHeight / f.currentHeight) * f.previousFontSize),
|
|
f.maxSize
|
|
);
|
|
|
|
} else {
|
|
// get available width from parent node
|
|
f.availableWidth = f.element.parentNode.clientWidth;
|
|
|
|
// the space our target element uses
|
|
f.currentWidth = f.element.scrollWidth;
|
|
|
|
// remember current font size
|
|
f.previousFontSize = f.currentFontSize;
|
|
|
|
// let's calculate the new font size
|
|
f.currentFontSize = Math.min(
|
|
Math.max(f.minSize, (f.availableWidth / f.currentWidth) * f.previousFontSize),
|
|
f.maxSize
|
|
);
|
|
}
|
|
// if allows wrapping, only wrap when at minimum font size (otherwise would break container)
|
|
f.whiteSpace = f.multiLine && f.currentFontSize === f.minSize ? 'normal' : 'nowrap';
|
|
};
|
|
|
|
// should always redraw if is not dirty layout, if is dirty layout, only redraw if size has changed
|
|
const shouldRedraw = (f) => {
|
|
if (f.vertical) {
|
|
return f.dirty !== DrawState.DIRTY_LAYOUT ||
|
|
(f.dirty === DrawState.DIRTY_LAYOUT &&
|
|
f.element.parentNode.clientHeight !== f.availableHeight);
|
|
} else {
|
|
return f.dirty !== DrawState.DIRTY_LAYOUT ||
|
|
(f.dirty === DrawState.DIRTY_LAYOUT &&
|
|
f.element.parentNode.clientWidth !== f.availableWidth);
|
|
}
|
|
};
|
|
// every fitty element is tested for invalid styles
|
|
const computeStyle = (f) => {
|
|
// get style properties
|
|
const style = w.getComputedStyle(f.element, null);
|
|
|
|
// get current font size in pixels (if we already calculated it, use the calculated version)
|
|
f.currentFontSize = parseFloat(style.getPropertyValue('font-size'));
|
|
|
|
// get display type and wrap mode
|
|
f.display = style.getPropertyValue('display');
|
|
f.whiteSpace = style.getPropertyValue('white-space');
|
|
|
|
// styles computed
|
|
return true;
|
|
};
|
|
|
|
// determines if this fitty requires initial styling, can be prevented by applying correct styles through CSS
|
|
const shouldPreStyle = (f) => {
|
|
let preStyle = false;
|
|
|
|
// if we already tested for prestyling we don't have to do it again
|
|
if (f.preStyleTestCompleted) { return false; }
|
|
|
|
// should have an inline style, if not, apply
|
|
if (!/inline-/.test(f.display)) {
|
|
preStyle = true;
|
|
f.display = 'inline-block';
|
|
}
|
|
|
|
// to correctly calculate dimensions the element should have whiteSpace set to nowrap
|
|
if (f.whiteSpace !== 'nowrap') {
|
|
preStyle = true;
|
|
f.whiteSpace = 'nowrap';
|
|
}
|
|
|
|
// we don't have to do this twice
|
|
f.preStyleTestCompleted = true;
|
|
|
|
return preStyle;
|
|
};
|
|
|
|
// apply styles to single fitty
|
|
const applyStyle = (f) => {
|
|
f.element.style.whiteSpace = f.whiteSpace;
|
|
f.element.style.display = f.display;
|
|
f.element.style.fontSize = f.currentFontSize + 'px';
|
|
};
|
|
|
|
// dispatch a fit event on a fitty
|
|
const dispatchFitEvent = (f) => {
|
|
f.element.dispatchEvent(
|
|
new CustomEvent('fit', {
|
|
detail: {
|
|
oldValue: f.previousFontSize,
|
|
newValue: f.currentFontSize,
|
|
scaleFactor: f.currentFontSize / f.previousFontSize,
|
|
},
|
|
})
|
|
);
|
|
};
|
|
|
|
// fit method, marks the fitty as dirty and requests a redraw (this will also redraw any other fitty marked as dirty)
|
|
const fit = (f, type) => (options) => {
|
|
f.dirty = type;
|
|
if (!f.active) { return; }
|
|
requestRedraw(options);
|
|
};
|
|
|
|
const init = (f) => {
|
|
// save some of the original CSS properties before we change them
|
|
f.originalStyle = {
|
|
whiteSpace: f.element.style.whiteSpace,
|
|
display: f.element.style.display,
|
|
fontSize: f.element.style.fontSize,
|
|
};
|
|
|
|
// should we observe DOM mutations
|
|
observeMutations(f);
|
|
|
|
// this is a new fitty so we need to validate if it's styles are in order
|
|
f.newbie = true;
|
|
|
|
// because it's a new fitty it should also be dirty, we want it to redraw on the first loop
|
|
f.dirty = true;
|
|
|
|
// we want to be able to update this fitty
|
|
fitties.push(f);
|
|
};
|
|
|
|
const destroy = (f) => () => {
|
|
// remove from fitties array
|
|
fitties = fitties.filter((_) => _.element !== f.element);
|
|
|
|
// stop observing DOM
|
|
if (f.observeMutations) { f.observer.disconnect(); }
|
|
|
|
// reset the CSS properties we changes
|
|
f.element.style.whiteSpace = f.originalStyle.whiteSpace;
|
|
f.element.style.display = f.originalStyle.display;
|
|
f.element.style.fontSize = f.originalStyle.fontSize;
|
|
};
|
|
|
|
// add a new fitty, does not redraw said fitty
|
|
const subscribe = (f) => () => {
|
|
if (f.active) { return; }
|
|
f.active = true;
|
|
requestRedraw();
|
|
};
|
|
|
|
// remove an existing fitty
|
|
const unsubscribe = (f) => () => (f.active = false);
|
|
|
|
const observeMutations = (f) => {
|
|
// no observing?
|
|
if (!f.observeMutations) { return; }
|
|
|
|
// start observing mutations
|
|
f.observer = new MutationObserver(fit(f, DrawState.DIRTY_CONTENT));
|
|
|
|
// start observing
|
|
f.observer.observe(f.element, f.observeMutations);
|
|
};
|
|
|
|
// default mutation observer settings
|
|
const mutationObserverDefaultSetting = {
|
|
subtree: true,
|
|
childList: true,
|
|
characterData: true,
|
|
};
|
|
|
|
// default fitty options
|
|
const defaultOptions = {
|
|
minSize: 16,
|
|
maxSize: 512,
|
|
multiLine: true,
|
|
vertical: false,
|
|
observeMutations: 'MutationObserver' in w ? mutationObserverDefaultSetting : false,
|
|
};
|
|
|
|
/**
|
|
* array of elements in, fitty instances out
|
|
* @param {Array} elements
|
|
* @param {object} options
|
|
*/
|
|
function fittyCreate(elements, options) {
|
|
// set options object
|
|
const fittyOptions = Object.assign(
|
|
{},
|
|
|
|
// expand default options
|
|
defaultOptions,
|
|
|
|
// override with custom options
|
|
options
|
|
);
|
|
|
|
// create fitties
|
|
const publicFitties = elements.map((element) => {
|
|
// create fitty instance
|
|
const f = Object.assign({}, fittyOptions, {
|
|
// internal options for this fitty
|
|
element,
|
|
active: true,
|
|
});
|
|
|
|
// initialise this fitty
|
|
init(f);
|
|
|
|
// expose API
|
|
return {
|
|
element,
|
|
fit: fit(f, DrawState.DIRTY),
|
|
unfreeze: subscribe(f),
|
|
freeze: unsubscribe(f),
|
|
unsubscribe: destroy(f),
|
|
};
|
|
});
|
|
|
|
// call redraw on newly initiated fitties
|
|
requestRedraw();
|
|
|
|
// expose fitties
|
|
return publicFitties;
|
|
}
|
|
|
|
/**
|
|
* fitty creation function
|
|
* @param {*} target
|
|
* @param {*} options
|
|
* @returns
|
|
*/
|
|
function fitty(target, options = {}) {
|
|
// if target is a string
|
|
return typeof target === 'string'
|
|
? // treat it as a querySelector
|
|
fittyCreate(toArray(document.querySelectorAll(target)), options)
|
|
: // create single fitty
|
|
fittyCreate([target], options)[0];
|
|
}
|
|
|
|
// handles viewport changes, redraws all fitties, but only does so after a timeout
|
|
let resizeDebounce = null;
|
|
const onWindowResized = () => {
|
|
w.clearTimeout(resizeDebounce);
|
|
resizeDebounce = w.setTimeout(redrawAll(DrawState.DIRTY_LAYOUT), fitty.observeWindowDelay);
|
|
};
|
|
|
|
// define observe window property, so when we set it to true or false events are automatically added and removed
|
|
const events = ['resize', 'orientationchange'];
|
|
Object.defineProperty(fitty, 'observeWindow', {
|
|
set: (enabled) => {
|
|
const method = `${enabled ? 'add' : 'remove'}EventListener`;
|
|
events.forEach((e) => {
|
|
w[method](e, onWindowResized);
|
|
});
|
|
},
|
|
});
|
|
|
|
// fitty global properties (by setting observeWindow to true the events above get added)
|
|
fitty.observeWindow = true;
|
|
fitty.observeWindowDelay = 100;
|
|
|
|
// public fit all method, will force redraw no matter what
|
|
fitty.fitAll = redrawAll(DrawState.DIRTY);
|
|
|
|
// export our fitty function, we don't want to keep it to our selves
|
|
return fitty;
|
|
})(typeof window === 'undefined' ? null : window);
|