moodle_local_treestudyplan/amd/src/util/fitty.js

370 lines
12 KiB
JavaScript
Raw Normal View History

2024-02-23 09:20:10 +01:00
/*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);