From 31958a60618b156c4f07f2995bc5aa646009ea5b Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 29 May 2026 16:06:40 +0100 Subject: [PATCH 1/3] fix(admin): keep code block language picker open when using its dropdown The picker's outside-click handler closed it on any mousedown whose target was not inside popoverRef. But the Autocomplete suggestion list renders through Base UI's Portal at document.body, so selecting a language counted as an outside click and tore the picker down before the selection committed -- the reported 'loses focus and closes' behaviour (#1200). Tag the portalled popup with a marker class and treat targets inside it as part of the picker, and ignore untrusted (synthetic) pointer events that browser extensions dispatch into inputs. Extracts the decision into shouldDismissPicker() with unit coverage. --- .changeset/fix-codeblock-language-picker.md | 5 ++ .../src/components/editor/CodeBlockNode.tsx | 38 ++++++++++++- .../admin/tests/editor/CodeBlockNode.test.ts | 54 ++++++++++++++++++- 3 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-codeblock-language-picker.md diff --git a/.changeset/fix-codeblock-language-picker.md b/.changeset/fix-codeblock-language-picker.md new file mode 100644 index 000000000..c2d81be5a --- /dev/null +++ b/.changeset/fix-codeblock-language-picker.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +Fix the code block language picker closing the moment you interact with its suggestion dropdown. The dropdown renders in a portal (outside the picker's DOM), so selecting a language -- or stray pointer events from browser extensions such as password managers -- was treated as an outside click and dismissed the picker before the choice was applied. diff --git a/packages/admin/src/components/editor/CodeBlockNode.tsx b/packages/admin/src/components/editor/CodeBlockNode.tsx index eae542ba5..3e88e80f3 100644 --- a/packages/admin/src/components/editor/CodeBlockNode.tsx +++ b/packages/admin/src/components/editor/CodeBlockNode.tsx @@ -26,6 +26,35 @@ import { CODE_BLOCK_LANGUAGES, languageLabel, normalizeLanguage } from "./codeBl const LANGUAGE_ITEMS = CODE_BLOCK_LANGUAGES.map((l) => l.label); +/** + * Marker class applied to the language dropdown's popup. The dropdown is + * rendered through Base UI's Portal, so its DOM lives at `document.body` -- + * outside the picker's `popoverRef`. We use this class to recognise it as + * part of the picker in the outside-click handler. + */ +export const LANGUAGE_PICKER_POPUP_CLASS = "emdash-language-picker-popup"; + +/** + * Decide whether a pointer event should dismiss the open language picker. + * + * Returns true only for events that land genuinely outside the picker. The + * subtlety (issue #1200): the Autocomplete suggestion list is portalled to + * `document.body`, so it is not a descendant of `popoverRef`. A naive + * "mousedown outside the popover closes it" check treats clicks on the + * dropdown -- including selecting a language -- as outside and tears the + * picker down before the selection commits, so it looks like the picker + * "loses focus and closes". Treat anything inside the portalled popup as + * part of the picker. + */ +export function shouldDismissPicker(target: Node | null, popover: HTMLElement | null): boolean { + if (!popover || !target) return false; + if (popover.contains(target)) return false; + if (target instanceof Element && target.closest(`.${LANGUAGE_PICKER_POPUP_CLASS}`)) { + return false; + } + return true; +} + function filterLanguages(item: string, query: string) { if (!query) return true; const needle = query.toLowerCase(); @@ -86,8 +115,13 @@ function CodeBlockNodeView({ node, updateAttributes, selected }: NodeViewProps) React.useEffect(() => { if (!isEditing) return undefined; const onMouseDown = (event: MouseEvent) => { + // Ignore synthetic events. Browser extensions (password managers, + // autofill) that inject into inputs dispatch untrusted pointer + // events; treating those as "the user clicked away" is what made + // the picker close mid-typing for some users (issue #1200). + if (!event.isTrusted) return; const target = event.target instanceof Node ? event.target : null; - if (popoverRef.current && target && !popoverRef.current.contains(target)) { + if (shouldDismissPicker(target, popoverRef.current)) { closePicker(); } }; @@ -123,7 +157,7 @@ function CodeBlockNodeView({ node, updateAttributes, selected }: NodeViewProps) filter={filterLanguages} > - + {(item: string) => ( diff --git a/packages/admin/tests/editor/CodeBlockNode.test.ts b/packages/admin/tests/editor/CodeBlockNode.test.ts index 3ff197c87..b1072a408 100644 --- a/packages/admin/tests/editor/CodeBlockNode.test.ts +++ b/packages/admin/tests/editor/CodeBlockNode.test.ts @@ -15,7 +15,11 @@ import { Editor } from "@tiptap/core"; import StarterKit from "@tiptap/starter-kit"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { CodeBlockExtension } from "../../src/components/editor/CodeBlockNode"; +import { + CodeBlockExtension, + LANGUAGE_PICKER_POPUP_CLASS, + shouldDismissPicker, +} from "../../src/components/editor/CodeBlockNode"; describe("CodeBlockExtension", () => { let editor: Editor; @@ -75,3 +79,51 @@ describe("CodeBlockExtension", () => { expect((node as { attrs?: { language?: string } }).attrs?.language).toBe("typescript"); }); }); + +describe("shouldDismissPicker", () => { + // The picker's outside-click handler uses this to decide whether a + // mousedown landed outside the picker. Regression for #1200: the + // Autocomplete suggestion list is portalled to document.body, so clicks + // on it must NOT be treated as "outside" or the picker tears down before + // the language selection commits. + let popover: HTMLElement; + + beforeEach(() => { + popover = document.createElement("div"); + document.body.appendChild(popover); + }); + + afterEach(() => { + popover.remove(); + document.querySelectorAll(`.${LANGUAGE_PICKER_POPUP_CLASS}`).forEach((el) => el.remove()); + }); + + it("does not dismiss when the target is inside the popover", () => { + const input = document.createElement("input"); + popover.appendChild(input); + expect(shouldDismissPicker(input, popover)).toBe(false); + }); + + it("does not dismiss when the target is inside the portalled dropdown", () => { + // The dropdown lives at document.body, NOT inside popover. + const popup = document.createElement("div"); + popup.className = LANGUAGE_PICKER_POPUP_CLASS; + const option = document.createElement("div"); + option.setAttribute("role", "option"); + popup.appendChild(option); + document.body.appendChild(popup); + expect(shouldDismissPicker(option, popover)).toBe(false); + }); + + it("dismisses when the target is genuinely outside the picker", () => { + const elsewhere = document.createElement("button"); + document.body.appendChild(elsewhere); + expect(shouldDismissPicker(elsewhere, popover)).toBe(true); + elsewhere.remove(); + }); + + it("does not dismiss when there is no popover or target", () => { + expect(shouldDismissPicker(null, popover)).toBe(false); + expect(shouldDismissPicker(document.createElement("div"), null)).toBe(false); + }); +}); From a04cf2c08536b6e2b09ad6c7dcd902ffc1fbeb62 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 29 May 2026 16:40:54 +0100 Subject: [PATCH 2/3] fix(admin): stop ProseMirror recreating the code block node view on extension DOM mutations Root cause of #1200, confirmed from a DOM-mutation trace: typing in the language picker with a browser extension active (password manager / autofill) mutates the DOM around the input, ProseMirror cannot reconcile the mutation and recreates the entire React node view, and the remount resets the picker's local `isEditing` state -- so it 'loses focus and closes'. It reproduces only with an extension (fine in Incognito and in clean automation), because only the extension mutates the overlay DOM. Add an `ignoreMutation` to the node view that ignores any mutation not inside the editable code content (`pre.emdash-code-block`), so the overlay -- and anything an extension injects into it -- never triggers a node view recreation. Selection and real code edits are still observed. Keeps the earlier outside-click hardening (which fixes a separate path: a genuine click on the portalled suggestion list). --- .../src/components/editor/CodeBlockNode.tsx | 28 +++++++++++- .../admin/tests/editor/CodeBlockNode.test.ts | 43 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/packages/admin/src/components/editor/CodeBlockNode.tsx b/packages/admin/src/components/editor/CodeBlockNode.tsx index 3e88e80f3..64e4c2e2e 100644 --- a/packages/admin/src/components/editor/CodeBlockNode.tsx +++ b/packages/admin/src/components/editor/CodeBlockNode.tsx @@ -213,6 +213,30 @@ function CodeBlockNodeView({ node, updateAttributes, selected }: NodeViewProps) ); } +/** + * Decide whether ProseMirror should ignore a DOM mutation inside this node + * view. + * + * The node view renders the editable code content *and* an interactive + * language-picker overlay. Browser extensions (password managers, autofill) + * mutate the DOM around the picker's input as you type; if ProseMirror + * reacts to those mutations it recreates the entire React node view, which + * throws away the picker's transient open state mid-edit and makes it + * vanish -- the reported "loses focus and closes" behaviour (issue #1200). + * + * Only mutations inside the editable code content (`pre.emdash-code-block`) + * are real edits ProseMirror must observe. Ignore everything else -- the + * overlay and anything injected into it -- so the node view is never + * recreated by picker UI or extension churn. Selection changes are always + * handled by ProseMirror. + */ +export function ignoreCodeBlockMutation(mutation: { type: string; target: Node }): boolean { + if (mutation.type === "selection") return false; + const node = mutation.target; + const el = node instanceof Element ? node : node.parentElement; + return !el?.closest("pre.emdash-code-block"); +} + /** * TipTap extension: code block with an inline language picker node view. * @@ -222,6 +246,8 @@ function CodeBlockNodeView({ node, updateAttributes, selected }: NodeViewProps) */ export const CodeBlockExtension = CodeBlock.extend({ addNodeView() { - return ReactNodeViewRenderer(CodeBlockNodeView); + return ReactNodeViewRenderer(CodeBlockNodeView, { + ignoreMutation: ({ mutation }) => ignoreCodeBlockMutation(mutation), + }); }, }); diff --git a/packages/admin/tests/editor/CodeBlockNode.test.ts b/packages/admin/tests/editor/CodeBlockNode.test.ts index b1072a408..44a9419cf 100644 --- a/packages/admin/tests/editor/CodeBlockNode.test.ts +++ b/packages/admin/tests/editor/CodeBlockNode.test.ts @@ -17,6 +17,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { CodeBlockExtension, + ignoreCodeBlockMutation, LANGUAGE_PICKER_POPUP_CLASS, shouldDismissPicker, } from "../../src/components/editor/CodeBlockNode"; @@ -127,3 +128,45 @@ describe("shouldDismissPicker", () => { expect(shouldDismissPicker(document.createElement("div"), null)).toBe(false); }); }); + +describe("ignoreCodeBlockMutation", () => { + // Regression for #1200: when a browser extension mutates the DOM around + // the language-picker overlay, ProseMirror must NOT recreate the node + // view (which would reset the picker's open state). Only edits to the + // editable code content should be observed. + let wrapper: HTMLElement; + let pre: HTMLElement; + let code: HTMLElement; + let overlayInput: HTMLInputElement; + + beforeEach(() => { + wrapper = document.createElement("div"); + pre = document.createElement("pre"); + pre.className = "emdash-code-block"; + code = document.createElement("code"); + pre.appendChild(code); + const overlay = document.createElement("div"); + overlayInput = document.createElement("input"); + overlay.appendChild(overlayInput); + wrapper.append(pre, overlay); + document.body.appendChild(wrapper); + }); + + afterEach(() => wrapper.remove()); + + it("observes mutations inside the editable code content", () => { + expect(ignoreCodeBlockMutation({ type: "childList", target: code })).toBe(false); + const textNode = document.createTextNode("x"); + code.appendChild(textNode); + expect(ignoreCodeBlockMutation({ type: "characterData", target: textNode })).toBe(false); + }); + + it("ignores mutations in the picker overlay (e.g. extension injection)", () => { + expect(ignoreCodeBlockMutation({ type: "childList", target: overlayInput })).toBe(true); + expect(ignoreCodeBlockMutation({ type: "attributes", target: overlayInput })).toBe(true); + }); + + it("always lets ProseMirror handle selection changes", () => { + expect(ignoreCodeBlockMutation({ type: "selection", target: overlayInput })).toBe(false); + }); +}); From bfaa04bdc2bc8c31daebeb09f3c407183cd189ce Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 29 May 2026 17:50:37 +0100 Subject: [PATCH 3/3] fix(admin): suppress password managers on the language input (the actual #1200 cause) The DOM-mutation trace from a real profile showed the trigger: the Keeper password manager (data-keeper-edited) activates on the language input, injects into it, and toggles a focus guard that marks the surrounding editor content aria-hidden. ProseMirror sees those foreign attribute mutations on its managed nodes, redraws the document, and recreates the code block node view -- which resets the picker's isEditing state and closes it. It only reproduces with the extension (fine in Incognito), which is why clean automation never triggered it. Mark the language input and its container with the password-manager-ignore class/attributes (keeper-ignore for Keeper; data-1p-ignore / data-lpignore / data-bwignore / data-form-type for the others). Mirrors Kumo's , which Autocomplete.InputGroup does not expose. This is the real fix; the earlier outside-click and ignoreMutation hardening in this PR remain as defense for the portalled-dropdown click path and stray overlay mutations. --- .../src/components/editor/CodeBlockNode.tsx | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/admin/src/components/editor/CodeBlockNode.tsx b/packages/admin/src/components/editor/CodeBlockNode.tsx index 64e4c2e2e..584035d1a 100644 --- a/packages/admin/src/components/editor/CodeBlockNode.tsx +++ b/packages/admin/src/components/editor/CodeBlockNode.tsx @@ -65,6 +65,25 @@ function filterLanguages(item: string, query: string) { return lang.aliases?.some((alias) => alias.toLowerCase().includes(needle)) ?? false; } +/** + * Tells password managers / autofill extensions to leave the language input + * alone. Keeper (seen in the wild setting `data-keeper-edited`) respects the + * `keeper-ignore` class; 1Password / LastPass / Bitwarden respect the data-* + * attributes. Without this, the extension injects into the input and toggles + * a focus guard that marks the surrounding editor content `aria-hidden`, + * which makes ProseMirror redraw and recreate the code block node view -- + * tearing the picker down mid-edit (issue #1200). Mirrors Kumo's + * ``, which `Autocomplete.InputGroup` does not + * expose, so we apply it by hand to the input and the picker container. + */ +const PASSWORD_MANAGER_IGNORE_CLASS = "keeper-ignore"; +const PASSWORD_MANAGER_IGNORE_ATTRS = { + "data-1p-ignore": "true", + "data-lpignore": "true", + "data-bwignore": "true", + "data-form-type": "other", +} as const; + function CodeBlockNodeView({ node, updateAttributes, selected }: NodeViewProps) { const { t } = useLingui(); const [isEditing, setIsEditing] = React.useState(false); @@ -147,8 +166,9 @@ function CodeBlockNodeView({ node, updateAttributes, selected }: NodeViewProps) {isEditing ? (
setDraft(next)} filter={filterLanguages} > - + {(item: string) => (