UNPKG

fullcalendar

Version:

FullCalendar Vanilla JS package for rendering a calendar

1,190 lines (1,169 loc) 51.4 kB
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("