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); + }); +});