From 869ee8a720906b1cee05dd360f78f0330d2ef336 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 6 Nov 2026 20:10:34 +0000 Subject: [PATCH] fix(core): ignore keydown events whose key is undefined Safari can dispatch trusted keydown events (autofill, dictation) with event.key unset. The handlers that read event.key.length now no-op instead of throwing. Fixes #3302 Co-authored-by: Rahul Kashyap --- .../InlineContentBoundaryEdit.ts | 21 +-- .../undefinedEventKey.test.ts | 131 ++++++++++++++++++ .../NodeSelectionKeyboard.ts | 13 +- .../SourceBlockWithPreview.ts | 13 +- packages/core/src/util/keyboardEventKey.ts | 15 ++ 5 files changed, 178 insertions(+), 15 deletions(-) create mode 100644 packages/core/src/extensions/InlineContentBoundaryEdit/undefinedEventKey.test.ts create mode 100644 packages/core/src/util/keyboardEventKey.ts diff --git a/packages/core/src/extensions/InlineContentBoundaryEdit/InlineContentBoundaryEdit.ts b/packages/core/src/extensions/InlineContentBoundaryEdit/InlineContentBoundaryEdit.ts index 01c0357d03..6cc72b6879 100644 --- a/packages/core/src/extensions/InlineContentBoundaryEdit/InlineContentBoundaryEdit.ts +++ b/packages/core/src/extensions/InlineContentBoundaryEdit/InlineContentBoundaryEdit.ts @@ -1,5 +1,6 @@ import { Plugin, PluginKey, Selection, TextSelection } from "prosemirror-state"; import { createExtension } from "../../editor/BlockNoteExtension.js"; +import { keyboardEventKey } from "../../util/keyboardEventKey.js"; const PLUGIN_KEY = new PluginKey("inline-content-boundary-edit"); @@ -48,14 +49,16 @@ export const InlineContentBoundaryEditExtension = createExtension( return false; } + // Safari autofill/dictation can send a keydown with no key. + const key = keyboardEventKey(event); + if (key === undefined) { + return false; + } + const isTypedChar = - event.key.length === 1 && !event.ctrlKey && !event.metaKey; + key.length === 1 && !event.ctrlKey && !event.metaKey; - if ( - !isTypedChar && - event.key !== "Backspace" && - event.key !== "Delete" - ) { + if (!isTypedChar && key !== "Backspace" && key !== "Delete") { return false; } @@ -73,10 +76,10 @@ export const InlineContentBoundaryEditExtension = createExtension( if (isTypedChar && node.content.size === 0) { const tr = view.state.tr.insert( contentFrom, - view.state.schema.text(event.key), + view.state.schema.text(key), ); tr.setSelection( - TextSelection.create(tr.doc, contentFrom + event.key.length), + TextSelection.create(tr.doc, contentFrom + key.length), ); view.dispatch(tr); @@ -88,7 +91,7 @@ export const InlineContentBoundaryEditExtension = createExtension( // inside it) so it stays editable. if ( node.content.size > 0 && - emptiesInlineContent(selection, event.key, { + emptiesInlineContent(selection, key, { from: contentFrom, to: contentTo, }) diff --git a/packages/core/src/extensions/InlineContentBoundaryEdit/undefinedEventKey.test.ts b/packages/core/src/extensions/InlineContentBoundaryEdit/undefinedEventKey.test.ts new file mode 100644 index 0000000000..0134e3dbec --- /dev/null +++ b/packages/core/src/extensions/InlineContentBoundaryEdit/undefinedEventKey.test.ts @@ -0,0 +1,131 @@ +import { NodeSelection } from "prosemirror-state"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js"; +import { defaultBlockSpecs } from "../../blocks/defaultBlocks.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { createBlockSpec } from "../../schema/blocks/createSpec.js"; + +// A block that opts into the source popup, so the capture-phase keydown +// listener in SourceBlockWithPreview runs its text-key branch. +const previewBlock = createBlockSpec( + { + type: "previewBlock", + propSchema: {}, + content: "plain", + }, + { + meta: { hasPreview: true }, + render: () => { + const dom = document.createElement("div"); + return { dom, contentDOM: dom }; + }, + }, +)(); + +const editors: BlockNoteEditor[] = []; + +afterEach(() => { + for (const editor of editors) { + editor._tiptapEditor.destroy(); + } + editors.length = 0; +}); + +function mount(editor: BlockNoteEditor) { + editor.mount(document.createElement("div")); + editors.push(editor); + return editor; +} + +// Safari autofill/dictation dispatches a trusted keydown whose `key` is +// undefined. `KeyboardEvent` itself never produces that value, so the test +// installs it the same way the bug report does. +function keydownWithoutKey(): KeyboardEvent { + const event = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, "key", { + configurable: true, + get: () => undefined, + }); + if (event.key !== undefined) { + throw new Error("failed to simulate a keydown whose key is undefined"); + } + return event; +} + +// jsdom reports exceptions thrown from event listeners as window "error" +// events instead of letting them escape `dispatchEvent`. Collect those so a +// handler that reads `event.key.length` fails this test directly. +function pressKeyless(editor: BlockNoteEditor) { + const event = keydownWithoutKey(); + const errors: unknown[] = []; + const onError = (errorEvent: ErrorEvent) => { + errors.push(errorEvent.error); + errorEvent.preventDefault(); + }; + window.addEventListener("error", onError); + try { + editor.prosemirrorView.dom.dispatchEvent(event); + } finally { + window.removeEventListener("error", onError); + } + return { event, errors }; +} + +describe("keydown events whose key is undefined", () => { + it("does not throw from a text cursor (InlineContentBoundaryEdit)", () => { + const editor = mount( + BlockNoteEditor.create({ + initialContent: [{ id: "p", type: "paragraph", content: "hello" }], + }), + ); + editor.setTextCursorPosition("p", "end"); + const before = editor.document; + + const { event, errors } = pressKeyless(editor); + + expect(errors).toEqual([]); + expect(event.defaultPrevented).toBe(false); + expect(editor.document).toEqual(before); + }); + + it("does not throw from a node selection (NodeSelectionKeyboard)", () => { + const editor = mount( + BlockNoteEditor.create({ + initialContent: [{ id: "img", type: "image" }], + }), + ); + editor.setTextCursorPosition("img"); + expect(editor.prosemirrorState.selection).toBeInstanceOf(NodeSelection); + const before = editor.document; + + const { event, errors } = pressKeyless(editor); + + expect(errors).toEqual([]); + expect(event.defaultPrevented).toBe(false); + expect(editor.document).toEqual(before); + }); + + it("does not throw on a closed source preview (SourceBlockWithPreview)", () => { + const editor = mount( + BlockNoteEditor.create({ + schema: BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs, previewBlock }, + }), + initialContent: [{ id: "src", type: "previewBlock", content: "x" }], + }), + ); + editor.setTextCursorPosition("src", "end"); + expect(editor.getTextCursorPosition().block.type).toBe("previewBlock"); + const before = editor.document; + + const { event, errors } = pressKeyless(editor); + + expect(errors).toEqual([]); + expect(event.defaultPrevented).toBe(false); + expect(editor.document).toEqual(before); + }); +}); diff --git a/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.ts b/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.ts index d0f2b26796..6e5f03881f 100644 --- a/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.ts +++ b/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.ts @@ -9,6 +9,7 @@ import { createExtension, type ExtensionOptions, } from "../../editor/BlockNoteExtension.js"; +import { keyboardEventKey } from "../../util/keyboardEventKey.js"; const PLUGIN_KEY = new PluginKey("node-selection-keyboard"); // By default, typing with a node selection active will cause ProseMirror to @@ -34,6 +35,12 @@ export const NodeSelectionKeyboardExtension = createExtension( key: PLUGIN_KEY, props: { handleKeyDown: (view, event) => { + // Safari autofill/dictation can send a keydown with no key. + const key = keyboardEventKey(event); + if (key === undefined) { + return false; + } + // Checks for node selection const { selection } = view.state; if (selection instanceof NodeSelection) { @@ -44,7 +51,7 @@ export const NodeSelectionKeyboardExtension = createExtension( // Make room to type below a contentless block at the document // end, while preserving gap cursors everywhere else. if ( - event.key === "ArrowDown" && + key === "ArrowDown" && !event.shiftKey && !event.altKey && !event.isComposing && @@ -69,14 +76,14 @@ export const NodeSelectionKeyboardExtension = createExtension( } } // Checks if key press is alphanumeric - if (event.key.length === 1) { + if (key.length === 1) { event.preventDefault(); return true; } // Checks if key press is Enter if ( - event.key === "Enter" && + key === "Enter" && !event.isComposing && !event.shiftKey && !event.altKey && diff --git a/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts b/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts index def3509cee..213123aa63 100644 --- a/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts +++ b/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts @@ -6,6 +6,7 @@ import { createStore, } from "../../editor/BlockNoteExtension.js"; import { Block } from "../../blocks/index.js"; +import { keyboardEventKey } from "../../util/keyboardEventKey.js"; /** * A single editor-wide extension that drives the source popup for blocks that @@ -163,7 +164,13 @@ export const SourceBlockWithPreviewExtension = createExtension( return; } - if (event.key === "Backspace" || event.key === "Delete") { + // Safari autofill/dictation can send a keydown with no key. + const key = keyboardEventKey(event); + if (key === undefined) { + return; + } + + if (key === "Backspace" || key === "Delete") { event.preventDefault(); event.stopImmediatePropagation(); editor.removeBlocks([block.id]); @@ -172,8 +179,8 @@ export const SourceBlockWithPreviewExtension = createExtension( } if ( - (event.key.length === 1 && !event.ctrlKey && !event.metaKey) || - event.key === "Tab" + (key.length === 1 && !event.ctrlKey && !event.metaKey) || + key === "Tab" ) { event.preventDefault(); event.stopImmediatePropagation(); diff --git a/packages/core/src/util/keyboardEventKey.ts b/packages/core/src/util/keyboardEventKey.ts new file mode 100644 index 0000000000..f6e8b2591a --- /dev/null +++ b/packages/core/src/util/keyboardEventKey.ts @@ -0,0 +1,15 @@ +/** + * Key of a keyboard event, or `undefined` when the event does not carry one. + * + * The DOM types `KeyboardEvent.key` as `string`. Safari still dispatches + * trusted `keydown` events — autofill, dictation, and similar — whose `key` + * is `undefined`. Handlers that read `key.length` throw on those events. No + * keymap can match a keydown without a key; the text they carry arrives + * through `beforeinput`. + */ +export function keyboardEventKey(event: KeyboardEvent): string | undefined { + // Widening admits the value Safari actually produces. `typeof` then rejects + // it, so callers no-op instead of reading `key.length`. + const key: string | undefined = event.key; + return typeof key === "string" ? key : undefined; +}