@mui/internal-docs-infra
Version:
MUI Infra - internal documentation creation tools.
222 lines (212 loc) • 9.36 kB
JavaScript
import * as React from 'react';
// `stringOrHastToString` is already part of the always-loaded `useCode` shell
// (via `useCopyFunctionality`/`Pre`). Passing it into the lazy editing engine
// keeps that engine chunk from statically pulling it (and `hastDecompress`).
import { stringOrHastToString } from "../pipeline/hastUtils/index.mjs";
import { useCodeContext } from "../CodeProvider/CodeContext.mjs";
import { peekEditingEngine, loadEditingEngine, preloadEditingEngine, resetEditingEngineCache } from "./editingEngineCache.mjs";
// The edit-time runtime (`analyzeSource`/`shiftComments`/`toControlledCode`) lives
// in the shared `./EditingEngine` chunk — the SAME chunk `useEditable` loads, so
// they download together. The shell warms it as soon as a block is editable (the
// effect below); by the time the user can type — which itself waits on the
// editable engine — the engine is ready and `setSource` runs synchronously. A
// read-only block never loads it.
/**
* Warms the editing engine so the next edit applies synchronously. Back-compat
* alias for {@link preloadEditingEngine}; the cache is shared with `useEditable`.
*/
export const preloadSourceEditingEngine = preloadEditingEngine;
/** Clears the shared editing-engine cache. Back-compat alias; for tests. */
export const resetSourceEditingEngineCache = resetEditingEngineCache;
/**
* Internal `setSource` shape used by the editing pipeline. The 3rd and 4th
* arguments (caret position, pre-parsed HAST) are wired between sibling
* hooks (`useEditable` → `Pre` → `useSourceEditing`) and are NOT part of
* the public `useCode` contract — host code should treat `setSource` as
* `(source, fileName?) => void`.
*/
/**
* Hook for managing source code editing functionality.
*
* Returns a `setSource(source, fileName?)` callback that updates the correct file
* (main or extra) within the controlled code for the current variant.
* If `fileName` is omitted, the currently selected file is assumed.
*/
export function useSourceEditing({
context,
selectedVariantKey,
effectiveCode,
selectedVariant,
disabled
}) {
const contextSetCode = context?.setCode;
// The provider's editing-engine loader (shared with `useEditable`); dedupes the
// chunk fetch page-wide. Undefined without a provider → the built-in default.
const {
editingEngineLoader
} = useCodeContext();
// Monotonic token bumped by every `setSource`/`reset`. A cold first edit
// defers its commit into a microtask; if a later edit or a `reset` happens
// before the engine resolves, the stale deferred commit must NOT run (it would
// re-apply a superseded edit and, after a reset, reverse it). The deferred
// callback captures the token at schedule time and bails if it changed.
const editTokenRef = React.useRef(0);
const setSource = React.useCallback((source, fileName, position, preParsed) => {
if (!contextSetCode) {
console.warn('setCode is not available in the current context. Ensure you are using CodeControllerContext.');
return;
}
// Mark this edit as the latest; a deferred (cold) commit checks this.
editTokenRef.current += 1;
const editToken = editTokenRef.current;
// Stash any pre-computed parse result against the resolved file name
// BEFORE the controlled-code update commits, so that the synchronous
// `parseControlledCode` pass triggered by the resulting React render
// can reuse the cached HAST instead of re-parsing the new source.
// The cache is owned by `CodeHighlighterClient` (per-highlighter
// state) and exposed on the context for both the writer (here) and
// reader (`parseControlledCode`).
const resolvedFileName = fileName ?? selectedVariant?.fileName;
const preParsedCache = context?.preParsedCache;
if (preParsed !== undefined && preParsedCache && resolvedFileName) {
preParsedCache.set(resolvedFileName, {
source,
hast: preParsed
});
}
const applyUpdate = engine => {
contextSetCode(currentCode => {
const newCode = currentCode ? {
...currentCode
} : engine.toControlledCode(effectiveCode, selectedVariantKey, context?.fallbacks, stringOrHastToString);
const variant = newCode[selectedVariantKey];
if (!variant) {
return newCode;
}
const effectiveFileName = fileName ?? selectedVariant?.fileName;
const isMainFile = effectiveFileName === selectedVariant?.fileName;
// Recompute the edited file's comment/window state for `source`.
// `shiftComments` shifts the comment map by the line delta relative to
// the edit position; on undo/redo the `position.history` flag tells it
// to reverse the edit (re-inserting deleted lines after the pre-edit
// caret), and its `collapseMap` restores comments that a deletion
// collapsed — so undo/redo is reversed from existing state, with no
// per-edit snapshots kept. `entry` is the file being edited (main
// variant or an extra file); both expose the same
// source/comments/collapseMap/totalLines/emptyLines shape.
const deriveFileState = (entry, nextSource) => {
// `analyzeSource` ignores a single trailing newline, so the host
// source (often unterminated) and the live contentEditable text
// (always terminated) report consistent line counts — the delta is
// 0 when no line was actually added or removed.
const {
totalLines,
emptyLines
} = engine.analyzeSource(nextSource);
if (!position) {
return {
comments: undefined,
collapseMap: undefined,
totalLines,
emptyLines
};
}
const oldLineCount = entry?.totalLines ?? (entry?.source != null ? engine.analyzeSource(entry.source).totalLines : 0);
const {
comments,
collapseMap
} = engine.shiftComments(entry?.comments, totalLines - oldLineCount, position, entry?.collapseMap, entry?.emptyLines);
return {
comments,
collapseMap,
totalLines,
emptyLines
};
};
if (isMainFile) {
if (source === variant.source) {
return currentCode ?? newCode;
}
const {
totalLines,
emptyLines,
comments,
collapseMap
} = deriveFileState(variant, source);
newCode[selectedVariantKey] = {
...variant,
source,
totalLines,
emptyLines,
comments,
collapseMap
};
} else if (effectiveFileName) {
const extraEntry = variant.extraFiles?.[effectiveFileName];
if (source === extraEntry?.source) {
return currentCode ?? newCode;
}
const {
totalLines,
emptyLines,
comments,
collapseMap
} = deriveFileState(extraEntry, source);
newCode[selectedVariantKey] = {
...variant,
extraFiles: {
...variant.extraFiles,
[effectiveFileName]: {
...extraEntry,
source,
totalLines,
emptyLines,
comments,
collapseMap
}
}
};
}
return newCode;
});
};
// Apply synchronously from the warm cache (the common case — the warm
// effect below loads the engine as soon as the block is editable). On a
// cold first edit, defer this one update until the chunk resolves; later
// edits are synchronous.
const warmEngine = peekEditingEngine();
if (warmEngine) {
applyUpdate(warmEngine);
} else {
Promise.resolve(loadEditingEngine(editingEngineLoader)).then(loaded => {
// Bail if a later edit or a reset superseded this one while loading.
if (editTokenRef.current === editToken) {
applyUpdate(loaded);
}
}).catch(() => {});
}
}, [contextSetCode, selectedVariantKey, effectiveCode, selectedVariant, context?.preParsedCache, context?.fallbacks, editingEngineLoader]);
// Warm the edit-time runtime as soon as the block is editable, so the first
// edit applies synchronously (no flash). Read-only blocks never load it.
React.useEffect(() => {
if (peekEditingEngine() || !contextSetCode || disabled) {
return;
}
preloadEditingEngine(editingEngineLoader).catch(() => {});
}, [contextSetCode, disabled, editingEngineLoader]);
const reset = React.useCallback(() => {
if (!contextSetCode) {
console.warn('setCode is not available in the current context. Ensure you are using CodeControllerContext.');
return;
}
// Supersede any pending cold edit so it can't re-apply after this reset.
editTokenRef.current += 1;
contextSetCode(undefined);
}, [contextSetCode]);
const isEditable = !disabled && Boolean(contextSetCode) && Boolean(selectedVariant);
const canReset = !disabled && Boolean(contextSetCode);
return {
setSource: isEditable ? setSource : undefined,
reset: canReset ? reset : undefined
};
}