From 38d7e339a24f916bb121fc38fe5451306ecd090a Mon Sep 17 00:00:00 2001 From: David Kaya Date: Wed, 25 Mar 2026 19:49:16 +0100 Subject: [PATCH] fix: replace imperative DOM overlay with React-based positioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The imperative approach inserted overlay elements directly into Lexical-managed CodeNode DOM elements. Lexical's MutationObserver detected the foreign DOM nodes, reconciled them away, which triggered update listeners that re-inserted them — causing an infinite loop that froze the app. The new approach renders overlay elements via React, positioned absolutely in a pointer-events:none container that sits over the content editable. Overlay positions are computed from each code element's bounding rect and updated via requestAnimationFrame on editor updates and scroll events. This avoids all Lexical DOM mutation and eliminates the freeze. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/renderer/components/MarkdownComposer.tsx | 163 +++++++++++-------- src/renderer/styles.css | 18 +- 2 files changed, 106 insertions(+), 75 deletions(-) diff --git a/src/renderer/components/MarkdownComposer.tsx b/src/renderer/components/MarkdownComposer.tsx index c8111f6..6f77cc7 100644 --- a/src/renderer/components/MarkdownComposer.tsx +++ b/src/renderer/components/MarkdownComposer.tsx @@ -241,90 +241,113 @@ function MarkdownPastePlugin() { return null; } -/** Attaches an imperative language-selector overlay to each CodeNode's DOM element. */ +/** Renders language-selector overlays for CodeNodes outside Lexical's managed DOM. */ function CodeBlockLanguagePlugin() { const [editor] = useLexicalComposerContext(); + const containerRef = useRef(null); + const [codeBlocks, setCodeBlocks] = useState>([]); useEffect(() => { - const overlays = new Map(); - - function syncCodeBlock(nodeKey: string) { - const codeElement = editor.getElementByKey(nodeKey); - if (!codeElement) return; - - let lang: string | null = null; + function collectCodeBlocks() { editor.getEditorState().read(() => { - const node = $getNodeByKey(nodeKey); - if ($isCodeNode(node)) lang = node.getLanguage() ?? null; - }); - - let entry = overlays.get(nodeKey); - if (!entry) { - const wrapper = document.createElement('div'); - wrapper.contentEditable = 'false'; - wrapper.className = 'mc-code-lang-overlay'; - - const select = document.createElement('select'); - select.className = 'mc-code-lang-select'; - for (const { value, label } of CODE_LANGUAGE_OPTIONS) { - const opt = document.createElement('option'); - opt.value = value; - opt.textContent = label; - select.appendChild(opt); + const blocks: Array<{ key: string; language: string }> = []; + for (const child of $getRoot().getChildren()) { + if ($isCodeNode(child)) { + blocks.push({ key: child.getKey(), language: child.getLanguage() ?? '' }); + } } - select.addEventListener('change', () => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - if ($isCodeNode(node)) node.setLanguage(select.value || undefined); - }); - requestAnimationFrame(() => editor.focus()); + setCodeBlocks((prev) => { + if ( + prev.length === blocks.length && + prev.every((b, i) => b.key === blocks[i].key && b.language === blocks[i].language) + ) { + return prev; + } + return blocks; }); - select.addEventListener('mousedown', (e) => e.stopPropagation()); - - wrapper.appendChild(select); - entry = { wrapper, select }; - overlays.set(nodeKey, entry); - } - - entry.select.value = lang ?? ''; - - // Re-insert if Lexical reconciled it away - if (!codeElement.contains(entry.wrapper)) { - codeElement.insertBefore(entry.wrapper, codeElement.firstChild); - } + }); } - function removeCodeBlock(nodeKey: string) { - const entry = overlays.get(nodeKey); - if (entry) { - entry.wrapper.remove(); - overlays.delete(nodeKey); - } - } - - const removeMutation = editor.registerMutationListener(CodeNode, (mutations) => { - for (const [nodeKey, type] of mutations) { - if (type === 'destroyed') removeCodeBlock(nodeKey); - else syncCodeBlock(nodeKey); - } - }); - - // Re-sync after every update to survive Lexical reconciliation - const removeUpdate = editor.registerUpdateListener(() => { - for (const nodeKey of overlays.keys()) { - syncCodeBlock(nodeKey); - } - }); - + const removeMutation = editor.registerMutationListener(CodeNode, collectCodeBlocks); + const removeUpdate = editor.registerUpdateListener(collectCodeBlocks); return () => { removeMutation(); removeUpdate(); - for (const entry of overlays.values()) entry.wrapper.remove(); - overlays.clear(); }; }, [editor]); - return null; + // Position overlays to match their corresponding code block elements + const positionOverlays = useCallback(() => { + const container = containerRef.current; + if (!container) return; + const containerRect = container.getBoundingClientRect(); + + for (const { key } of codeBlocks) { + const codeEl = editor.getElementByKey(key); + const overlayEl = container.querySelector(`[data-code-key="${key}"]`) as HTMLElement | null; + if (!codeEl || !overlayEl) continue; + + const codeRect = codeEl.getBoundingClientRect(); + overlayEl.style.top = `${codeRect.top - containerRect.top + 3}px`; + overlayEl.style.right = `${containerRect.right - codeRect.right + 4}px`; + } + }, [editor, codeBlocks]); + + // Reposition on editor updates, scroll, and resize + useEffect(() => { + if (codeBlocks.length === 0) return; + positionOverlays(); + + let rafId: number | null = null; + const schedulePosition = () => { + if (rafId !== null) cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(positionOverlays); + }; + + const removeUpdate = editor.registerUpdateListener(schedulePosition); + const rootElement = editor.getRootElement(); + rootElement?.addEventListener('scroll', schedulePosition); + + return () => { + removeUpdate(); + rootElement?.removeEventListener('scroll', schedulePosition); + if (rafId !== null) cancelAnimationFrame(rafId); + }; + }, [editor, codeBlocks, positionOverlays]); + + const handleLanguageChange = useCallback( + (nodeKey: string, newLanguage: string) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if ($isCodeNode(node)) node.setLanguage(newLanguage || undefined); + }); + requestAnimationFrame(() => editor.focus()); + }, + [editor], + ); + + if (codeBlocks.length === 0) return null; + + return ( +
+ {codeBlocks.map(({ key, language }) => ( +
+ +
+ ))} +
+ ); } /* ── Toolbar ──────────────────────────────────────────── */ @@ -526,6 +549,7 @@ export const MarkdownComposer = forwardRef + {children} @@ -534,7 +558,6 @@ export const MarkdownComposer = forwardRef - diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 006ad63..c66af0c 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -228,7 +228,6 @@ textarea { /* Code block */ .mc-code-block { display: block; - position: relative; background: rgba(24, 24, 27, 0.8); border: 1px solid #27272a; border-radius: 6px; @@ -241,12 +240,21 @@ textarea { overflow-x: auto; } -/* Language selector overlay inside code blocks */ +/* Language selector overlays positioned outside Lexical-managed DOM */ +.mc-code-overlays-container { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + z-index: 10; + overflow: hidden; +} + .mc-code-lang-overlay { position: absolute; - top: 3px; - right: 4px; - z-index: 1; + pointer-events: auto; user-select: none; }