@mui/internal-docs-infra
Version:
MUI Infra - internal documentation creation tools.
307 lines (295 loc) • 12.5 kB
JavaScript
'use client';
import * as React from 'react';
import { useScrollAnchor } from "../useScrollAnchor/useScrollAnchor.mjs";
const GUTTER_STATE_ATTRIBUTE = 'data-scrollbar-gutter';
const gutterCleanupTimers = new WeakMap();
const gutterFlipTimers = new WeakMap();
const scrollbackAnimations = new WeakMap();
const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true;
/**
* Schedules `callback` to run after `duration` ms on the browser's animation
* timeline (via a no-op WAAPI animation), so DevTools' animation speed slider
* scales the delay in step with CSS transitions. Falls back to `setTimeout`
* when WAAPI isn't available.
*
* Cancelling the returned `Animation` does NOT invoke `callback` (the
* rejected `finished` promise is swallowed), matching `clearTimeout`
* semantics.
*/
function scheduleOnAnimationTimeline(target, duration, callback) {
if (typeof target.animate === 'function') {
const anim = target.animate([{
opacity: 1
}, {
opacity: 1
}], {
duration,
fill: 'none'
});
anim.finished.then(callback, () => {
// Swallow rejection from `Animation.cancel()` so cancelling the
// schedule doesn't fire the cleanup callback.
});
return anim;
}
return setTimeout(callback, duration);
}
function cancelScheduled(handle) {
if (handle === undefined) {
return;
}
// Guard the `instanceof` so we don't throw a `ReferenceError` in browsers
// that lack WAAPI (where `Animation` is undefined as a global).
if (typeof Animation !== 'undefined' && handle instanceof Animation) {
handle.cancel();
} else {
clearTimeout(handle);
}
}
/**
* Smoothly slides the `<code>` element back to the left edge over `duration`
* ms using an ease-out cubic via the Web Animations API.
*
* `scrollEl` is whichever element owns the horizontal scroll — the inner
* `<pre>` by default, or an attached scroll container (see `scrollContainerRef`)
* when the code block is rendered inside a fixed-size window. `code` is this
* code window's own `<code>` (scoped to its container by the caller) so a shared
* scroll container holding several blocks animates the right one.
*
* Used during collapse instead of tweening `scrollEl.scrollLeft` because the
* scrollbar-gutter animation forces `overflow-x: hidden` on `scrollEl`, which
* snaps `scrollLeft` to 0 instantly. Animating a transform on the inner
* `code` element produces the same visual effect, isn't reset by the overflow
* change, and is naturally clipped by the scroll element's hidden overflow.
*
* Honors `prefers-reduced-motion` by snapping immediately.
*/
function smoothCollapseScrollLeft(scrollEl, code, duration) {
const startLeft = scrollEl.scrollLeft;
if (startLeft <= 0) {
return null;
}
// Cancel any leftover scroll-back animation from a previous toggle so we
// don't end up with two transforms competing on the same element.
scrollbackAnimations.get(scrollEl)?.cancel();
scrollbackAnimations.delete(scrollEl);
// Snap the actual scroll position back to the left edge now. When we can
// animate, the WAAPI transform below visually compensates by translating the
// element from `-startLeft` back to `0`; otherwise (no WAAPI, no `code`,
// reduced motion, or zero duration) this stands as an instant snap — still
// the correct collapsed end state.
scrollEl.scrollLeft = 0;
if (!code || typeof code.animate !== 'function' || prefersReducedMotion() || duration <= 0) {
return null;
}
const anim = code.animate([{
transform: `translateX(${-startLeft}px)`
}, {
transform: 'translateX(0)'
}], {
duration,
easing: 'cubic-bezier(0, 0, 0.2, 1)',
fill: 'none'
});
scrollbackAnimations.set(scrollEl, anim);
const onSettle = () => {
if (scrollbackAnimations.get(scrollEl) === anim) {
scrollbackAnimations.delete(scrollEl);
}
};
anim.finished.then(onSettle, onSettle);
return anim;
}
function isElementInViewport(element) {
const rect = element.getBoundingClientRect();
return rect.bottom > 0 && rect.top < window.innerHeight;
}
/**
* Measures the horizontal scrollbar height of the scroll element by
* temporarily forcing `overflow-x: scroll`.
*/
function measureScrollbarHeight(scrollEl) {
const prevOverflow = scrollEl.style.overflowX;
scrollEl.style.overflowX = 'scroll';
const scrollbarHeight = scrollEl.offsetHeight - scrollEl.clientHeight;
scrollEl.style.overflowX = prevOverflow;
return scrollbarHeight;
}
function clearGutterState(scrollEl) {
cancelScheduled(gutterCleanupTimers.get(scrollEl));
gutterCleanupTimers.delete(scrollEl);
const flipTimer = gutterFlipTimers.get(scrollEl);
if (flipTimer !== undefined) {
clearTimeout(flipTimer);
gutterFlipTimers.delete(scrollEl);
}
scrollEl.removeAttribute(GUTTER_STATE_ATTRIBUTE);
}
function cancelAllForScrollEl(scrollEl) {
scrollbackAnimations.get(scrollEl)?.cancel();
scrollbackAnimations.delete(scrollEl);
clearGutterState(scrollEl);
}
/**
* Drives a from→to transition on the `data-scrollbar-gutter` attribute of
* the scroll element, which the consumer's CSS hooks into to animate the swap
* between a real scrollbar and equivalent padding-bottom.
*
* `scrollEl` is whichever element owns the horizontal scroll — the inner
* `<pre>` by default, or the attached `scrollContainerRef` when the code block
* is rendered inside a fixed-size window. `code` is this code window's own
* `<code>` (scoped to its container by the caller).
*
* Skips the animation when content doesn't overflow (no scrollbar exists)
* or when the browser uses overlay scrollbars (zero height).
*/
function animateScrollbarGutter(scrollEl, code, from, to, durationMs) {
const scrollbarHeight = measureScrollbarHeight(scrollEl);
if (scrollbarHeight === 0) {
return; // Overlay scrollbars, nothing to do
}
// Decide from this code window's own `<code>`, not from `scrollEl` — the
// scroll owner may be a shared container wrapping other content. `code`'s
// `scrollWidth` reflects hidden frames (via `min-width: fit-content`), so it
// predicts the post-expand width and still reflects the wide source during
// collapse; compare it against the scroll owner's visible width.
//
// Exception: a collapse-to-empty block (`data-focused-lines="0"`) shows
// nothing while collapsed, so its `scrollWidth` can't predict the post-expand
// width that way. On expand, run the swap anyway so `overflow-x` stays hidden
// through the reveal instead of flashing a scrollbar; if the expanded source
// turns out to fit, the swap simply ends without one. On collapse the current
// width is real, so the normal skip still applies.
const widthUnknownOnExpand = from === 'expand-from' && code?.getAttribute('data-focused-lines') === '0';
if (!code || !widthUnknownOnExpand && code.scrollWidth <= scrollEl.clientWidth) {
return;
}
clearGutterState(scrollEl);
scrollEl.setAttribute(GUTTER_STATE_ATTRIBUTE, from);
// Move into the transition state on the next macrotask. Tracked so the
// flip can be cancelled if the component unmounts before it fires.
const flipTimer = setTimeout(() => {
gutterFlipTimers.delete(scrollEl);
scrollEl.setAttribute(GUTTER_STATE_ATTRIBUTE, to);
}, 0);
gutterFlipTimers.set(scrollEl, flipTimer);
// Schedule cleanup on the animation timeline so DevTools throttling
// scales it together with the CSS transition.
const cleanup = scheduleOnAnimationTimeline(scrollEl, durationMs + 30, () => {
clearGutterState(scrollEl);
});
gutterCleanupTimers.set(scrollEl, cleanup);
}
const DEFAULT_ANCHOR_SELECTOR = '[data-frame-type="highlighted"], [data-frame-type="focus"]';
const DEFAULT_COLLAPSIBLE_SELECTOR = '[data-collapsible]';
/**
* Layered helper that combines `useScrollAnchor` with the additional
* choreography needed when expanding/collapsing a syntax-highlighted code
* block.
*
* On top of the page-scroll compensation provided by `useScrollAnchor`, it:
*
* - Selects an anchor inside the container (highlighted or focus frame),
* falling back to the toggle when the primary anchor is offscreen on
* collapse.
* - Drives a `data-scrollbar-gutter` attribute on the inner `<pre>` so the
* consumer's CSS can swap between a real horizontal scrollbar and
* equivalent `padding-bottom` without a snap.
* - Smoothly returns the `<code>` element's `scrollLeft` to `0` on
* collapse via a compositor-driven transform, so the focused region
* (which usually starts at column 0) is back in view after collapse.
*
* The hook expects a structure like:
*
* ```jsx
* <div ref={containerRef}>
* <pre>
* <code>...</code>
* </pre>
* <button ref={toggleRef}>Expand</button>
* </div>
* ```
*
* Anchor selection and the collapsible probe are configurable so it works
* with any highlighter that marks frames with data attributes.
*/
export function useCodeWindow(options = {}) {
const {
expandDuration = 350,
collapseDuration = 350,
scrollBackDuration = 300,
anchorSelector = DEFAULT_ANCHOR_SELECTOR,
collapsibleProbeSelector = DEFAULT_COLLAPSIBLE_SELECTOR
} = options;
const toggleRef = React.useRef(null);
const lastScrollElRef = React.useRef(null);
const {
containerRef,
scrollContainerRef,
anchorScroll: rawAnchorScroll
} = useScrollAnchor();
React.useEffect(() => {
return () => {
const scrollEl = lastScrollElRef.current;
if (scrollEl) {
cancelAllForScrollEl(scrollEl);
lastScrollElRef.current = null;
}
};
}, []);
const anchorScroll = React.useCallback(direction => {
const container = containerRef.current;
if (!container) {
return;
}
const primaryAnchor = container.querySelector(anchorSelector);
const toggleAnchor = toggleRef.current;
let anchor = primaryAnchor ?? toggleAnchor;
if (direction === 'collapse' && primaryAnchor && !isElementInViewport(primaryAnchor)) {
anchor = toggleAnchor ?? primaryAnchor;
}
if (!anchor) {
return;
}
// The element whose horizontal scrollbar we smooth: the attached scroll
// container when one is provided (the code block lives inside a
// fixed-size window that owns both scroll axes), otherwise the inner
// `<pre>`, which scrolls horizontally on its own.
const scrollEl = scrollContainerRef.current ?? container.querySelector('pre');
// Scope content lookups to *this* code window's `container`, never to
// `scrollEl`: an attached scroll container may wrap several code blocks or
// unrelated content, so `scrollEl.querySelector('code')` could match the
// wrong block. The overflow decision and scroll-back both use this code.
const code = container.querySelector('code');
if (scrollEl) {
lastScrollElRef.current = scrollEl;
if (direction === 'collapse') {
// Smoothly return horizontal scroll to the left edge. We animate
// via a transform on the inner `code` element rather than
// tweening `scrollEl.scrollLeft`, because the gutter animation below
// sets `overflow-x: hidden` which would snap `scrollLeft` to 0
// instantly. Both animations start in the same frame: the
// scroll-back resets `scrollLeft` to 0 up front, so the gutter
// swap's `overflow-x` change has nothing left to snap.
smoothCollapseScrollLeft(scrollEl, code, scrollBackDuration);
animateScrollbarGutter(scrollEl, code, 'collapse-from', 'collapse-to', collapseDuration);
}
if (direction === 'expand') {
// Cancel any in-flight collapse scroll-back so its leftover
// transform can't drift the code horizontally during expand.
scrollbackAnimations.get(scrollEl)?.cancel();
scrollbackAnimations.delete(scrollEl);
if (collapsibleProbeSelector && container.querySelector(collapsibleProbeSelector)) {
animateScrollbarGutter(scrollEl, code, 'expand-from', 'expand-to', expandDuration);
}
}
}
rawAnchorScroll(anchor, direction === 'collapse' ? collapseDuration : expandDuration);
}, [containerRef, scrollContainerRef, rawAnchorScroll, anchorSelector, collapsibleProbeSelector, collapseDuration, expandDuration, scrollBackDuration]);
return {
containerRef,
scrollContainerRef,
toggleRef,
anchorScroll
};
}