UNPKG

@mui/internal-docs-infra

Version:

MUI Infra - internal documentation creation tools.

406 lines (383 loc) 19.9 kB
import * as React from 'react'; import { useCodeHighlighterContextOptional } from "../CodeHighlighter/CodeHighlighterContext.mjs"; import { useCodeContext } from "../CodeProvider/CodeContext.mjs"; import { useControlledCode } from "../CodeControllerContext/index.mjs"; import { extractNameAndSlugFromUrl } from "../pipeline/loaderUtils/index.mjs"; import { useVariantSelection } from "./useVariantSelection.mjs"; import { useTransformManagement } from "./useTransformManagement.mjs"; import { useFileNavigation } from "./useFileNavigation.mjs"; import { useUIState } from "./useUIState.mjs"; import { useCopyFunctionality } from "./useCopyFunctionality.mjs"; import { useSourceEditing } from "./useSourceEditing.mjs"; import { findCollapseInFocusTransforms, shouldHighlightForRender } from "./useCodeUtils.mjs"; import { findVariantFocusedLinesMismatches } from "./sourceLineCounts.mjs"; export function useCode(contentProps, opts) { const { copy: copyOpts, initialVariant, initialTransform, preClassName, fileHashMode = 'remove-hash', saveHashVariantToLocalStorage = 'on-interaction', sourceEnhancers, disabled, onExpand, transformDelay, transformLayoutShift = 'selected', strictCollapseInFocus = false, variantLayoutShift = 'selected', variantSwapDelay, strictMatchingVariantFocusedLines = false } = opts || {}; // Safely try to get context values - will be undefined if not in context const context = useCodeHighlighterContextOptional(); const codeContext = useCodeContext(); const controllerContext = useControlledCode(); // Merge enhancers from CodeProvider, CodeControllerContext, and useCode opts. // Provider enhancers run first so they match the order applied by // `loadPrecomputedCodeHighlighter` on the server, then controller and // per-call enhancers layer on top. This lets a single `<CodeProvider>` // configure the baseline (e.g., `@highlight` / `@focus` framing) while // individual `useCode` callers add demo-specific extras without losing the // shared defaults. // Hoist the optional controller member into a stable local so the memo's // inferred dependency matches the source dependency. `useControlledCode()` // always returns a fresh object, so `controllerContext` is never null but // changes identity every render; depending on the narrowed value keeps the // memo correct (and lets the compiler preserve the manual memoization). const controllerEnhancers = controllerContext?.sourceEnhancers; const mergedEnhancers = React.useMemo(() => { const enhancers = []; if (codeContext.sourceEnhancers) { enhancers.push(...codeContext.sourceEnhancers); } if (controllerEnhancers) { enhancers.push(...controllerEnhancers); } if (sourceEnhancers) { enhancers.push(...sourceEnhancers); } return enhancers.length > 0 ? enhancers : undefined; }, [codeContext.sourceEnhancers, controllerEnhancers, sourceEnhancers]); // Get the effective code - context overrides contentProps if available const effectiveCode = React.useMemo(() => { return context?.code || contentProps.code || {}; }, [context?.code, contentProps.code]); // Opt-in development-time assertion: throw if any transform's // `.collapse` placeholder would land inside the focus region. The // check is purely a lookup against precomputed manifest flags (no // tree walking) so it is cheap to run on every render; the memo // ensures the actual scan only re-runs when `effectiveCode` changes. // Fail-fast in render so demo authors notice the problem the first // time they load the page instead of debugging a missing animation. const collapseInFocusOffenders = React.useMemo(() => strictCollapseInFocus ? findCollapseInFocusTransforms(effectiveCode) : null, [strictCollapseInFocus, effectiveCode]); if (collapseInFocusOffenders && collapseInFocusOffenders.length > 0) { const first = collapseInFocusOffenders[0]; const extraCount = collapseInFocusOffenders.length - 1; const suffix = extraCount > 0 ? ` (${extraCount} more offender(s) suppressed).` : `.`; throw new Error(`[useCode] strictCollapseInFocus is enabled and transform "${first.transformKey}" on variant "${first.variantName}" file "${first.fileName}" introduces a .collapse placeholder inside the visible focus region. Narrow the focused area (e.g. tighten @focus/@padding markers or shrink the transform's edit range) so the placeholder lands outside the initially-visible window${suffix}`); } // Opt-in development-time assertion: throw if any two variants // declare a file with the same name but disagree on // `focusedLines`. Cheap precomputed-metadata lookup — the memo // ensures the actual scan only re-runs when `effectiveCode` // changes. Fail-fast in render so demo authors notice the problem // the first time they load the page. const variantFocusedLinesMismatches = React.useMemo(() => strictMatchingVariantFocusedLines ? findVariantFocusedLinesMismatches(effectiveCode) : null, [strictMatchingVariantFocusedLines, effectiveCode]); if (variantFocusedLinesMismatches && variantFocusedLinesMismatches.length > 0) { const first = variantFocusedLinesMismatches[0]; const extraCount = variantFocusedLinesMismatches.length - 1; const suffix = extraCount > 0 ? ` (${extraCount} more mismatch(es) suppressed).` : `.`; throw new Error(`[useCode] strictMatchingVariantFocusedLines is enabled and file "${first.fileName}" has ${first.focusedLinesA} focused line(s) in variant "${first.variantA}" but ${first.focusedLinesB} focused line(s) in variant "${first.variantB}". Align the @focus/@padding markers across variants so the collapsed window matches${suffix}`); } // Dev-only sanity check: `strictMatchingVariantFocusedLines` only // protects against coordinated-barrier risk while the block is // collapsed under `variantLayoutShift: 'focus'`. Enabling it in // any other mode produces throws that don't correspond to a real // layout-shift hazard, so warn the author once per render. if (process.env.NODE_ENV !== 'production' && strictMatchingVariantFocusedLines && variantLayoutShift !== 'focus') { console.warn(`[useCode] strictMatchingVariantFocusedLines is enabled but variantLayoutShift is "${variantLayoutShift}". The strict check only guards coordinated-barrier swaps under 'focus' mode; consider setting variantLayoutShift: 'focus' or disabling strictMatchingVariantFocusedLines.`); } // Memoize userProps with auto-generated name and slug if missing const userProps = React.useMemo(() => { // Extract only the user-defined properties (T) from contentProps const { name: contentName, slug: contentSlug, code, components, url: contentUrl, // `collapseToEmpty` / `initialExpanded` are render-time display flags, not // user-facing props — strip them (rest siblings) so they don't leak into // the demo's props. collapseToEmpty: collapseToEmptyContentProp, initialExpanded: initialExpandedContentProp, ...userDefinedProps } = contentProps; // Get URL from context first, then fall back to contentProps const effectiveUrl = context?.url || contentUrl; let name = contentName; let slug = contentSlug; // Generate name and slug from URL if they're missing and we have a URL if ((!name || !slug) && effectiveUrl) { try { const generated = extractNameAndSlugFromUrl(effectiveUrl); name = name || generated.name; slug = slug || generated.slug; } catch { // If URL parsing fails, keep the original values (which might be undefined) } } return { ...userDefinedProps, name, slug }; }, [contentProps, context?.url]); // Resolve the render-time display flags. They must come from `contentProps` // (threaded by the demo factory / `CodeHighlighter` / code transforms) rather // than `useCode` opts: the loading fallback derives its own copy from the same // `contentProps`, so a per-call opt would let the live render and the fallback // disagree. const collapseToEmpty = contentProps.collapseToEmpty === true; const initialExpanded = contentProps.initialExpanded === true; // Sub-hook: UI State Management (needs slug to check for relevant hash) const uiState = useUIState({ initialExpanded, mainSlug: userProps.slug }); // Lift `selectedFileName` state out of `useFileNavigation` so // `useTransformManagement` *and* `useVariantSelection` can read it // (selected-file-scoped `transformLayoutShift` / // `variantLayoutShift` modes). `useFileNavigation` consumes the // value + setter as controlled props. Initial value is resolved // below once `useVariantSelection` has reported the initial // variant. const [selectedFileNameState, setSelectedFileNameState] = React.useState(undefined); // Sub-hook: Variant Selection const variantSelection = useVariantSelection({ effectiveCode, initialVariant, variantType: contentProps.variantType, mainSlug: userProps.slug, saveHashVariantToLocalStorage, variantLayoutShift, selectedFileName: selectedFileNameState, expanded: uiState.expanded, variantSwapDelay, deferHighlight: context?.deferHighlight }); // Seed the selected file name from the variant's main file the // first time the variant resolves. Subsequent file selections come // through `useFileNavigation`'s controlled setter. Set-state during // render triggers one extra render on first mount; we accept that // cost because the alternative (lazy `useState` initializer) // requires resolving the variant key here, which depends on // `useUrlHashState` / `usePreference` hooks that already live // inside `useVariantSelection`. Duplicating them at this level // would be worse than the extra render. if (selectedFileNameState === undefined && variantSelection.selectedVariant?.fileName) { setSelectedFileNameState(variantSelection.selectedVariant.fileName); } // Defer the outgoing `<Pre>` from rendering highlighted spans while // a stored-preference bootstrap swap is known to be coming. See // `shouldHighlightForRender` for the full rationale, including the // `highlightAfter === 'init'` bypass that prevents a visible flash // of unhighlighted code on first-paint variant swaps. const shouldHighlight = shouldHighlightForRender({ deferHighlight: context?.deferHighlight, highlightReady: context?.highlightReady, pendingBootstrap: variantSelection.pendingBootstrap, highlightAfter: context?.highlightAfter }); // The rendered tree should reflect the *committed* variant so the // outgoing `<Pre>` stays put during `variantSwapDelay`. When no // delay is configured these are always equal to `selectedVariant` / // `selectedVariantKey`. Falling back to the pending value (rather // than `null`) keeps the boot path — before the coordinator has // committed for the first time — rendering the freshly-resolved // variant instead of nothing. const renderedVariant = variantSelection.committedVariant ?? variantSelection.selectedVariant; const renderedVariantKey = variantSelection.committedVariantKey || variantSelection.selectedVariantKey; // Sub-hook: Transform Management const transformManagement = useTransformManagement({ context, effectiveCode, selectedVariantKey: renderedVariantKey, selectedVariant: renderedVariant, initialTransform, transformDelay, transformLayoutShift, selectedFileName: selectedFileNameState, expanded: uiState.expanded }); // Sub-hook: Source Editing const sourceEditing = useSourceEditing({ context, selectedVariantKey: renderedVariantKey, effectiveCode, selectedVariant: renderedVariant, disabled }); // Combine the two animation phases into a single `transforming` // attribute for `<Pre>`. Both phases share the `data-transforming` // attribute and the `.collapse` placeholder bridge — the only // difference is which delta drives the bridge. When both are // simultaneously eligible (rare — a transform swap mid-variant-swap // window) the variant phase takes precedence because the rendered // tree just swapped variants and that's the larger visual change. const transforming = variantSelection.variantSwappingPhase ?? transformManagement.transformingPhase; // Route `<Pre>`'s readiness callback to whichever phase source owns // the current animation window. Each source flips its own paused → // active transition independently; we just forward the signal. const variantPhaseActive = variantSelection.variantSwappingPhase !== null; const notifyVariantTransitionReady = variantSelection.notifyVariantTransitionReady; const notifyTransformTransitionReady = transformManagement.notifyTransformTransitionReady; const onPreTransitionReady = React.useCallback(() => { if (variantPhaseActive) { notifyVariantTransitionReady(); } else { notifyTransformTransitionReady(); } }, [variantPhaseActive, notifyVariantTransitionReady, notifyTransformTransitionReady]); // Defer `expand()` while a variant or transform swap is in flight, // or while the currently-displayed variant's source is still being // highlighted. Callers that pair `selectVariant(...)` / // `selectTransform(...)` with `expand()` in the same tick (e.g. // "show source of variant X" or "switch to JS then expand" // affordances) would otherwise flip `expanded` mid-animation: the // bridge `.collapse` placeholder switches metric (`focus` → // `total`) and the previously-hidden rows pop in before the swap // commits, producing a visible jump. When no `variantSwapDelay` // is configured the swap commits synchronously, but the new // variant's `parsedCode` is still computed asynchronously — the // `deferHighlight` flag published by `CodeHighlighterClient` // (true while `waitingForParsedCode`) keeps the gate engaged // through that window too. // // `expand()` always queues through a `pendingExpand` state flag; // a passive effect resolves it on every render where the composed // `transforming` phase is `null` and `deferHighlight` is falsy // (i.e. neither a variant/transform swap nor an async re-highlight // is in flight). Using state (not a ref) keeps the drain reactive: // a synchronous `expand()` triggers a render, the effect runs, and // `setExpanded(true)` flushes in the same React batch — preserving // the "expand is synchronous" semantics for the common case while // naturally waiting on in-flight swaps. `setExpanded` stays direct // so explicit controlled-state writes remain synchronous regardless // of swap phase. const setExpanded = uiState.setExpanded; const swapInFlight = transforming !== null || !!context?.deferHighlight; const [pendingExpand, setPendingExpand] = React.useState(false); // Keep the latest `onExpand` in a ref so the stable `expand` callback below // can call it without changing identity (it is forwarded down to `<Pre>`). const onExpandRef = React.useRef(onExpand); React.useLayoutEffect(() => { onExpandRef.current = onExpand; }); const expand = React.useCallback(() => { // Notify the host synchronously, while the block is still collapsed, so it // can capture the pre-expansion layout and engage a scroll anchor (the // expansion itself is deferred below via `pendingExpand`). This mirrors the // timing of clicking the expand toggle, where the host anchors the scroll // before the layout changes. onExpandRef.current?.(); setPendingExpand(true); }, []); React.useEffect(() => { if (pendingExpand && !swapInFlight) { /* eslint-disable react-hooks/set-state-in-effect -- intentional queue drain: commit deferred expand only after in-flight swaps settle (swapInFlight transitions false on a later render); see comment above re: flicker-avoidance and same-batch synchronous-expand semantics */ setPendingExpand(false); setExpanded(true); /* eslint-enable react-hooks/set-state-in-effect */ } }, [pendingExpand, swapInFlight, setExpanded]); // Partner variant whose per-file line counts feed `<Pre>`'s bridge // computation. `null` when no variant swap is in flight (the bridge // collapses to a no-op inside `<Pre>` either way; this lookup is a // performance shortcut so we don't read the entire variant on every // render). const swapPartnerVariant = React.useMemo(() => { if (!variantSelection.swapPartnerVariantKey) { return null; } const variant = effectiveCode[variantSelection.swapPartnerVariantKey]; if (variant && typeof variant === 'object' && 'source' in variant) { return variant; } return null; }, [effectiveCode, variantSelection.swapPartnerVariantKey]); // Bridge line-count metric should mirror variant layout-shift mode: // only `'focus'` compares focused lines while collapsed; every other // mode always compares total lines. const variantBridgeLineMode = variantLayoutShift === 'focus' ? 'focus' : 'total'; // Sub-hook: File Navigation const fileNavigation = useFileNavigation({ selectedVariant: renderedVariant, transformedFiles: transformManagement.transformedFiles, selectedTransform: transformManagement.selectedTransform, mainSlug: userProps.slug, selectedVariantKey: renderedVariantKey, selectVariant: variantSelection.selectVariantProgrammatic, variantKeys: variantSelection.variantKeys, shouldHighlight, preClassName, setSource: sourceEditing.setSource, editActivation: context?.editActivation, onActivate: context?.onEditingActivated, effectiveCode, fileHashMode, saveHashVariantToLocalStorage, saveVariantToLocalStorage: variantSelection.saveVariantToLocalStorage, hashVariant: variantSelection.hashVariant, sourceEnhancers: mergedEnhancers, fallbacks: context?.fallbacks, expanded: uiState.expanded, collapseToEmpty, expand, transforming, onPreTransitionReady, variantBridgeLineMode, swapPartnerVariant, selectedFileName: selectedFileNameState, setSelectedFileName: setSelectedFileNameState }); // Sub-hook: Copy Functionality const copyFunctionality = useCopyFunctionality({ selectedFile: fileNavigation.selectedFile, selectedVariant: renderedVariant, transformedFiles: transformManagement.transformedFiles, // Per-file dictionaries for the active variant (decodes `hastCompressed` // sources back to text); `selectedFileFallback` covers the single-file copy. fallbacks: context?.fallbacks, selectedFileFallback: fileNavigation.selectedFileFallback, title: userProps.name, copyOpts }); return { variants: variantSelection.variantKeys, selectedVariant: variantSelection.selectedVariantKey, selectVariant: variantSelection.selectVariant, files: fileNavigation.files, selectedFile: fileNavigation.selectedFileComponent, selectedFileLines: fileNavigation.selectedFileLines, selectedFileName: fileNavigation.selectedFileName, selectedFileUrl: fileNavigation.selectedFileUrl, selectedFileSlug: fileNavigation.selectedFileSlug, selectFileName: fileNavigation.selectFileName, allFilesSlugs: fileNavigation.allFilesSlugs, expanded: uiState.expanded, expand, setExpanded, copy: copyFunctionality.copy, copyMarkdown: copyFunctionality.copyMarkdown, availableTransforms: transformManagement.availableTransforms, selectedTransform: transformManagement.selectedTransform, selectTransform: transformManagement.selectTransform, pendingTransform: transformManagement.pendingTransform, setSource: sourceEditing.setSource, reset: sourceEditing.reset, refresh: context?.refresh, userProps }; }