UNPKG

@mui/internal-docs-infra

Version:

MUI Infra - internal documentation creation tools.

239 lines (226 loc) 9.71 kB
// `useEditable` is the lightweight, always-mounted shell for live code editing. // It owns the editing state and refs (undo history, caret, the MutationObserver // ref) so they survive across renders, but the heavy runtime — the // contentEditable setup and the keyboard/paste/caret handlers — lives in the // separately-loaded `./EditableEngine` chunk. `contentEditable` is applied to // the element only once that engine resolves, so read-only code blocks never // pull the engine into their bundle. The engine factory is injected (typically // by `CodeProvider` via context); a built-in fallback keeps editing working // without a provider. The original fork attribution lives in `./EditableEngine`. import * as React from 'react'; import { peekEditingEngine, loadEditingEngine, preloadEditingEngine, resetEditingEngineCache } from "./editingEngineCache.mjs"; // A fresh empty snapshot per call — the pre-load `edit.getState()` must not hand // out a shared mutable object, or one caller mutating it would corrupt the // snapshot every other pre-load caller sees. const emptySnapshot = () => ({ text: '', position: { position: 0, extent: 0, content: '', line: 0 } }); // The resolved engine is cached in the shared `editingEngineCache` (so the // FIRST editable block resolves the loader once and every block after attaches // synchronously — and `useSourceEditing` shares the same warm module). These // are back-compat aliases over that cache; the param is now an // `EditingEngineLoader` (resolves the module, not just the factory). /** * Eagerly loads the editing engine and primes the shared cache so the next * editable block attaches synchronously instead of after a load round-trip. * Optional — `useEditable` loads on demand anyway. Pass the provider's * `editingEngineLoader` to share its deduplication. */ export const preloadEditableEngine = preloadEditingEngine; /** * Clears the shared editing-engine cache so the next editable block resolves its * loader from scratch. Intended for tests that exercise the cold path. */ export const resetEditableEngineCache = resetEditingEngineCache; /** * The lightweight, always-mounted shell for live code editing. Owns the editing * state/refs and a stable `edit` proxy; the heavy runtime is loaded on demand * from `./EditableEngine` and `contentEditable` is applied only once it resolves. * * The host element (`elementRef.current`) is expected to be **stable for the * lifetime of the hook** once the block is editable: the engine attaches once * and its setup effect does not re-run on a node swap, so a caller that replaces * the bound element in place would leave `contentEditable` on the stale node. */ export const useEditable = (elementRef, onChange, opts) => { // Normalize once into a non-optional local so the effects below can read // `config.X` directly without any non-null assertions on `opts`. const config = opts ?? {}; const unblock = React.useState([])[1]; // The editing state bag, the visible-region bounds, and a config snapshot are // all mutable refs the engine reads/writes. They're synced in the layout effect // below (never during render — React refs must not be touched while rendering). const stateRef = React.useRef(null); const observerRef = React.useRef(null); const boundsRef = React.useRef({}); const configRef = React.useRef(config); const [engine, setEngine] = React.useState(null); const engineRef = React.useRef(null); // Fires `onActivate` once per block lifetime, the first time the block engages // for editing (mount in `'eager'`; hover/focus/click in `'interaction'`). const activatedRef = React.useRef(false); // Stable Edit proxy. Delegates to the loaded engine; before the engine // resolves the mutators are no-ops and `getState` returns an empty snapshot // (matching the historical pre-mount behavior). const [edit] = React.useState(() => ({ update(content) { engineRef.current?.edit.update(content); }, insert(append, offset) { engineRef.current?.edit.insert(append, offset); }, move(pos) { engineRef.current?.edit.move(pos); }, getState() { return engineRef.current?.edit.getState() ?? emptySnapshot(); } })); // Keep the mutable refs current. Runs every render in a layout effect (not // during render, so the React Compiler ref rules are satisfied) and before the // resolve effect below, so the engine is always built against fresh values. // The engine's handlers read these refs at event time, long after this commits. React.useLayoutEffect(() => { let editingState = stateRef.current; if (editingState === null) { editingState = { disconnected: false, onChange, pendingContent: null, queue: [], history: [], historyAt: -1, lastCommittedContent: null, domDirty: false, position: null, repeatFlushId: null, skipNextRestore: false, preParseAbort: null }; stateRef.current = editingState; } else { // `onChange` can change without a remount (e.g. controlled code updates the // closure), so refresh it every render. It's declared as a method on // `State`, so the assignment needs no cast. editingState.onChange = onChange; } const bounds = boundsRef.current; bounds.minColumn = config.minColumn; bounds.minRow = config.minRow; bounds.maxRow = config.maxRow; bounds.onBoundary = config.onBoundary; bounds.caretSelector = config.caretSelector; bounds.preParse = config.preParse; configRef.current = config; }); // Resolve the engine when the block is editable. `'eager'` (default) loads on // mount; `'interaction'` defers the load until the user engages: hover // (pointerenter) warms the chunk so the eventual commit is instant, and focus // or click commits (loads + attaches). `contentEditable` is applied only after // the engine resolves (via `setup`). React.useLayoutEffect(() => { const editingState = stateRef.current; if (typeof window === 'undefined' || config.disabled || !elementRef.current || !editingState || engineRef.current) { return undefined; } const loader = config.engineLoader; const ctx = { elementRef, state: editingState, observerRef, boundsRef, configRef, unblock }; const attach = create => { if (engineRef.current) { return; } const created = create(ctx); engineRef.current = created; setEngine(created); }; // Notify the host the block has engaged for editing, exactly once. The host // (e.g. `CodeHighlighter`) uses this to warm the rest of the live-editing // dependencies — grammars and the worker — at the activation moment. const notifyActivated = () => { if (activatedRef.current) { return; } activatedRef.current = true; configRef.current.onActivate?.(); }; let cancelled = false; // Attach the engine: synchronously from the warm shared cache (a later block // on the page, or a test pre-warm), otherwise via the loader. Fail open on a // load error — leave the block as read-only plain text rather than crash. const load = () => { const warmModule = peekEditingEngine(); if (warmModule) { attach(warmModule.createEditableEngine); return; } Promise.resolve(loadEditingEngine(loader)).then(mod => { if (!cancelled) { attach(mod.createEditableEngine); } }).catch(() => {}); }; if ((config.activation ?? 'eager') === 'eager') { notifyActivated(); load(); return () => { cancelled = true; }; } // 'interaction': defer attaching (and thus `contentEditable`) until the user // engages the block, regardless of whether the engine is already cached. // Hover (pointerenter) warms the chunk so the eventual commit is instant; // focus and pointerdown commit (load + attach). const element = elementRef.current; const warm = () => { notifyActivated(); preloadEditingEngine(loader).catch(() => {}); }; const commit = () => { notifyActivated(); load(); }; element.addEventListener('pointerenter', warm); element.addEventListener('pointerdown', commit); element.addEventListener('focus', commit); return () => { cancelled = true; element.removeEventListener('pointerenter', warm); element.removeEventListener('pointerdown', commit); element.removeEventListener('focus', commit); }; // `config.disabled` drives the re-run once the block becomes editable; the // refs the effect reads are stable (and a ref can't be a dependency), so they // are intentionally omitted. // eslint-disable-next-line react-hooks/exhaustive-deps }, [config.disabled, config.engineLoader, config.activation]); // Per-render observe + caret-restore, delegated to the engine once it exists. React.useLayoutEffect(() => { if (typeof window === 'undefined' || !engine) { return undefined; } return engine.observeAndRestore(); }); // contentEditable setup + handler binding, delegated to the engine. Re-runs // once the engine resolves and on `disabled`/`indentation` changes (the engine // re-reads them and the previous cleanup detaches contentEditable first). React.useLayoutEffect(() => { if (typeof window === 'undefined' || !engine) { return undefined; } return engine.setup(); }, [engine, config.disabled, config.indentation]); return edit; };