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..584035d1a 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();
@@ -36,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);
@@ -86,8 +134,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();
}
};
@@ -113,8 +166,9 @@ function CodeBlockNodeView({ node, updateAttributes, selected }: NodeViewProps)
{isEditing ? (
setDraft(next)}
filter={filterLanguages}
>
-
-
+
+
{(item: string) => (
@@ -179,6 +237,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.
*
@@ -188,6 +270,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 3ff197c87..44a9419cf 100644
--- a/packages/admin/tests/editor/CodeBlockNode.test.ts
+++ b/packages/admin/tests/editor/CodeBlockNode.test.ts
@@ -15,7 +15,12 @@ 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,
+ ignoreCodeBlockMutation,
+ LANGUAGE_PICKER_POPUP_CLASS,
+ shouldDismissPicker,
+} from "../../src/components/editor/CodeBlockNode";
describe("CodeBlockExtension", () => {
let editor: Editor;
@@ -75,3 +80,93 @@ 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);
+ });
+});
+
+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);
+ });
+});