import { h, ref, defineComponent, computed, onMounted, onBeforeUnmount, mergeProps, Transition, watchEffect, Fragment } from 'vue'; import { on, off } from 'evtd'; import { VResizeObserver } from 'vueuc'; import { useIsIos } from 'vooks'; import { getPreciseEventTarget } from 'seemly'; import { useConfig, useTheme, useThemeClass, useRtl } from "../../../_mixins/index.mjs"; import { useReactivated, Wrapper } from "../../../_utils/index.mjs"; import { scrollbarLight } from "../styles/index.mjs"; import style from "./styles/index.cssr.mjs"; const scrollbarProps = Object.assign(Object.assign({}, useTheme.props), { size: { type: Number, default: 5 }, duration: { type: Number, default: 0 }, scrollable: { type: Boolean, default: true }, xScrollable: Boolean, trigger: { type: String, default: 'hover' }, useUnifiedContainer: Boolean, triggerDisplayManually: Boolean, // If container is set, resize observer won't not attached container: Function, content: Function, containerClass: String, containerStyle: [String, Object], contentClass: [String, Array], contentStyle: [String, Object], horizontalRailStyle: [String, Object], verticalRailStyle: [String, Object], onScroll: Function, onWheel: Function, onResize: Function, internalOnUpdateScrollLeft: Function, internalHoistYRail: Boolean }); const Scrollbar = defineComponent({ name: 'Scrollbar', props: scrollbarProps, inheritAttrs: false, setup(props) { const { mergedClsPrefixRef, inlineThemeDisabled, mergedRtlRef } = useConfig(props); const rtlEnabledRef = useRtl('Scrollbar', mergedRtlRef, mergedClsPrefixRef); // dom ref const wrapperRef = ref(null); const containerRef = ref(null); const contentRef = ref(null); const yRailRef = ref(null); const xRailRef = ref(null); // data ref const contentHeightRef = ref(null); const contentWidthRef = ref(null); const containerHeightRef = ref(null); const containerWidthRef = ref(null); const yRailSizeRef = ref(null); const xRailSizeRef = ref(null); const containerScrollTopRef = ref(0); const containerScrollLeftRef = ref(0); const isShowXBarRef = ref(false); const isShowYBarRef = ref(false); let yBarPressed = false; let xBarPressed = false; let xBarVanishTimerId; let yBarVanishTimerId; let memoYTop = 0; let memoXLeft = 0; let memoMouseX = 0; let memoMouseY = 0; const isIos = useIsIos(); const yBarSizeRef = computed(() => { const { value: containerHeight } = containerHeightRef; const { value: contentHeight } = contentHeightRef; const { value: yRailSize } = yRailSizeRef; if (containerHeight === null || contentHeight === null || yRailSize === null) { return 0; } else { return Math.min(containerHeight, yRailSize * containerHeight / contentHeight + props.size * 1.5); } }); const yBarSizePxRef = computed(() => { return `${yBarSizeRef.value}px`; }); const xBarSizeRef = computed(() => { const { value: containerWidth } = containerWidthRef; const { value: contentWidth } = contentWidthRef; const { value: xRailSize } = xRailSizeRef; if (containerWidth === null || contentWidth === null || xRailSize === null) { return 0; } else { return xRailSize * containerWidth / contentWidth + props.size * 1.5; } }); const xBarSizePxRef = computed(() => { return `${xBarSizeRef.value}px`; }); const yBarTopRef = computed(() => { const { value: containerHeight } = containerHeightRef; const { value: containerScrollTop } = containerScrollTopRef; const { value: contentHeight } = contentHeightRef; const { value: yRailSize } = yRailSizeRef; if (containerHeight === null || contentHeight === null || yRailSize === null) { return 0; } else { const heightDiff = contentHeight - containerHeight; if (!heightDiff) return 0; return containerScrollTop / heightDiff * (yRailSize - yBarSizeRef.value); } }); const yBarTopPxRef = computed(() => { return `${yBarTopRef.value}px`; }); const xBarLeftRef = computed(() => { const { value: containerWidth } = containerWidthRef; const { value: containerScrollLeft } = containerScrollLeftRef; const { value: contentWidth } = contentWidthRef; const { value: xRailSize } = xRailSizeRef; if (containerWidth === null || contentWidth === null || xRailSize === null) { return 0; } else { const widthDiff = contentWidth - containerWidth; if (!widthDiff) return 0; return containerScrollLeft / widthDiff * (xRailSize - xBarSizeRef.value); } }); const xBarLeftPxRef = computed(() => { return `${xBarLeftRef.value}px`; }); const needYBarRef = computed(() => { const { value: containerHeight } = containerHeightRef; const { value: contentHeight } = contentHeightRef; return containerHeight !== null && contentHeight !== null && contentHeight > containerHeight; }); const needXBarRef = computed(() => { const { value: containerWidth } = containerWidthRef; const { value: contentWidth } = contentWidthRef; return containerWidth !== null && contentWidth !== null && contentWidth > containerWidth; }); const mergedShowXBarRef = computed(() => { const { trigger } = props; return trigger === 'none' || isShowXBarRef.value; }); const mergedShowYBarRef = computed(() => { const { trigger } = props; return trigger === 'none' || isShowYBarRef.value; }); const mergedContainerRef = computed(() => { const { container } = props; if (container) return container(); return containerRef.value; }); const mergedContentRef = computed(() => { const { content } = props; if (content) return content(); return contentRef.value; }); const activateState = useReactivated(() => { // Only restore for builtin container & content if (!props.container) { // remount scrollTo({ top: containerScrollTopRef.value, left: containerScrollLeftRef.value }); } }); // methods const handleContentResize = () => { if (activateState.isDeactivated) return; sync(); }; const handleContainerResize = e => { if (activateState.isDeactivated) return; const { onResize } = props; if (onResize) onResize(e); sync(); }; const scrollTo = (options, y) => { if (!props.scrollable) return; if (typeof options === 'number') { scrollToPosition(y !== null && y !== void 0 ? y : 0, options, 0, false, 'auto'); return; } const { left, top, index, elSize, position, behavior, el, debounce = true } = options; if (left !== undefined || top !== undefined) { scrollToPosition(left !== null && left !== void 0 ? left : 0, top !== null && top !== void 0 ? top : 0, 0, false, behavior); } if (el !== undefined) { scrollToPosition(0, el.offsetTop, el.offsetHeight, debounce, behavior); } else if (index !== undefined && elSize !== undefined) { scrollToPosition(0, index * elSize, elSize, debounce, behavior); } else if (position === 'bottom') { scrollToPosition(0, Number.MAX_SAFE_INTEGER, 0, false, behavior); } else if (position === 'top') { scrollToPosition(0, 0, 0, false, behavior); } }; const scrollBy = (options, y) => { if (!props.scrollable) return; const { value: container } = mergedContainerRef; if (!container) return; if (typeof options === 'object') { container.scrollBy(options); } else { container.scrollBy(options, y || 0); } }; function scrollToPosition(left, top, elSize, debounce, behavior) { const { value: container } = mergedContainerRef; if (!container) return; if (debounce) { const { scrollTop, offsetHeight } = container; if (top > scrollTop) { if (top + elSize <= scrollTop + offsetHeight) { // do nothing } else { container.scrollTo({ left, top: top + elSize - offsetHeight, behavior }); } return; } } container.scrollTo({ left, top, behavior }); } function handleMouseEnterWrapper() { showXBar(); showYBar(); sync(); } function handleMouseLeaveWrapper() { hideBar(); } function hideBar() { hideYBar(); hideXBar(); } function hideYBar() { if (yBarVanishTimerId !== undefined) { window.clearTimeout(yBarVanishTimerId); } yBarVanishTimerId = window.setTimeout(() => { isShowYBarRef.value = false; }, props.duration); } function hideXBar() { if (xBarVanishTimerId !== undefined) { window.clearTimeout(xBarVanishTimerId); } xBarVanishTimerId = window.setTimeout(() => { isShowXBarRef.value = false; }, props.duration); } function showXBar() { if (xBarVanishTimerId !== undefined) { window.clearTimeout(xBarVanishTimerId); } isShowXBarRef.value = true; } function showYBar() { if (yBarVanishTimerId !== undefined) { window.clearTimeout(yBarVanishTimerId); } isShowYBarRef.value = true; } function handleScroll(e) { const { onScroll } = props; if (onScroll) onScroll(e); syncScrollState(); } function syncScrollState() { // only collect scroll state, do not trigger any dom event const { value: container } = mergedContainerRef; if (container) { containerScrollTopRef.value = container.scrollTop; containerScrollLeftRef.value = container.scrollLeft * ((rtlEnabledRef === null || rtlEnabledRef === void 0 ? void 0 : rtlEnabledRef.value) ? -1 : 1); } } function syncPositionState() { // only collect position state, do not trigger any dom event // Don't use getClientBoundingRect because element may be scale transformed const { value: content } = mergedContentRef; if (content) { contentHeightRef.value = content.offsetHeight; contentWidthRef.value = content.offsetWidth; } const { value: container } = mergedContainerRef; if (container) { containerHeightRef.value = container.offsetHeight; containerWidthRef.value = container.offsetWidth; } const { value: xRailEl } = xRailRef; const { value: yRailEl } = yRailRef; if (xRailEl) { xRailSizeRef.value = xRailEl.offsetWidth; } if (yRailEl) { yRailSizeRef.value = yRailEl.offsetHeight; } } /** * Sometimes there's only one element that we can scroll, * For example for textarea, there won't be a content element. */ function syncUnifiedContainer() { const { value: container } = mergedContainerRef; if (container) { containerScrollTopRef.value = container.scrollTop; containerScrollLeftRef.value = container.scrollLeft * ((rtlEnabledRef === null || rtlEnabledRef === void 0 ? void 0 : rtlEnabledRef.value) ? -1 : 1); containerHeightRef.value = container.offsetHeight; containerWidthRef.value = container.offsetWidth; contentHeightRef.value = container.scrollHeight; contentWidthRef.value = container.scrollWidth; } const { value: xRailEl } = xRailRef; const { value: yRailEl } = yRailRef; if (xRailEl) { xRailSizeRef.value = xRailEl.offsetWidth; } if (yRailEl) { yRailSizeRef.value = yRailEl.offsetHeight; } } function sync() { if (!props.scrollable) return; if (props.useUnifiedContainer) { syncUnifiedContainer(); } else { syncPositionState(); syncScrollState(); } } function isMouseUpAway(e) { var _a; return !((_a = wrapperRef.value) === null || _a === void 0 ? void 0 : _a.contains(getPreciseEventTarget(e))); } function handleXScrollMouseDown(e) { e.preventDefault(); e.stopPropagation(); xBarPressed = true; on('mousemove', window, handleXScrollMouseMove, true); on('mouseup', window, handleXScrollMouseUp, true); memoXLeft = containerScrollLeftRef.value; memoMouseX = (rtlEnabledRef === null || rtlEnabledRef === void 0 ? void 0 : rtlEnabledRef.value) ? window.innerWidth - e.clientX : e.clientX; } function handleXScrollMouseMove(e) { if (!xBarPressed) return; if (xBarVanishTimerId !== undefined) { window.clearTimeout(xBarVanishTimerId); } if (yBarVanishTimerId !== undefined) { window.clearTimeout(yBarVanishTimerId); } const { value: containerWidth } = containerWidthRef; const { value: contentWidth } = contentWidthRef; const { value: xBarSize } = xBarSizeRef; if (containerWidth === null || contentWidth === null) return; const dX = (rtlEnabledRef === null || rtlEnabledRef === void 0 ? void 0 : rtlEnabledRef.value) ? window.innerWidth - e.clientX - memoMouseX : e.clientX - memoMouseX; const dScrollLeft = dX * (contentWidth - containerWidth) / (containerWidth - xBarSize); const toScrollLeftUpperBound = contentWidth - containerWidth; let toScrollLeft = memoXLeft + dScrollLeft; toScrollLeft = Math.min(toScrollLeftUpperBound, toScrollLeft); toScrollLeft = Math.max(toScrollLeft, 0); const { value: container } = mergedContainerRef; if (container) { container.scrollLeft = toScrollLeft * ((rtlEnabledRef === null || rtlEnabledRef === void 0 ? void 0 : rtlEnabledRef.value) ? -1 : 1); const { internalOnUpdateScrollLeft } = props; if (internalOnUpdateScrollLeft) internalOnUpdateScrollLeft(toScrollLeft); } } function handleXScrollMouseUp(e) { e.preventDefault(); e.stopPropagation(); off('mousemove', window, handleXScrollMouseMove, true); off('mouseup', window, handleXScrollMouseUp, true); xBarPressed = false; sync(); if (isMouseUpAway(e)) { hideBar(); } } function handleYScrollMouseDown(e) { e.preventDefault(); e.stopPropagation(); yBarPressed = true; on('mousemove', window, handleYScrollMouseMove, true); on('mouseup', window, handleYScrollMouseUp, true); memoYTop = containerScrollTopRef.value; memoMouseY = e.clientY; } function handleYScrollMouseMove(e) { if (!yBarPressed) return; if (xBarVanishTimerId !== undefined) { window.clearTimeout(xBarVanishTimerId); } if (yBarVanishTimerId !== undefined) { window.clearTimeout(yBarVanishTimerId); } const { value: containerHeight } = containerHeightRef; const { value: contentHeight } = contentHeightRef; const { value: yBarSize } = yBarSizeRef; if (containerHeight === null || contentHeight === null) return; const dY = e.clientY - memoMouseY; const dScrollTop = dY * (contentHeight - containerHeight) / (containerHeight - yBarSize); const toScrollTopUpperBound = contentHeight - containerHeight; let toScrollTop = memoYTop + dScrollTop; toScrollTop = Math.min(toScrollTopUpperBound, toScrollTop); toScrollTop = Math.max(toScrollTop, 0); const { value: container } = mergedContainerRef; if (container) { container.scrollTop = toScrollTop; } } function handleYScrollMouseUp(e) { e.preventDefault(); e.stopPropagation(); off('mousemove', window, handleYScrollMouseMove, true); off('mouseup', window, handleYScrollMouseUp, true); yBarPressed = false; sync(); if (isMouseUpAway(e)) { hideBar(); } } watchEffect(() => { const { value: needXBar } = needXBarRef; const { value: needYBar } = needYBarRef; const { value: mergedClsPrefix } = mergedClsPrefixRef; const { value: xRailEl } = xRailRef; const { value: yRailEl } = yRailRef; if (xRailEl) { if (!needXBar) { xRailEl.classList.add(`${mergedClsPrefix}-scrollbar-rail--disabled`); } else { xRailEl.classList.remove(`${mergedClsPrefix}-scrollbar-rail--disabled`); } } if (yRailEl) { if (!needYBar) { yRailEl.classList.add(`${mergedClsPrefix}-scrollbar-rail--disabled`); } else { yRailEl.classList.remove(`${mergedClsPrefix}-scrollbar-rail--disabled`); } } }); onMounted(() => { // if container exist, it always can't be resolved when scrollbar is mounted // for example: // - component // - scrollbar // - inner // if you pass inner to scrollbar, you may use a ref inside component // however, when scrollbar is mounted, ref is not ready at component // you need to init by yourself if (props.container) return; sync(); }); onBeforeUnmount(() => { if (xBarVanishTimerId !== undefined) { window.clearTimeout(xBarVanishTimerId); } if (yBarVanishTimerId !== undefined) { window.clearTimeout(yBarVanishTimerId); } off('mousemove', window, handleYScrollMouseMove, true); off('mouseup', window, handleYScrollMouseUp, true); }); const themeRef = useTheme('Scrollbar', '-scrollbar', style, scrollbarLight, props, mergedClsPrefixRef); const cssVarsRef = computed(() => { const { common: { cubicBezierEaseInOut, scrollbarBorderRadius, scrollbarHeight, scrollbarWidth }, self: { color, colorHover } } = themeRef.value; return { '--n-scrollbar-bezier': cubicBezierEaseInOut, '--n-scrollbar-color': color, '--n-scrollbar-color-hover': colorHover, '--n-scrollbar-border-radius': scrollbarBorderRadius, '--n-scrollbar-width': scrollbarWidth, '--n-scrollbar-height': scrollbarHeight }; }); const themeClassHandle = inlineThemeDisabled ? useThemeClass('scrollbar', undefined, cssVarsRef, props) : undefined; const exposedMethods = { scrollTo, scrollBy, sync, syncUnifiedContainer, handleMouseEnterWrapper, handleMouseLeaveWrapper }; return Object.assign(Object.assign({}, exposedMethods), { mergedClsPrefix: mergedClsPrefixRef, rtlEnabled: rtlEnabledRef, containerScrollTop: containerScrollTopRef, wrapperRef, containerRef, contentRef, yRailRef, xRailRef, needYBar: needYBarRef, needXBar: needXBarRef, yBarSizePx: yBarSizePxRef, xBarSizePx: xBarSizePxRef, yBarTopPx: yBarTopPxRef, xBarLeftPx: xBarLeftPxRef, isShowXBar: mergedShowXBarRef, isShowYBar: mergedShowYBarRef, isIos, handleScroll, handleContentResize, handleContainerResize, handleYScrollMouseDown, handleXScrollMouseDown, cssVars: inlineThemeDisabled ? undefined : cssVarsRef, themeClass: themeClassHandle === null || themeClassHandle === void 0 ? void 0 : themeClassHandle.themeClass, onRender: themeClassHandle === null || themeClassHandle === void 0 ? void 0 : themeClassHandle.onRender }); }, render() { var _a; const { $slots, mergedClsPrefix, triggerDisplayManually, rtlEnabled, internalHoistYRail } = this; if (!this.scrollable) return (_a = $slots.default) === null || _a === void 0 ? void 0 : _a.call($slots); const triggerIsNone = this.trigger === 'none'; const createYRail = (className, style) => { return h("div", { ref: "yRailRef", class: [`${mergedClsPrefix}-scrollbar-rail`, `${mergedClsPrefix}-scrollbar-rail--vertical`, className], "data-scrollbar-rail": true, style: [style || '', this.verticalRailStyle], "aria-hiddens": true }, h( // eslint-disable-next-line @typescript-eslint/no-unsafe-argument triggerIsNone ? Wrapper : Transition, triggerIsNone ? null : { name: 'fade-in-transition' }, { default: () => this.needYBar && this.isShowYBar && !this.isIos ? h("div", { class: `${mergedClsPrefix}-scrollbar-rail__scrollbar`, style: { height: this.yBarSizePx, top: this.yBarTopPx }, onMousedown: this.handleYScrollMouseDown }) : null })); }; const createChildren = () => { var _a, _b; (_a = this.onRender) === null || _a === void 0 ? void 0 : _a.call(this); return h('div', mergeProps(this.$attrs, { role: 'none', ref: 'wrapperRef', class: [`${mergedClsPrefix}-scrollbar`, this.themeClass, rtlEnabled && `${mergedClsPrefix}-scrollbar--rtl`], style: this.cssVars, onMouseenter: triggerDisplayManually ? undefined : this.handleMouseEnterWrapper, onMouseleave: triggerDisplayManually ? undefined : this.handleMouseLeaveWrapper }), [this.container ? (_b = $slots.default) === null || _b === void 0 ? void 0 : _b.call($slots) : h("div", { role: "none", ref: "containerRef", class: [`${mergedClsPrefix}-scrollbar-container`, this.containerClass], style: this.containerStyle, onScroll: this.handleScroll, onWheel: this.onWheel }, h(VResizeObserver, { onResize: this.handleContentResize }, { default: () => h("div", { ref: "contentRef", role: "none", style: [{ width: this.xScrollable ? 'fit-content' : null }, this.contentStyle], class: [`${mergedClsPrefix}-scrollbar-content`, this.contentClass] }, $slots) })), internalHoistYRail ? null : createYRail(undefined, undefined), this.xScrollable && h("div", { ref: "xRailRef", class: [`${mergedClsPrefix}-scrollbar-rail`, `${mergedClsPrefix}-scrollbar-rail--horizontal`], style: this.horizontalRailStyle, "data-scrollbar-rail": true, "aria-hidden": true }, h( // eslint-disable-next-line @typescript-eslint/no-unsafe-argument triggerIsNone ? Wrapper : Transition, triggerIsNone ? null : { name: 'fade-in-transition' }, { default: () => this.needXBar && this.isShowXBar && !this.isIos ? h("div", { class: `${mergedClsPrefix}-scrollbar-rail__scrollbar`, style: { width: this.xBarSizePx, right: rtlEnabled ? this.xBarLeftPx : undefined, left: rtlEnabled ? undefined : this.xBarLeftPx }, onMousedown: this.handleXScrollMouseDown }) : null }))]); }; const scrollbarNode = this.container ? createChildren() : h(VResizeObserver, { onResize: this.handleContainerResize }, { default: createChildren }); if (internalHoistYRail) { return h(Fragment, null, scrollbarNode, createYRail(this.themeClass, this.cssVars)); } else { return scrollbarNode; } } }); export default Scrollbar; export const XScrollbar = Scrollbar;