@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
143 lines (128 loc) • 5.79 kB
text/typescript
import { Selection, TextSelection } from "prosemirror-state";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor";
import {
createExtension,
createStore,
} from "../../editor/BlockNoteExtension.js";
/**
* Inline-content counterpart of {@link SourceBlockWithPreviewExtension}. A
* single editor-wide extension that drives the source popup for inline content
* that renders a preview. Which inline content it activates on is decided by
* each spec's `meta.hasPreview` flag, so individual inline content opts in
* rather than the extension being configured with a type.
*
* Unlike the block version, the popup isn't toggled with a separate state flag:
* it's open exactly when the selection is inside the inline content's source.
* The store therefore only tracks which inline content (by its position) holds
* the selection - moving the selection in opens its popup, moving it out closes
* it. Since the source popup is always laid out (just hidden via opacity), the
* cursor can navigate into and out of it with the arrow keys as usual.
*
* The extension is registered once (it's a default extension) and is a no-op
* when no inline content declares `meta.hasPreview`.
*/
export const SourceInlineContentWithPreviewExtension = createExtension(
({ editor }: { editor: BlockNoteEditor<any, any, any> }) => {
const store = createStore<{
selected: number | undefined;
}>({
selected: undefined,
});
// Inline content has a preview iff its spec's implementation declares
// `meta.hasPreview`.
const nodeHasPreview = (nodeName: string) =>
!!editor.schema.inlineContentSpecs[nodeName]?.implementation?.meta
?.hasPreview;
// Moves the selection out of the inline content, to just `"before"` or
// `"after"` it, which closes the popup via the selection-change handler
// below. Lets the keyboard commit-and-exit the source the same way arrowing
// past its edge does, keeps Enter from splitting the block while editing the
// source, and lets the up/down arrows step out of the source rather than
// staying trapped inside it.
const moveSelectionOut =
(direction: "before" | "after") =>
({ editor }: { editor: BlockNoteEditor<any, any, any> }) => {
const { $from } = editor.prosemirrorState.selection;
const node = $from.node();
if (!nodeHasPreview(node.type.name)) {
return false;
}
const view = editor.prosemirrorView!;
const selection = Selection.near(
view.state.doc.resolve(
direction === "before" ? $from.before() : $from.after(),
),
direction === "before" ? -1 : 1,
);
view.dispatch(view.state.tr.setSelection(selection));
return true;
};
return {
key: "sourceInlineContentWithPreview",
store,
keyboardShortcuts: {
Enter: moveSelectionOut("after"),
"Shift-Enter": moveSelectionOut("after"),
Escape: moveSelectionOut("after"),
ArrowUp: moveSelectionOut("before"),
ArrowDown: moveSelectionOut("after"),
// While editing the source, selects the whole source instead of the
// whole document.
"Mod-a": ({ editor }) => {
const { $from } = editor.prosemirrorState.selection;
if (!nodeHasPreview($from.node().type.name)) {
return false;
}
const view = editor.prosemirrorView!;
view.dispatch(
view.state.tr.setSelection(
TextSelection.create(view.state.doc, $from.start(), $from.end()),
),
);
return true;
},
},
mount: ({ dom, signal }) => {
// The popup is open exactly when the selection is inside the inline
// content, so we just track which inline content (if any) holds it.
const unsubscribeSelectionChange = editor.onSelectionChange(() => {
const { $from } = editor.prosemirrorState.selection;
const node = $from.node();
store.setState({
selected: nodeHasPreview(node.type.name)
? $from.before()
: undefined,
});
});
signal.addEventListener("abort", unsubscribeSelectionChange);
// Sets `visibility: hidden` on the popup for a single frame when pressing up/down arrow
// keys. The popup is normally hidden through `opacity: 0`, which means it's still visible
// to the browser for navigation. Therefore, the up/down arrows can sometimes move the
// selection into the popup from unexpected positions, such as on the same line. Setting
// `visibility: hidden` makes the browser ignore it when determining the new selection.
// TODO: This is hacky, we should find a cleaner solution.
const handleVerticalArrow = (event: KeyboardEvent) => {
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") {
return;
}
// When the selection is already inside a source, leave navigation
// (moving within or out of it) to the browser as usual.
const { $from } = editor.prosemirrorState.selection;
if (nodeHasPreview($from.node().type.name)) {
return;
}
dom.classList.add("bn-suppress-source-popup-caret");
requestAnimationFrame(() =>
dom.classList.remove("bn-suppress-source-popup-caret"),
);
};
dom.addEventListener("keydown", handleVerticalArrow, {
capture: true,
signal,
});
const handleBlur = () => store.setState({ selected: undefined });
dom.addEventListener("blur", handleBlur, { capture: true, signal });
},
};
},
);