Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-codeblock-language-picker.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 36 additions & 2 deletions packages/admin/src/components/editor/CodeBlockNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
}
};
Expand Down Expand Up @@ -123,7 +157,7 @@ function CodeBlockNodeView({ node, updateAttributes, selected }: NodeViewProps)
filter={filterLanguages}
>
<Autocomplete.InputGroup size="sm" placeholder={t`Language`} />
<Autocomplete.Content sideOffset={4}>
<Autocomplete.Content sideOffset={4} className={LANGUAGE_PICKER_POPUP_CLASS}>
<Autocomplete.List>
{(item: string) => (
<Autocomplete.Item key={item} value={item}>
Expand Down
54 changes: 53 additions & 1 deletion packages/admin/tests/editor/CodeBlockNode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
});
});
Loading