UNPKG

@mdxeditor/editor

Version:

React component for rich text markdown editing

369 lines (368 loc) 12.5 kB
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 };