fullcalendar
Version:
FullCalendar Vanilla JS package for rendering a calendar
1,190 lines (1,169 loc) • 51.4 kB
JavaScript
import { I as isPropsEqualShallow, H as isArraysEqual, K as isPropsEqualWithMap, g as guid, e as createFormatter, A as formatWithOrdinals, L as createAriaClickAttrs, J as computeViewBorderless, u as EventImpl, M as setElEventRange, N as buildEventRangeTimeText, O as getEventTagAndAttrs, P as computeEventRangeDraggable } from './2add5508.js';
import { createContext, Component, createElement, isValidElement, flushSync } from 'preact/compat';
import { b as isNonHandlerPropsEqual } from './f0fa24ec.js';
import { j as joinClassNames } from './69261bb4.js';
import { createDuration, startOfDay, addDays, rangeContainsMarker, joinDateTimeFormatParts } from '@full-ui/headless-calendar';
import { c as classNames } from './4a45af02.js';
import { jsx, jsxs, Fragment } from 'preact/jsx-runtime';
import { c as computeElIsRtl, E as Emitter } from './56f74c4a.js';
function memoize(workerFunc, resEquality, teardownFunc) {
let currentArgs;
let currentRes;
return function (...newArgs) {
if (!currentArgs) {
currentRes = workerFunc.apply(this, newArgs);
}
else if (!isArraysEqual(currentArgs, newArgs)) {
if (teardownFunc) {
teardownFunc(currentRes);
}
let res = workerFunc.apply(this, newArgs);
if (!resEquality || !resEquality(res, currentRes)) {
currentRes = res;
}
}
currentArgs = newArgs;
return currentRes;
};
}
function memoizeObjArg(workerFunc, resEquality, teardownFunc) {
let currentArg;
let currentRes;
return (newArg) => {
if (!currentArg) {
currentRes = workerFunc.call(this, newArg);
}
else if (!isPropsEqualShallow(currentArg, newArg)) {
if (teardownFunc) {
teardownFunc(currentRes);
}
let res = workerFunc.call(this, newArg);
if (!resEquality || !resEquality(res, currentRes)) {
currentRes = res;
}
}
currentArg = newArg;
return currentRes;
};
}
const ViewContextType = createContext({}); // for Components
function buildViewContext(viewSpec, viewApi, viewOptions, dateProfileGenerator, dateEnv, nowManager, pluginHooks, dispatch, getCurrentData, emitter, calendarApi, baseId, registerInteractiveComponent, unregisterInteractiveComponent) {
return {
dateEnv,
nowManager,
options: viewOptions,
pluginHooks,
emitter,
dispatch,
getCurrentData,
calendarApi,
viewSpec,
viewApi,
dateProfileGenerator,
baseId,
registerInteractiveComponent,
unregisterInteractiveComponent,
};
}
/* eslint max-classes-per-file: off */
class PureComponent extends Component {
// debug: boolean
shouldComponentUpdate(nextProps, nextState) {
return !isPropsEqualWithMap(this.props, nextProps, this.propEquality /*, this.debug && 'props' */) ||
!isPropsEqualWithMap(this.state, nextState, this.stateEquality /*, this.debug && 'state' */);
}
}
PureComponent.addPropsEquality = addPropsEquality;
PureComponent.addStateEquality = addStateEquality;
PureComponent.contextType = ViewContextType;
PureComponent.prototype.propEquality = {};
PureComponent.prototype.stateEquality = {};
class BaseComponent extends PureComponent {
}
BaseComponent.contextType = ViewContextType;
function addPropsEquality(propEquality) {
let hash = Object.create(this.prototype.propEquality);
Object.assign(hash, propEquality);
this.prototype.propEquality = hash;
}
function addStateEquality(stateEquality) {
let hash = Object.create(this.prototype.stateEquality);
Object.assign(hash, stateEquality);
this.prototype.stateEquality = hash;
}
// use other one
function setRef(ref, current) {
if (typeof ref === 'function') {
ref(current);
}
else if (ref) {
// see https://github.com/facebook/react/issues/13029
ref.current = current;
}
}
class ContentInjector extends BaseComponent {
constructor() {
super(...arguments);
this.id = guid();
this.queuedDomNodes = [];
this.currentDomNodes = [];
this.handleEl = (el) => {
this.el = el;
if (this.props.elRef) {
setRef(this.props.elRef, el);
}
};
}
render() {
const { props, context } = this;
const { options } = context;
const { customGenerator, defaultGenerator, renderProps } = props;
const attrs = buildElAttrs(props, '', this.handleEl);
let useDefault = false;
let innerContent;
let queuedDomNodes = [];
let currentGeneratorMeta;
if (customGenerator != null) {
const customGeneratorRes = typeof customGenerator === 'function' ?
customGenerator(renderProps) :
customGenerator;
if (customGeneratorRes === true) {
useDefault = true;
// NOTE: see how mergeContentInjectors also uses `true` to signal useDefault
}
else {
const isObject = customGeneratorRes && typeof customGeneratorRes === 'object'; // non-null
if (isObject && ('html' in customGeneratorRes)) {
attrs.dangerouslySetInnerHTML = { __html: customGeneratorRes.html };
}
else if (isObject && ('domNodes' in customGeneratorRes)) {
queuedDomNodes = Array.prototype.slice.call(customGeneratorRes.domNodes);
}
else if (isObject
? isValidElement(customGeneratorRes) // vdom node
: typeof customGeneratorRes !== 'function' // primitive value (like string or number)
) {
// use in vdom
innerContent = customGeneratorRes;
}
else {
// an exotic object for handleCustomRendering
currentGeneratorMeta = customGeneratorRes;
}
}
}
else {
useDefault = !hasCustomRenderingHandler(props.generatorName, options);
}
if (useDefault && defaultGenerator) {
innerContent = defaultGenerator(renderProps);
}
this.queuedDomNodes = queuedDomNodes;
this.currentGeneratorMeta = currentGeneratorMeta;
return createElement(props.tag, attrs, innerContent);
}
componentDidMount() {
this.applyQueueudDomNodes();
this.triggerCustomRendering(true);
}
componentDidUpdate() {
this.applyQueueudDomNodes();
this.triggerCustomRendering(true);
}
componentWillUnmount() {
this.triggerCustomRendering(false); // TODO: different API for removal?
}
triggerCustomRendering(isActive) {
const { props, context } = this;
const { handleCustomRendering, customRenderingMetaMap } = context.options;
if (handleCustomRendering) {
const generatorMeta = this.currentGeneratorMeta ??
customRenderingMetaMap?.[props.generatorName];
if (generatorMeta) {
handleCustomRendering({
id: this.id,
isActive,
containerEl: this.el,
generatorMeta,
renderProps: props.renderProps,
});
}
}
}
applyQueueudDomNodes() {
const { queuedDomNodes, currentDomNodes } = this;
const { el } = this;
if (!isArraysEqual(queuedDomNodes, currentDomNodes)) {
for (const domNode of currentDomNodes) {
domNode.remove();
}
for (let newNode of queuedDomNodes) {
el.appendChild(newNode);
}
this.currentDomNodes = queuedDomNodes;
}
}
}
ContentInjector.addPropsEquality({
renderProps: isPropsEqualShallow,
attrs: isNonHandlerPropsEqual,
style: isPropsEqualShallow,
});
// Util
/*
Does UI-framework provide custom way of rendering that does not use Preact VDOM
AND does the calendar's options define custom rendering?
AKA. Should we NOT render the default content?
*/
function hasCustomRenderingHandler(generatorName, options) {
return Boolean(options.handleCustomRendering &&
generatorName &&
options.customRenderingMetaMap?.[generatorName]);
}
function buildElAttrs(props, className, elRef) {
const attrs = { ...props.attrs, ref: elRef };
if (props.className || className) {
attrs.className = joinClassNames(className, props.className, attrs.className);
}
if (props.style) {
attrs.style = props.style;
}
return attrs;
}
const RenderId = createContext(0);
class ContentContainer extends Component {
constructor() {
super(...arguments);
this.InnerContent = InnerContentInjector.bind(undefined, this);
this.handleEl = (el) => {
this.el = el;
if (this.props.elRef) {
setRef(this.props.elRef, el);
if (el && this.didMountMisfire) {
this.componentDidMount();
}
}
};
}
render() {
const { props } = this;
const generatedClassName = generateClassName(props.classNameGenerator, props.renderProps);
if (props.children) {
const attrs = buildElAttrs(props, generatedClassName, this.handleEl);
const children = props.children(this.InnerContent, props.renderProps, attrs);
if (props.tag) {
return createElement(props.tag, attrs, children);
}
else {
return children;
}
}
else {
return createElement((ContentInjector), {
...props,
elRef: this.handleEl,
tag: props.tag || 'div',
className: joinClassNames(props.className, generatedClassName),
renderId: this.context,
});
}
}
componentDidMount() {
if (this.el) {
this.props.didMount?.({
...this.props.renderProps,
el: this.el,
});
}
else {
this.didMountMisfire = true;
}
}
componentWillUnmount() {
this.props.willUnmount?.({
...this.props.renderProps,
el: this.el,
});
}
}
ContentContainer.contextType = RenderId;
function InnerContentInjector(containerComponent, props) {
const parentProps = containerComponent.props;
return createElement((ContentInjector), {
renderProps: parentProps.renderProps,
generatorName: parentProps.generatorName,
customGenerator: parentProps.customGenerator,
defaultGenerator: parentProps.defaultGenerator,
renderId: containerComponent.context,
...props,
});
}
// Utils
function generateClassName(classNameGenerator, renderProps) {
return (typeof classNameGenerator === 'function' ?
classNameGenerator(renderProps) :
classNameGenerator) || ''; // handles undefined
}
function renderText(renderProps) {
return renderProps.text;
}
function getIsHeightAuto(options) {
return options.height === 'auto' || options.contentHeight === 'auto';
}
function getTableHeaderSticky(options) {
let { tableHeaderSticky } = options;
if (tableHeaderSticky == null || tableHeaderSticky === 'auto') {
tableHeaderSticky = getIsHeightAuto(options);
}
return tableHeaderSticky;
}
function getFooterScrollbarSticky(options) {
const isHeightAuto = getIsHeightAuto(options);
let { footerScrollbarSticky } = options;
if (footerScrollbarSticky == null || footerScrollbarSticky === 'auto') {
footerScrollbarSticky = isHeightAuto;
}
return Boolean(footerScrollbarSticky) && isHeightAuto;
}
function getScrollerSyncerClass(pluginHooks) {
const ScrollerSyncer = pluginHooks.scrollerSyncerClass;
if (!ScrollerSyncer) {
throw new RangeError('Must import @fullcalendar/scrollgrid');
}
return ScrollerSyncer;
}
class NowTimerRunner {
constructor(handleChange) {
this.handleChange = handleChange;
this.isMounted = false;
this.handleRefresh = () => {
let timing = this.computeTiming();
if (timing.nowDate.valueOf() !== this.nowDate.valueOf()) {
this.nowDate = timing.nowDate;
this.todayRange = timing.todayRange;
this.handleChange();
}
this.clearTimeout();
this.setTimeout(timing.waitMs);
};
this.handleVisibilityChange = () => {
if (!document.hidden) {
this.handleRefresh();
}
};
}
update(input) {
if (!this.isMounted) {
this.isMounted = true;
// init inputs
this.unit = input.unit;
this.unitValue = input.unitValue;
this.nowIndicatorSnap = input.nowIndicatorSnap;
this.nowManager = input.nowManager;
this.dateEnv = input.dateEnv;
// init outputs
const timing = this.computeTiming();
this.nowDate = timing.nowDate;
this.todayRange = timing.todayRange;
// init listeners
this.setTimeout();
this.nowManager.addResetListener(this.handleRefresh);
// fired tab becomes visible after being hidden
// SSR check. CalendarDataManager calls top-level sync :(
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', this.handleVisibilityChange);
}
}
else if (input.unit !== this.unit ||
input.unitValue !== this.unitValue ||
input.nowIndicatorSnap !== this.nowIndicatorSnap ||
input.nowManager !== this.nowManager ||
input.dateEnv !== this.dateEnv) {
// update inputs
this.unit = input.unit;
this.unitValue = input.unitValue;
this.nowIndicatorSnap = input.nowIndicatorSnap;
this.nowManager = input.nowManager;
this.dateEnv = input.dateEnv;
this.clearTimeout();
this.setTimeout();
}
return {
nowDate: this.nowDate,
todayRange: this.todayRange,
};
}
destroy() {
if (this.isMounted) {
this.isMounted = false;
this.clearTimeout();
this.nowManager.removeResetListener(this.handleRefresh);
// SSR check. CalendarDataManager calls top-level sync :(
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
}
}
}
computeTiming() {
let unroundedNow = this.nowManager.getDateMarker();
let { unit, unitValue, nowIndicatorSnap, dateEnv } = this;
if (nowIndicatorSnap === 'auto') {
nowIndicatorSnap =
// large unit?
/year|month|week|day/.test(unit) ||
// if slotDuration 30 mins for example, would NOT appear to snap (legacy behavior)
(unitValue || 1) === 1;
}
let nowDate;
let waitMs;
if (nowIndicatorSnap) {
nowDate = dateEnv.startOf(unroundedNow, unit); // aka currentUnitStart
let nextUnitStart = dateEnv.add(nowDate, createDuration(1, unit));
waitMs = nextUnitStart.valueOf() - unroundedNow.valueOf();
}
else {
nowDate = unroundedNow;
waitMs = 1000 * 60; // 1 minute
}
// there is a max setTimeout ms value (https://stackoverflow.com/a/3468650/96342)
// ensure no longer than a day
waitMs = Math.min(1000 * 60 * 60 * 24, waitMs);
return {
nowDate,
todayRange: buildDayRange(nowDate),
waitMs,
};
}
setTimeout(waitMs = this.computeTiming().waitMs) {
// NOTE: timeout could take longer than expected if tab sleeps,
// which is why we listen to 'visibilitychange'
this.timeoutId = setTimeout(() => {
// NOTE: timeout could also return *earlier* than expected, and we need to wait like 2 ms more
// This is why use use same waitMs from computeTiming
const timing = this.computeTiming();
this.nowDate = timing.nowDate;
this.todayRange = timing.todayRange;
this.handleChange();
this.setTimeout(timing.waitMs);
}, waitMs);
}
clearTimeout() {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
}
}
function buildDayRange(date) {
let start = startOfDay(date);
let end = addDays(start, 1);
return { start, end };
}
class NowTimer extends Component {
constructor(props, context) {
super(props, context);
this.handleChange = () => {
this.forceUpdate();
};
this.runner = new NowTimerRunner(this.handleChange);
}
render() {
const { props, context } = this;
const { nowDate, todayRange } = this.runner.update({
nowManager: context.nowManager,
unit: props.unit,
unitValue: props.unitValue,
nowIndicatorSnap: context.options.nowIndicatorSnap,
dateEnv: context.dateEnv,
});
return props.children(nowDate, todayRange);
}
componentWillUnmount() {
this.runner.destroy();
}
}
NowTimer.contextType = ViewContextType;
const FULL_DATE_FORMAT = createFormatter({ year: 'numeric', month: 'long', day: 'numeric' });
const WEEK_FORMAT = createFormatter({ week: 'long' });
const WEEKDAY_ONLY_FORMAT = createFormatter({
weekday: 'long',
});
function findWeekdayText(parts) {
for (const part of parts) {
if (part.type === 'weekday') {
return part.value;
}
}
return '';
}
function findDayNumberText(parts) {
for (const part of parts) {
if (part.type === 'day') {
return part.value;
}
}
return '';
}
function findMonthText(parts) {
for (const part of parts) {
if (part.type === 'month') {
return part.value;
}
}
return '';
}
/*
TODO: just have this return the string?
*/
function buildDateStr(context, dateMarker, viewType = 'day') {
return joinDateTimeFormatParts(context.dateEnv.formatToParts(dateMarker, viewType === 'week' ? WEEK_FORMAT : FULL_DATE_FORMAT));
}
/*
Assumes navLinks enabled
Always hidden to screen readers. Do not point aria-labelledby at this. Use aria-label instead.
*/
function buildNavLinkAttrs(context, dateMarker, viewType = 'day', dateStr = buildDateStr(context, dateMarker, viewType), isTabbable = true) {
const { dateEnv, options, calendarApi } = context;
const zonedDate = dateEnv.toDate(dateMarker);
const handleInteraction = (ev) => {
let customAction = viewType === 'day' ? options.navLinkDayClick :
viewType === 'week' ? options.navLinkWeekClick : null;
if (typeof customAction === 'function') {
customAction.call(calendarApi, dateEnv.toDate(dateMarker), ev);
}
else {
if (typeof customAction === 'string') {
viewType = customAction;
}
calendarApi.zoomTo(dateMarker, viewType);
}
};
return {
'role': 'link', // TODO
'aria-label': formatWithOrdinals(options.navLinkHint, [dateStr, zonedDate], dateStr),
'className': joinClassNames(options.navLinkClass, classNames.cursorPointer, classNames.internalNavLink),
...(isTabbable
? createAriaClickAttrs(handleInteraction)
: { onClick: handleInteraction }),
};
}
function getDateMeta(dateMarker, dateEnv, dateProfile, todayRange, nowDate) {
const isDisabled = Boolean(dateProfile && (!dateProfile.activeRange || !rangeContainsMarker(dateProfile.activeRange, dateMarker)));
return {
date: dateEnv.toDate(dateMarker),
dow: dateMarker.getUTCDay(),
isDisabled,
isOther: !isDisabled && Boolean(dateProfile && !rangeContainsMarker(dateProfile.currentRange, dateMarker)),
isToday: !isDisabled && Boolean(todayRange && rangeContainsMarker(todayRange, dateMarker)),
isPast: !isDisabled && Boolean(nowDate ? (dateMarker < nowDate) : todayRange ? (dateMarker < todayRange.start) : false),
isFuture: !isDisabled && Boolean(nowDate ? (dateMarker > nowDate) : todayRange ? (dateMarker >= todayRange.end) : false),
};
}
function isDimsEqual(v0, v1) {
return v0 != null && (v0 === v1 || Math.abs(v0 - v1) < 0.01);
}
const nativeBorderBoxEnabled = true;
const configMap = new Map();
const afterSizeCallbacks = new Set();
let isHandling = false;
let isStalling = false;
function afterSize(callback) {
afterSizeCallbacks.add(callback);
// batch & then flush when not within ResizeObserver handler loop
// happens for watchers that die and report `null` as dimension
if (!isHandling && !isStalling) {
isStalling = true;
requestAnimationFrame(() => {
isStalling = false;
flushAfterSize();
});
}
}
function flushAfterSize() {
for (const flushedCallback of afterSizeCallbacks.values()) {
flushedCallback();
afterSizeCallbacks.delete(flushedCallback);
}
}
// Native
// -------------------------------------------------------------------------------------------------
// Single global ResizeObserver does batching and uses less memory than individuals
// Will always fire with delay after DOM mutation, but before repaint,
// thus doesn't need !isHandling check like checkConfigMap
const globalResizeObserver = typeof ResizeObserver !== 'undefined' && new ResizeObserver((entries) => {
isHandling = true;
// // debug
// console.log('RESIZE-OBSERVER', entries.map((entry) => entry.target))
for (let entry of entries) {
const el = entry.target;
const config = configMap.get(el);
let width;
let height;
if (entry.borderBoxSize && nativeBorderBoxEnabled) {
const borderBoxSize = entry.borderBoxSize[0] || entry.borderBoxSize; // HACK for Firefox
width = borderBoxSize.inlineSize;
height = borderBoxSize.blockSize;
}
else {
({ width, height } = el.getBoundingClientRect());
}
let shouldFire = false;
if (!isDimsEqual(config.width, width)) {
config.width = width;
shouldFire = config.watchWidth;
}
if (!isDimsEqual(config.height, height)) {
config.height = height;
shouldFire || (shouldFire = config.watchHeight);
}
if (shouldFire) {
config.callback(width, height);
}
}
flushSync(() => {
flushAfterSize();
isHandling = false;
});
});
/*
PRECONDITION: element can only have one listener attached
*/
function watchSize(el, callback, watchWidth = true, watchHeight = true) {
configMap.set(el, { callback, watchWidth, watchHeight });
// if statement is for jsdom and other shim environments that execute component effects, but
// haven't implemented ResizeObserver. Reference: https://github.com/jsdom/jsdom/issues/3368
if (globalResizeObserver) {
globalResizeObserver.observe(el, {
box: 'border-box'
// default is 'content-box'
});
}
return () => {
configMap.delete(el);
// same reasoning as above
if (globalResizeObserver) {
globalResizeObserver.unobserve(el);
}
};
}
function watchWidth(el, callback) {
return watchSize(el, callback,
/* watchWidth = */ true);
}
function watchHeight(el, callback) {
return watchSize(el, (_width, height) => callback(height),
/* watchWidth = */ false,
/* watchHeight = */ true);
}
class ViewContainer extends BaseComponent {
constructor() {
super(...arguments);
this.refineRenderProps = memoizeObjArg(refineRenderProps);
}
render() {
const { props, context } = this;
const { options, viewSpec } = context;
const renderProps = this.refineRenderProps({
...computeViewBorderless(options),
options: { headerToolbar: options.headerToolbar, footerToolbar: options.footerToolbar },
isHeightAuto: getIsHeightAuto(options),
viewApi: context.viewApi,
});
return (jsx(ContentContainer, { elRef: props.elRef, tag: props.tag || 'div', attrs: props.attrs, style: props.style, className: joinClassNames(props.className, generateClassName(options.viewClass, renderProps),
// WORKAROUND for way calendar's className would get merged into view's className
generateClassName(viewSpec.optionDefaults.class, renderProps), generateClassName(viewSpec.optionDefaults.className, renderProps), generateClassName(viewSpec.optionOverrides.class, renderProps), generateClassName(viewSpec.optionOverrides.className, renderProps)), renderProps: renderProps, generatorName: undefined, didMount: options.didMount || options.viewDidMount, willUnmount: options.willUnmount || options.viewWillUnmount, children: () => props.children }));
}
}
function refineRenderProps(raw) {
return {
view: raw.viewApi,
borderlessX: raw.borderlessX,
borderlessTop: raw.borderlessTop,
borderlessBottom: raw.borderlessBottom,
options: raw.options,
isHeightAuto: raw.isHeightAuto,
};
}
/*
an INTERACTABLE date component
PURPOSES:
- hook up to fg, fill, and mirror renderers
- interface for dragging and hits
*/
class DateComponent extends BaseComponent {
constructor() {
super(...arguments);
this.uid = guid();
}
// Hit System
// -----------------------------------------------------------------------------------------------------------------
prepareHits() {
}
queryHit(isRtl, positionLeft, positionTop, elWidth, elHeight) {
return null; // this should be abstract
}
// Pointer Interaction Utils
// -----------------------------------------------------------------------------------------------------------------
isValidSegDownEl(el) {
return !this.props.eventDrag && // HACK
!this.props.eventResize && // HACK
!el.closest(`.${classNames.internalEventMirror}`);
}
isValidDateDownEl(el) {
return !el.closest(`.${classNames.internalEvent}:not(.${classNames.internalBgEvent})`) &&
!el.closest(`.${classNames.internalMoreLink}`) &&
!el.closest(`.${classNames.internalNavLink}`) &&
!el.closest(`.${classNames.internalPopover}`); // hack
}
}
class DelayedRunner {
constructor(drainedOption) {
this.drainedOption = drainedOption;
this.isRunning = false;
this.isDirty = false;
this.pauseDepths = {};
this.timeoutId = 0;
}
request(delay) {
this.isDirty = true;
if (!this.isPaused()) {
this.clearTimeout();
if (delay == null) {
this.tryDrain();
}
else {
this.timeoutId = setTimeout(// NOT OPTIMAL! TODO: look at debounce
this.tryDrain.bind(this), delay);
}
}
}
pause(scope = '') {
let { pauseDepths } = this;
pauseDepths[scope] = (pauseDepths[scope] || 0) + 1;
this.clearTimeout();
}
resume(scope = '', force) {
let { pauseDepths } = this;
if (scope in pauseDepths) {
if (force) {
delete pauseDepths[scope];
}
else {
pauseDepths[scope] -= 1;
let depth = pauseDepths[scope];
if (depth <= 0) {
delete pauseDepths[scope];
}
}
this.tryDrain();
}
}
isPaused() {
return Object.keys(this.pauseDepths).length;
}
tryDrain() {
if (!this.isRunning && !this.isPaused()) {
this.isRunning = true;
while (this.isDirty) {
this.isDirty = false;
this.drained(); // might set isDirty to true again
}
this.isRunning = false;
}
}
clear() {
this.clearTimeout();
this.isDirty = false;
this.pauseDepths = {};
}
clearTimeout() {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = 0;
}
}
drained() {
if (this.drainedOption) {
this.drainedOption();
}
}
}
/*
NOTE: detection is complicated (w/ touch and wheel) because ScrollerSyncer needs to know about it,
but are we sure we can't just ignore programmatic scrollTo() calls with a flag? and determine the
the scroll-master simply by who was the newest scroller? Does passive:true do things asynchronously?
*/
class ScrollListener {
constructor(el) {
this.el = el;
this.emitter = new Emitter();
this.isScroll = false;
this.isScrollRecent = false;
this.isWheelRecent = false;
this.isMouseDown = false; // user currently has mouse down?
this.isTouchDown = false; // user currently has finger down?
// accumulated during scroll
this.isMouse = false;
this.isTouch = false;
this.isWheel = false;
// Handlers
// ----------------------------------------------------------------------------------------------
this.handleScroll = () => {
this.isScrollRecent = true;
if (this.isMouseDown) {
this.isMouse = true;
}
if (this.isTouchDown) {
this.isTouch = true;
}
if (this.isWheelRecent) {
this.isWheel = true;
}
this.startScroll();
this.emitter.trigger('scroll', this.getIsDevice());
this.scrollWaiter.request(500);
};
this.handleScrollWait = () => {
this.isScrollRecent = false;
// only end the scroll if not currently touching.
// if touching, the scrolling will end later, on touchend.
if (!this.isTouchDown) {
this.endScroll();
}
};
// will fire *before* the scroll event is fired (might not cause a scroll!)
this.handleWheel = () => {
this.isWheelRecent = true;
this.wheelWaiter.request(500);
};
this.handleWheelWait = () => {
this.isWheelRecent = false;
};
this.handleMouseDown = () => {
this.isMouseDown = true;
};
this.handleMouseUp = () => {
this.isMouseDown = false;
};
// will fire *before* the scroll event is fired (might not cause a scroll!)
this.handleTouchStart = () => {
this.isTouchDown = true;
};
this.handleTouchEnd = () => {
this.isTouchDown = false;
// if the user ended their touch, and the scroll area wasn't moving,
// we consider this to be the end of the scroll
// otherwise, wait for inertia to finish and handleScrollWait to fire
if (!this.isScrollRecent) {
this.endScroll();
}
};
this.wheelWaiter = new DelayedRunner(this.handleWheelWait);
this.scrollWaiter = new DelayedRunner(this.handleScrollWait);
el.addEventListener('scroll', this.handleScroll, { passive: true });
el.addEventListener('wheel', this.handleWheel, { passive: true });
el.addEventListener('mousedown', this.handleMouseDown);
el.addEventListener('mouseup', this.handleMouseUp);
el.addEventListener('touchstart', this.handleTouchStart, { passive: true });
el.addEventListener('touchend', this.handleTouchEnd);
}
destroy() {
let { el } = this;
el.removeEventListener('scroll', this.handleScroll, { passive: true });
el.removeEventListener('wheel', this.handleWheel, { passive: true });
el.removeEventListener('mousedown', this.handleMouseDown);
el.removeEventListener('mouseup', this.handleMouseUp);
el.removeEventListener('touchstart', this.handleTouchStart, { passive: true });
el.removeEventListener('touchend', this.handleTouchEnd);
}
// Start / Stop
// ----------------------------------------------------------------------------------------------
startScroll() {
if (!this.isScroll) {
this.isScroll = true;
this.emitter.trigger('scrollStart', this.getIsDevice());
}
}
endScroll() {
if (this.isScroll) { // extra protection because might be called publicly
this.scrollWaiter.clear(); // (same)
this.wheelWaiter.clear(); // (same)
this.isScroll = false;
this.isWheelRecent = false;
this.emitter.trigger('scrollEnd', this.getIsDevice());
this.isMouse = false;
this.isTouch = false;
this.isWheel = false;
}
}
getIsDevice() {
return this.isWheel || this.isMouse || this.isTouch;
}
}
class Scroller extends DateComponent {
constructor() {
super(...arguments);
this.handleEl = (el) => {
if (this.el) {
this.el = null;
this._isUnmounting = true;
this.listener.destroy();
}
if (el) {
this.el = el;
this._isUnmounting = false;
this.listener = new ScrollListener(el);
}
};
this.handleHRuler = (el) => {
if (this.disconnectHRuler) {
this.disconnectHRuler();
this.disconnectHRuler = undefined;
if (this.clientWidth !== undefined) {
this.clientWidth = undefined;
setRef(this.props.clientWidthRef, null);
}
}
if (el) {
this.disconnectHRuler = watchWidth(el, (clientWidth) => {
if (this._isUnmounting)
return;
if (clientWidth !== this.clientWidth) {
this.clientWidth = clientWidth;
setRef(this.props.clientWidthRef, clientWidth);
}
});
}
};
this.handleVRuler = (el) => {
if (this.disconnectVRuler) {
this.disconnectVRuler();
this.disconnectVRuler = undefined;
if (this.clientHeight !== undefined) {
this.clientHeight = undefined;
setRef(this.props.clientHeightRef, null);
}
}
if (el) {
this.disconnectVRuler = watchHeight(el, (clientHeight) => {
if (this._isUnmounting)
return;
if (clientHeight !== this.clientHeight) {
this.clientHeight = clientHeight;
setRef(this.props.clientHeightRef, clientHeight);
}
const bottomScrollbarWidth = Math.round(this.el.getBoundingClientRect().height - clientHeight);
if (bottomScrollbarWidth !== this.bottomScrollbarWidth) {
this.bottomScrollbarWidth = bottomScrollbarWidth;
setRef(this.props.bottomScrollbarWidthRef, bottomScrollbarWidth);
}
});
}
};
}
render() {
const { props } = this;
// if there's only one axis that needs scrolling, the other axis will unintentionally have
// scrollbars too if we don't force to 'hidden'
const fallbackOverflow = (props.horizontal || props.vertical) ? 'hidden' : '';
return (jsxs("div", { ref: this.handleEl, className: joinClassNames(props.className, classNames.noPadding, classNames.rel, // for children fillTop/fillStart
props.hideScrollbars && classNames.noScrollbars, classNames.internalScroller), style: {
...props.style,
overflowX: (props.horizontal ? 'auto' : fallbackOverflow),
overflowY: (props.vertical ? 'auto' : fallbackOverflow),
}, children: [props.children, Boolean(props.clientWidthRef) && (jsx("div", { ref: this.handleHRuler, className: classNames.fillTop })), Boolean(props.clientHeightRef || props.bottomScrollbarWidthRef) && (jsx("div", { ref: this.handleVRuler, className: classNames.fillStart }))] }));
}
endScroll() {
this.listener.endScroll();
}
// Public API
// -----------------------------------------------------------------------------------------------
get x() {
const { el } = this;
return el ? getNormalizedScrollX(el) : 0;
}
get y() {
const { el } = this;
return el ? el.scrollTop : 0;
}
scrollTo({ x, y }) {
const { el } = this;
if (el) {
if (y != null) {
el.scrollTop = y;
}
if (x != null) {
setNormalizedScrollX(el, x);
}
}
}
addScrollStartListener(handler) {
this.listener.emitter.on('scrollStart', handler);
}
removeScrollStartListener(handler) {
this.listener.emitter.off('scrollStart', handler);
}
addScrollEndListener(handler) {
this.listener.emitter.on('scrollEnd', handler);
}
removeScrollEndListener(handler) {
this.listener.emitter.off('scrollEnd', handler);
}
}
// Public API
// -------------------------------------------------------------------------------------------------
// We can drop normalization when support for Chromium-based <86 is dropped (see Notion)
function getNormalizedScrollX(el) {
const { scrollLeft } = el;
const isRtl = computeElIsRtl(el);
return isRtl ? getNormalizedRtlScrollX(scrollLeft, el) : scrollLeft;
}
function setNormalizedScrollX(el, x) {
const isRtl = computeElIsRtl(el);
el.scrollLeft = isRtl ? getNormalizedRtlScrollLeft(x, el) : x;
}
/*
Returns a value in the 'reverse' system
*/
function getNormalizedRtlScrollX(scrollLeft, el) {
switch (getRtlScrollerSystem()) {
case 'positive':
return el.scrollWidth - el.clientWidth - scrollLeft;
case 'negative':
return -scrollLeft;
}
return scrollLeft;
}
/*
Receives a value in the 'reverse' system
TODO: is this really the same equations as getNormalizedRtlScrollX??? I think so
If so, consolidate. With isRtl check too
*/
function getNormalizedRtlScrollLeft(x, el) {
switch (getRtlScrollerSystem()) {
case 'positive':
return el.scrollWidth - el.clientWidth - x;
case 'negative':
return -x;
}
return x;
}
let _rtlScrollerSystem;
function getRtlScrollerSystem() {
return _rtlScrollerSystem || (_rtlScrollerSystem = detectRtlScrollerSystem());
}
function detectRtlScrollerSystem() {
let el = document.createElement('div');
el.style.position = 'absolute';
el.style.top = '-1000px';
el.style.width = '100px'; // must be at least the side of scrollbars or you get inaccurate values (#7335)
el.style.height = '100px'; // "
el.style.overflow = 'scroll';
el.style.direction = 'rtl';
let innerEl = document.createElement('div');
innerEl.style.width = '200px';
innerEl.style.height = '200px';
el.appendChild(innerEl);
document.body.appendChild(el);
let system;
if (el.scrollLeft > 0) {
system = 'positive'; // scroll is a positive number from the left edge
}
else {
el.scrollLeft = 50;
if (el.scrollLeft > 0) {
system = 'reverse'; // scroll is a positive number from the right edge
}
else {
system = 'negative'; // scroll is a negative number from the right edge
}
}
el.remove();
return system;
}
class StandardEvent extends BaseComponent {
constructor() {
super(...arguments);
// memo
this.buildPublicEvent = memoize((context, eventDef, eventInstance) => new EventImpl(context, eventDef, eventInstance));
this.handleEl = (el) => {
this.el = el;
setRef(this.props.elRef, el);
if (el) {
setElEventRange(el, this.props.eventRange);
}
};
}
render() {
const { props, context } = this;
const { options } = context;
const { eventRange } = props;
const eventUi = eventRange.ui;
const timeFormat = options.eventTimeFormat || props.defaultTimeFormat;
const timeText = props.forcedTimeText ?? buildEventRangeTimeText(timeFormat, eventRange, // just for def/instance
props.slicedStart, props.slicedEnd, props.isStart, props.isEnd, context, props.defaultDisplayEventTime, props.defaultDisplayEventEnd);
const [tag, attrs, isInteractive] = getEventTagAndAttrs(eventRange, context);
const eventApi = this.buildPublicEvent(context, eventRange.def, eventRange.instance);
const isDraggable = !props.disableDragging && computeEventRangeDraggable(eventRange, context);
const isBlock = /row|column/.test(props.display);
const subcontentRenderProps = {
event: eventApi,
isNarrow: props.isNarrow || false,
isShort: props.isShort || false,
timeText,
};
const renderProps = {
event: eventApi, // make stable. everything else atomic. FYI, eventRange unfortunately gets reconstructed a lot, but def/instance is stable
view: context.viewApi,
timeText: timeText,
color: eventUi.color || options.eventColor,
contrastColor: eventUi.contrastColor || options.eventContrastColor,
isDraggable,
isStartResizable: !props.disableResizing && props.isStart && eventUi.durationEditable && options.eventResizableFromStart,
isEndResizable: !props.disableResizing && props.isEnd && eventUi.durationEditable,
isMirror: props.isMirror,
isStart: Boolean(props.isStart),
isEnd: Boolean(props.isEnd),
isFirst: Boolean(props.isFirst),
isLast: Boolean(props.isLast),
isPast: Boolean(props.isPast), // TODO: don't cast. getDateMeta does it
isFuture: Boolean(props.isFuture), // TODO: don't cast. getDateMeta does it
isToday: Boolean(props.isToday), // TODO: don't cast. getDateMeta does it
isSelected: Boolean(props.isSelected),
isDragging: Boolean(props.isDragging),
isResizing: Boolean(props.isResizing),
isInteractive,
isNarrow: props.isNarrow || false,
isShort: props.isShort || false,
level: props.level || 0,
timeClass: joinClassNames(generateClassName(options.eventTimeClass, subcontentRenderProps), isBlock && generateClassName(options.blockEventTimeClass, subcontentRenderProps), props.display === 'row' && generateClassName(options.rowEventTimeClass, subcontentRenderProps), props.display === 'column' && generateClassName(options.columnEventTimeClass, subcontentRenderProps), props.display === 'list-item' && generateClassName(options.listItemEventTimeClass, subcontentRenderProps)),
titleClass: joinClassNames(generateClassName(options.eventTitleClass, subcontentRenderProps), isBlock && generateClassName(options.blockEventTitleClass, subcontentRenderProps), props.display === 'row' && generateClassName(options.rowEventTitleClass, subcontentRenderProps), props.display === 'column' && generateClassName(options.columnEventTitleClass, subcontentRenderProps), props.display === 'list-item' && generateClassName(options.listItemEventTitleClass, subcontentRenderProps), props.display === 'row' && options.rowEventTitleSticky && classNames.stickyS, props.display === 'column' && options.columnEventTitleSticky && classNames.stickyT),
options: { eventOverlap: Boolean(options.eventOverlap) },
};
const outerClassName = joinClassNames(// already includes eventClass below
isBlock && generateClassName(options.blockEventClass, renderProps), props.display === 'row' && generateClassName(options.rowEventClass, renderProps), props.display === 'column' && generateClassName(options.columnEventClass, renderProps), props.display === 'list-item' && generateClassName(options.listItemEventClass, renderProps), eventUi.className, props.className, props.display === 'column'
? classNames.flexCol
: classNames.flexRow, (eventRange.def.url || isDraggable) && classNames.cursorPointer, classNames.internalEvent, props.isMirror && classNames.internalEventMirror, isDraggable && classNames.internalEventDraggable, renderProps.isSelected && classNames.internalEventSelected, (renderProps.isStartResizable || renderProps.isEndResizable) && classNames.internalEventResizable);
const beforeClassName = joinClassNames(generateClassName(options.eventBeforeClass, renderProps), isBlock && generateClassName(options.blockEventBeforeClass, renderProps), props.display === 'row' && generateClassName(options.rowEventBeforeClass, renderProps), props.display === 'column' && generateClassName(options.columnEventBeforeClass, renderProps), props.display === 'list-item' && generateClassName(options.listItemEventBeforeClass, renderProps));
const afterClassName = joinClassNames(generateClassName(options.eventAfterClass, renderProps), isBlock && generateClassName(options.blockEventAfterClass, renderProps), props.display === 'row' && generateClassName(options.rowEventAfterClass, renderProps), props.display === 'column' && generateClassName(options.columnEventAfterClass, renderProps), props.display === 'list-item' && generateClassName(options.listItemEventAfterClass, renderProps));
const innerClassName = joinClassNames(generateClassName(options.eventInnerClass, renderProps), isBlock && generateClassName(options.blockEventInnerClass, renderProps), props.display === 'row' && generateClassName(options.rowEventInnerClass, renderProps), props.display === 'column' && generateClassName(options.columnEventInnerClass, renderProps), props.display === 'list-item' && generateClassName(options.listItemEventInnerClass, renderProps), !props.disableLiquid && classNames.liquid);
const beforeContent = props.display === 'row' && options.rowEventBeforeContent;
const afterContent = props.display === 'row' && options.rowEventAfterContent;
return (jsx(ContentContainer, { tag: tag, attrs: {
...props.attrs,
...attrs,
// HACK because this event-element gets attached to root during some dragging
dir: (props.isDragging && options.direction === 'rtl') ? 'rtl' : undefined,
}, className: outerClassName, style: {
'--fc-event-color': renderProps.color,
'--fc-event-contrast-color': renderProps.contrastColor,
}, elRef: this.handleEl, renderProps: renderProps, generatorName: "eventContent", customGenerator: options.eventContent, defaultGenerator: renderInnerContent, classNameGenerator: options.eventClass, didMount: options.eventDidMount, willUnmount: options.eventWillUnmount, children: (InnerContent) => (jsxs(Fragment, { children: [Boolean(renderProps.isSelected && isBlock) && (jsx("div", { className: props.display === 'column'
? classNames.hitX
: classNames.hitY })), (beforeClassName || beforeContent) && (jsxs("div", { className: joinClassNames(beforeClassName, !props.disableZindexes && classNames.z1, renderProps.isStartResizable && joinClassNames(props.display === 'column'
? classNames.cursorResizeT
: classNames.cursorResizeS,
// these classnames required for dnd
classNames.internalEventResizer, classNames.internalEventResizerStart)), children: [beforeContent && (jsx(ContentContainer, { tag: 'div', style: { display: 'contents' }, attrs: { 'aria-hidden': true }, renderProps: renderProps, generatorName: undefined, customGenerator: beforeContent })), Boolean(renderProps.isStartResizable && renderProps.isSelected) && (jsx("div", { className: classNames.hit }))] })), jsx(InnerContent, { tag: "div", className: joinClassNames(innerClassName, !props.disableZindexes && classNames.z0) }), (afterClassName || afterContent) && (jsxs("div", { className: joinClassNames(afterClassName, !props.disableZindexes && classNames.z1, renderProps.isEndResizable && joinClassNames(props.display === 'column'
? classNames.cursorResizeB
: classNames.cursorResizeE,
// these classnames required for dnd
classNames.internalEventResizer, classNames.internalEventResizerEnd)), children: [afterContent && (jsx(ContentContainer, { tag: 'div', style: { display: 'contents' }, attrs: { 'aria-hidden': true }, renderProps: renderProps, generatorName: undefined, customGenerator: afterContent })), Boolean(renderProps.isEndResizable && renderProps.isSelected) && (jsx("