@mdxeditor/editor
Version:
React component for rich text markdown editing
369 lines (368 loc) • 12.5 kB
JavaScript
import { Cell, debounceTime, useRealm, useCellValue, useCell } from "@mdxeditor/gurx";
import { getNearestEditorFromDOMNode, $getNearestNodeFromDOMNode, $isTextNode, $createRangeSelection } from "lexical";
import { realmPlugin } from "../../RealmWithPlugins.js";
import { contentEditableRef$, createRootEditorSubscription$ } from "../core/index.js";
const EmptyTextNodeIndex = {
allText: "",
nodeIndex: [],
offsetIndex: []
};
const editorSearchTerm$ = Cell("");
const editorSearchRanges$ = Cell([]);
const editorSearchCursor$ = Cell(0);
const editorSearchTextNodeIndex$ = Cell(EmptyTextNodeIndex);
const searchOpen$ = Cell(false);
const editorSearchTermDebounced$ = Cell("", (realm) => {
realm.link(editorSearchTermDebounced$, realm.pipe(editorSearchTerm$, realm.transformer(debounceTime(250))));
});
const editorSearchScrollableContent$ = Cell(
null,
(r) => r.sub(contentEditableRef$, (cref) => {
var _a;
r.pub(editorSearchScrollableContent$, ((_a = cref == null ? void 0 : cref.current) == null ? void 0 : _a.parentNode) ?? null);
})
);
const MDX_SEARCH_NAME = "MdxSearch";
const MDX_FOCUS_SEARCH_NAME = "MdxFocusSearch";
const debouncedIndexer$ = Cell(EmptyTextNodeIndex, (realm) => {
realm.link(debouncedIndexer$, realm.pipe(editorSearchTextNodeIndex$, realm.transformer(debounceTime(250))));
});
function* searchText(allText, searchQuery) {
if (!searchQuery) {
return;
}
let regex;
try {
regex = new RegExp(searchQuery, "gi");
} catch (e) {
console.error("Invalid search pattern:", e);
return;
}
let match;
while ((match = regex.exec(allText)) !== null) {
if (match[0].length === 0) {
if (regex.lastIndex === match.index) {
regex.lastIndex++;
}
continue;
}
const start = match.index;
const end = start + match[0].length - 1;
yield [start, end];
}
}
function indexAllTextNodes(root) {
var _a;
let allText = "";
const nodeIndex = [];
const offsetIndex = [];
if (!root) {
return { allText: "", nodeIndex, offsetIndex };
}
const contentSelector = "p, h1, h2, h3, h4, h5, h6, li, code, pre";
const treeWalker = document.createTreeWalker(
root,
NodeFilter.SHOW_TEXT,
// The corrected heuristic: accept any text node that is a descendant of a valid content container.
(node) => {
var _a2;
if ((_a2 = node.parentElement) == null ? void 0 : _a2.closest(contentSelector)) {
return NodeFilter.FILTER_ACCEPT;
}
return NodeFilter.FILTER_REJECT;
}
);
let currentNode;
while (currentNode = treeWalker.nextNode()) {
const nodeContent = ((_a = currentNode.textContent) == null ? void 0 : _a.normalize("NFKD")) ?? currentNode.textContent ?? "";
for (let i = 0; i < nodeContent.length; i++) {
nodeIndex.push(currentNode);
offsetIndex.push(i);
allText += nodeContent[i] ?? "";
}
}
return { allText, nodeIndex, offsetIndex };
}
function* rangeSearchScan(searchQuery, { allText, offsetIndex, nodeIndex }) {
for (const [start, end] of searchText(allText, searchQuery)) {
const startOffset = offsetIndex[start];
const endOffset = offsetIndex[end];
const startNode = nodeIndex[start];
const endNode = nodeIndex[end];
const range = new Range();
if (startNode === void 0 || endNode === void 0 || startOffset === void 0 || endOffset === void 0) {
throw new Error("Invalid range: startNode, endNode, startOffset, or endOffset is undefined.");
}
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset + 1);
yield range;
}
}
const focusHighlightRange = (range) => {
CSS.highlights.delete(MDX_FOCUS_SEARCH_NAME);
if (range)
CSS.highlights.set(MDX_FOCUS_SEARCH_NAME, new Highlight(range));
};
const highlightRanges = (ranges) => {
CSS.highlights.set(MDX_SEARCH_NAME, new Highlight(...ranges));
};
const resetHighlights = () => {
CSS.highlights.delete(MDX_SEARCH_NAME);
CSS.highlights.delete(MDX_FOCUS_SEARCH_NAME);
};
const scrollToRange = (range, contentEditable, options) => {
const ignoreIfInView = (options == null ? void 0 : options.ignoreIfInView) ?? true;
const behavior = (options == null ? void 0 : options.behavior) ?? "smooth";
const [first] = range.getClientRects();
if (!contentEditable) {
console.warn("No content-editable element found for scrolling.");
return;
}
if (!first) {
console.warn("No client rect found for the range, cannot scroll.");
return;
}
const containerRect = contentEditable.getBoundingClientRect();
const topRelativeToContainer = first.top - containerRect.top;
const bottomRelativeToContainer = first.bottom - containerRect.top;
if (ignoreIfInView) {
const rangeTop = topRelativeToContainer + contentEditable.scrollTop;
const rangeBottom = bottomRelativeToContainer + contentEditable.scrollTop;
const visibleTop = contentEditable.scrollTop;
const visibleBottom = visibleTop + contentEditable.clientHeight;
const inView = rangeTop >= visibleTop && rangeBottom <= visibleBottom;
if (inView)
return;
}
const top = topRelativeToContainer + contentEditable.scrollTop - first.height;
contentEditable.scrollTo({ top, behavior });
};
function isSimilarRange(range1, range2) {
return range1.startContainer === range2.startContainer && range1.startOffset === range2.startOffset;
}
function replaceTextInRange(range, str, onUpdate) {
const startDomNode = range.startContainer;
const endDomNode = range.endContainer;
const startOffset = range.startOffset;
const endOffset = range.endOffset;
const editor = getNearestEditorFromDOMNode(startDomNode);
if (!editor) {
console.warn("No editor found for the provided DOM node.");
return;
}
editor.update(
() => {
const startLexicalNode = $getNearestNodeFromDOMNode(startDomNode);
const endLexicalNode = $getNearestNodeFromDOMNode(endDomNode);
if (!$isTextNode(startLexicalNode) || !$isTextNode(endLexicalNode)) {
return;
}
try {
const selection = $createRangeSelection();
selection.anchor.set(startLexicalNode.getKey(), startOffset, "text");
selection.focus.set(endLexicalNode.getKey(), endOffset, "text");
selection.insertText(str);
} catch (e) {
console.warn("Error replacing text in the editor:", e);
if (onUpdate) {
onUpdate();
}
}
},
{
onUpdate
}
);
}
function useEditorSearch() {
const realm = useRealm();
const ranges = useCellValue(editorSearchRanges$);
const cursor = useCellValue(editorSearchCursor$);
const search = useCellValue(editorSearchTerm$);
const currentRange = ranges[cursor - 1] ?? null;
const contentEditable = useCellValue(editorSearchScrollableContent$);
const [isSearchOpen, setIsSearchOpen] = useCell(searchOpen$);
const openSearch = () => {
setIsSearchOpen(true);
};
const closeSearch = () => {
setIsSearchOpen(false);
};
const toggleSearch = () => {
setIsSearchOpen(!isSearchOpen);
};
const rangeCount = ranges.length;
const scrollToRangeOrIndex = (range, options) => {
const scrollRange = typeof range === "number" ? ranges[range - 1] : range;
if (!scrollRange) {
throw new Error("Error scrolling to range, range does not exist");
}
scrollToRange(scrollRange, contentEditable, options);
};
const setSearch = (term) => {
if ((term ?? "") !== search) {
realm.pub(editorSearchCursor$, 0);
}
realm.pub(editorSearchTermDebounced$, term ?? "");
};
const next = () => {
if (!ranges.length)
return;
const newVal = cursor % ranges.length + 1;
scrollToRangeOrIndex(newVal);
realm.pub(editorSearchCursor$, newVal);
};
const prev = () => {
if (!ranges.length)
return;
const newVal = cursor <= 1 ? ranges.length : cursor - 1;
scrollToRangeOrIndex(newVal);
realm.pub(editorSearchCursor$, newVal);
};
const replace = (str, onUpdate) => {
const currentRange2 = ranges[cursor - 1];
if (!currentRange2) {
return;
}
const { startContainer, startOffset } = currentRange2 ?? {};
replaceTextInRange(currentRange2, str, () => {
const unsub = realm.sub(editorSearchRanges$, (newRanges) => {
unsub();
if (isSimilarRange(newRanges[cursor - 1] ?? {}, {
startOffset,
startContainer
})) {
realm.pub(editorSearchCursor$, (cursor + 1) % (newRanges.length + 1) || 1);
}
});
onUpdate == null ? void 0 : onUpdate();
});
};
const replaceAll = (str, onUpdate) => {
const runReplaceAll = () => {
let ticks = 0;
for (let i = ranges.length - 1; i >= 0; i--) {
const textReplaceRange = ranges[i];
if (!textReplaceRange) {
throw new Error("error replacing all text range does not exist");
}
replaceTextInRange(textReplaceRange, str, () => {
ticks++;
if (ticks >= ranges.length) {
onUpdate == null ? void 0 : onUpdate();
}
});
}
};
if (typeof requestIdleCallback === "function") {
requestIdleCallback(runReplaceAll);
} else {
setTimeout(runReplaceAll, 0);
}
};
return {
next,
prev,
total: rangeCount,
cursor,
setSearch,
search,
currentRange,
isSearchOpen,
setIsSearchOpen,
openSearch,
closeSearch,
toggleSearch,
ranges,
scrollToRangeOrIndex,
replace,
replaceAll
};
}
const searchPlugin = realmPlugin({
//TODO: ensure proper event cleanup
init(realm) {
if (typeof CSS.highlights === "undefined") {
console.warn("CSS.highlights is not supported in this browser. Search functionality will be limited.");
return;
}
realm.sub(editorSearchCursor$, (cursor) => {
const ranges = realm.getValue(editorSearchRanges$);
focusHighlightRange(ranges[cursor - 1]);
});
const updateHighlights = (searchQuery, textNodeIndex) => {
if (!searchQuery) {
realm.pub(editorSearchCursor$, 0);
realm.pub(editorSearchRanges$, []);
resetHighlights();
return;
}
const ranges = Array.from(rangeSearchScan(searchQuery, textNodeIndex));
realm.pub(editorSearchRanges$, ranges);
highlightRanges(ranges);
if (ranges.length) {
const currentCursor = realm.getValue(editorSearchCursor$) || 1;
focusHighlightRange(ranges[currentCursor - 1]);
realm.pub(editorSearchCursor$, currentCursor);
const scrollRange = ranges[currentCursor - 1];
if (!scrollRange)
throw new Error("error updating highlights, scroll range does not exist");
const contentEditable = realm.getValue(editorSearchScrollableContent$);
scrollToRange(scrollRange, contentEditable, {
ignoreIfInView: true
});
} else {
resetHighlights();
}
};
realm.sub(editorSearchTextNodeIndex$, (textNodeIndex) => {
updateHighlights(realm.getValue(editorSearchTerm$), textNodeIndex);
});
realm.sub(editorSearchTerm$, (searchQuery) => {
updateHighlights(searchQuery, realm.getValue(editorSearchTextNodeIndex$));
});
realm.pub(createRootEditorSubscription$, (editor) => {
let observer = null;
return editor.registerRootListener((rootElement) => {
if (observer) {
observer.disconnect();
observer = null;
}
if (rootElement) {
const initialIndex = indexAllTextNodes(rootElement);
realm.pub(editorSearchTextNodeIndex$, initialIndex);
observer = new MutationObserver(() => {
const newIndex = indexAllTextNodes(rootElement);
if (realm.getValue(searchOpen$)) {
realm.pub(editorSearchTextNodeIndex$, newIndex);
} else {
realm.pub(debouncedIndexer$, newIndex);
}
});
observer.observe(rootElement, {
childList: true,
subtree: true,
characterData: true
});
return () => observer == null ? void 0 : observer.disconnect();
}
});
});
}
});
export {
EmptyTextNodeIndex,
MDX_FOCUS_SEARCH_NAME,
MDX_SEARCH_NAME,
debouncedIndexer$,
editorSearchCursor$,
editorSearchRanges$,
editorSearchScrollableContent$,
editorSearchTerm$,
editorSearchTermDebounced$,
editorSearchTextNodeIndex$,
rangeSearchScan,
searchOpen$,
searchPlugin,
useEditorSearch
};