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
Original file line number Diff line number Diff line change
@@ -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");

Expand Down Expand Up @@ -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;
}

Expand All @@ -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);

Expand All @@ -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,
})
Expand Down
Original file line number Diff line number Diff line change
@@ -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<any, any, any>[] = [];

afterEach(() => {
for (const editor of editors) {
editor._tiptapEditor.destroy();
}
editors.length = 0;
});

function mount(editor: BlockNoteEditor<any, any, any>) {
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<any, any, any>) {
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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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 &&
Expand All @@ -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 &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]);
Expand All @@ -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();
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/util/keyboardEventKey.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading