Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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.
94 changes: 89 additions & 5 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 All @@ -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
* `<Input passwordManagerIgnore>`, 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);
Expand Down Expand Up @@ -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();
}
};
Expand All @@ -113,17 +166,22 @@ function CodeBlockNodeView({ node, updateAttributes, selected }: NodeViewProps)
{isEditing ? (
<div
ref={popoverRef}
className="flex items-center gap-1 rounded-md border bg-kumo-overlay p-1 shadow-lg"
className={`flex items-center gap-1 rounded-md border bg-kumo-overlay p-1 shadow-lg ${PASSWORD_MANAGER_IGNORE_CLASS}`}
onKeyDown={handleKeyDown}
{...PASSWORD_MANAGER_IGNORE_ATTRS}
>
<Autocomplete
items={LANGUAGE_ITEMS}
value={draft}
onValueChange={(next: string) => setDraft(next)}
filter={filterLanguages}
>
<Autocomplete.InputGroup size="sm" placeholder={t`Language`} />
<Autocomplete.Content sideOffset={4}>
<Autocomplete.InputGroup
size="sm"
placeholder={t`Language`}
className={PASSWORD_MANAGER_IGNORE_CLASS}
/>
<Autocomplete.Content sideOffset={4} className={LANGUAGE_PICKER_POPUP_CLASS}>
<Autocomplete.List>
{(item: string) => (
<Autocomplete.Item key={item} value={item}>
Expand Down Expand Up @@ -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.
*
Expand All @@ -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),
});
},
});
97 changes: 96 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,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;
Expand Down Expand Up @@ -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);
});
});
Loading