@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
614 lines (540 loc) • 22.9 kB
text/typescript
import { Extension } from "@tiptap/core";
import { TextSelection } from "prosemirror-state";
import { ReplaceAroundStep } from "prosemirror-transform";
import {
getBottomNestedBlockInfo,
getParentBlockInfo,
getPrevBlockInfo,
mergeBlocksCommand,
} from "../../api/blockManipulation/commands/mergeBlocks/mergeBlocks.js";
import { nestBlock } from "../../api/blockManipulation/commands/nestBlock/nestBlock.js";
import { splitBlockCommand } from "../../api/blockManipulation/commands/splitBlock/splitBlock.js";
import { updateBlockCommand } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js";
import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js";
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
export const KeyboardShortcutsExtension = Extension.create<{
editor: BlockNoteEditor<any, any, any>;
tabBehavior: "prefer-navigate-ui" | "prefer-indent";
}>({
priority: 50,
// TODO: The shortcuts need a refactor. Do we want to use a command priority
// design as there is now, or clump the logic into a single function?
addKeyboardShortcuts() {
// handleBackspace is partially adapted from https://github.com/ueberdosis/tiptap/blob/ed56337470efb4fd277128ab7ef792b37cfae992/packages/core/src/extensions/keymap.ts
const handleBackspace = () =>
this.editor.commands.first(({ chain, commands }) => [
// Deletes the selection if it's not empty.
() => commands.deleteSelection(),
// Undoes an input rule if one was triggered in the last editor state change.
() => commands.undoInputRule(),
// Reverts block content type to a paragraph if the selection is at the start of the block.
() =>
commands.command(({ state }) => {
const blockInfo = getBlockInfoFromSelection(state);
if (!blockInfo.isBlockContainer) {
return false;
}
const selectionAtBlockStart =
state.selection.from === blockInfo.blockContent.beforePos + 1;
const isParagraph =
blockInfo.blockContent.node.type.name === "paragraph";
if (selectionAtBlockStart && !isParagraph) {
return commands.command(
updateBlockCommand(blockInfo.bnBlock.beforePos, {
type: "paragraph",
props: {},
}),
);
}
return false;
}),
// Removes a level of nesting if the block is indented if the selection is at the start of the block.
() =>
commands.command(({ state }) => {
const blockInfo = getBlockInfoFromSelection(state);
if (!blockInfo.isBlockContainer) {
return false;
}
const { blockContent } = blockInfo;
const selectionAtBlockStart =
state.selection.from === blockContent.beforePos + 1;
if (selectionAtBlockStart) {
return commands.liftListItem("blockContainer");
}
return false;
}),
// Merges block with the previous one if it isn't indented, and the selection is at the start of the
// block. The target block for merging must contain inline content.
() =>
commands.command(({ state }) => {
const blockInfo = getBlockInfoFromSelection(state);
if (!blockInfo.isBlockContainer) {
return false;
}
const { bnBlock: blockContainer, blockContent } = blockInfo;
const selectionAtBlockStart =
state.selection.from === blockContent.beforePos + 1;
const selectionEmpty = state.selection.empty;
const posBetweenBlocks = blockContainer.beforePos;
if (selectionAtBlockStart && selectionEmpty) {
return chain()
.command(mergeBlocksCommand(posBetweenBlocks))
.scrollIntoView()
.run();
}
return false;
}),
() =>
commands.command(({ state, dispatch }) => {
// when at the start of a first block in a column
const blockInfo = getBlockInfoFromSelection(state);
if (!blockInfo.isBlockContainer) {
return false;
}
const selectionAtBlockStart =
state.selection.from === blockInfo.blockContent.beforePos + 1;
if (!selectionAtBlockStart) {
return false;
}
const prevBlockInfo = getPrevBlockInfo(
state.doc,
blockInfo.bnBlock.beforePos,
);
if (prevBlockInfo) {
// should be no previous block
return false;
}
const parentBlockInfo = getParentBlockInfo(
state.doc,
blockInfo.bnBlock.beforePos,
);
if (parentBlockInfo?.blockNoteType !== "column") {
return false;
}
const column = parentBlockInfo;
const columnList = getParentBlockInfo(
state.doc,
column.bnBlock.beforePos,
);
if (columnList?.blockNoteType !== "columnList") {
throw new Error("parent of column is not a column list");
}
const shouldRemoveColumn =
column.childContainer!.node.childCount === 1;
const shouldRemoveColumnList =
shouldRemoveColumn &&
columnList.childContainer!.node.childCount === 2;
const isFirstColumn =
columnList.childContainer!.node.firstChild ===
column.bnBlock.node;
if (dispatch) {
const blockToMove = state.doc.slice(
blockInfo.bnBlock.beforePos,
blockInfo.bnBlock.afterPos,
false,
);
/*
There are 3 different cases:
a) remove entire column list (if no columns would be remaining)
b) remove just a column (if no blocks inside a column would be remaining)
c) keep columns (if there are blocks remaining inside a column)
Each of these 3 cases has 2 sub-cases, depending on whether the backspace happens at the start of the first (most-left) column,
or at the start of a non-first column.
*/
if (shouldRemoveColumnList) {
if (isFirstColumn) {
state.tr.step(
new ReplaceAroundStep(
// replace entire column list
columnList.bnBlock.beforePos,
columnList.bnBlock.afterPos,
// select content of remaining column:
column.bnBlock.afterPos + 1,
columnList.bnBlock.afterPos - 2,
blockToMove,
blockToMove.size, // append existing content to blockToMove
false,
),
);
const pos = state.tr.doc.resolve(column.bnBlock.beforePos);
state.tr.setSelection(TextSelection.between(pos, pos));
} else {
// replaces the column list with the blockToMove slice, prepended with the content of the remaining column
state.tr.step(
new ReplaceAroundStep(
// replace entire column list
columnList.bnBlock.beforePos,
columnList.bnBlock.afterPos,
// select content of existing column:
columnList.bnBlock.beforePos + 2,
column.bnBlock.beforePos - 1,
blockToMove,
0, // prepend existing content to blockToMove
false,
),
);
const pos = state.tr.doc.resolve(
state.tr.mapping.map(column.bnBlock.beforePos - 1),
);
state.tr.setSelection(TextSelection.between(pos, pos));
}
} else if (shouldRemoveColumn) {
if (isFirstColumn) {
// delete column
state.tr.delete(
column.bnBlock.beforePos,
column.bnBlock.afterPos,
);
// move before columnlist
state.tr.insert(
columnList.bnBlock.beforePos,
blockToMove.content,
);
const pos = state.tr.doc.resolve(
columnList.bnBlock.beforePos,
);
state.tr.setSelection(TextSelection.between(pos, pos));
} else {
// just delete the </column><column> closing and opening tags to merge the columns
state.tr.delete(
column.bnBlock.beforePos - 1,
column.bnBlock.beforePos + 1,
);
}
} else {
// delete block
state.tr.delete(
blockInfo.bnBlock.beforePos,
blockInfo.bnBlock.afterPos,
);
if (isFirstColumn) {
// move before columnlist
state.tr.insert(
columnList.bnBlock.beforePos - 1,
blockToMove.content,
);
} else {
// append block to previous column
state.tr.insert(
column.bnBlock.beforePos - 1,
blockToMove.content,
);
}
const pos = state.tr.doc.resolve(column.bnBlock.beforePos - 1);
state.tr.setSelection(TextSelection.between(pos, pos));
}
}
return true;
}),
// Deletes the current block if it's an empty block with inline content,
// and moves the selection to the previous block.
() =>
commands.command(({ state }) => {
const blockInfo = getBlockInfoFromSelection(state);
if (!blockInfo.isBlockContainer) {
return false;
}
const blockEmpty =
blockInfo.blockContent.node.childCount === 0 &&
blockInfo.blockContent.node.type.spec.content === "inline*";
if (blockEmpty) {
const prevBlockInfo = getPrevBlockInfo(
state.doc,
blockInfo.bnBlock.beforePos,
);
if (!prevBlockInfo || !prevBlockInfo.isBlockContainer) {
return false;
}
let chainedCommands = chain();
if (
prevBlockInfo.blockContent.node.type.spec.content ===
"tableRow+"
) {
const tableBlockEndPos = blockInfo.bnBlock.beforePos - 1;
const tableBlockContentEndPos = tableBlockEndPos - 1;
const lastRowEndPos = tableBlockContentEndPos - 1;
const lastCellEndPos = lastRowEndPos - 1;
const lastCellParagraphEndPos = lastCellEndPos - 1;
chainedCommands = chainedCommands.setTextSelection(
lastCellParagraphEndPos,
);
} else if (
prevBlockInfo.blockContent.node.type.spec.content === ""
) {
const nonEditableBlockContentStartPos =
prevBlockInfo.blockContent.afterPos -
prevBlockInfo.blockContent.node.nodeSize;
chainedCommands = chainedCommands.setNodeSelection(
nonEditableBlockContentStartPos,
);
} else {
const blockContentStartPos =
prevBlockInfo.blockContent.afterPos -
prevBlockInfo.blockContent.node.nodeSize;
chainedCommands =
chainedCommands.setTextSelection(blockContentStartPos);
}
return chainedCommands
.deleteRange({
from: blockInfo.bnBlock.beforePos,
to: blockInfo.bnBlock.afterPos,
})
.scrollIntoView()
.run();
}
return false;
}),
// Deletes previous block if it contains no content and isn't a table,
// when the selection is empty and at the start of the block. Moves the
// current block into the deleted block's place.
() =>
commands.command(({ state }) => {
const blockInfo = getBlockInfoFromSelection(state);
if (!blockInfo.isBlockContainer) {
// TODO
throw new Error(`todo`);
}
const selectionAtBlockStart =
state.selection.from === blockInfo.blockContent.beforePos + 1;
const selectionEmpty = state.selection.empty;
const prevBlockInfo = getPrevBlockInfo(
state.doc,
blockInfo.bnBlock.beforePos,
);
if (prevBlockInfo && selectionAtBlockStart && selectionEmpty) {
const bottomBlock = getBottomNestedBlockInfo(
state.doc,
prevBlockInfo,
);
if (!bottomBlock.isBlockContainer) {
// TODO
throw new Error(`todo`);
}
const prevBlockNotTableAndNoContent =
bottomBlock.blockContent.node.type.spec.content === "" ||
(bottomBlock.blockContent.node.type.spec.content ===
"inline*" &&
bottomBlock.blockContent.node.childCount === 0);
if (prevBlockNotTableAndNoContent) {
return chain()
.cut(
{
from: blockInfo.bnBlock.beforePos,
to: blockInfo.bnBlock.afterPos,
},
bottomBlock.bnBlock.afterPos,
)
.deleteRange({
from: bottomBlock.bnBlock.beforePos,
to: bottomBlock.bnBlock.afterPos,
})
.run();
}
}
return false;
}),
]);
const handleDelete = () =>
this.editor.commands.first(({ commands }) => [
// Deletes the selection if it's not empty.
() => commands.deleteSelection(),
// Merges block with the next one (at the same nesting level or lower),
// if one exists, the block has no children, and the selection is at the
// end of the block.
() =>
commands.command(({ state }) => {
// TODO: Change this to not rely on offsets & schema assumptions
const blockInfo = getBlockInfoFromSelection(state);
if (!blockInfo.isBlockContainer) {
return false;
}
const {
bnBlock: blockContainer,
blockContent,
childContainer,
} = blockInfo;
const { depth } = state.doc.resolve(blockContainer.beforePos);
const blockAtDocEnd =
blockContainer.afterPos === state.doc.nodeSize - 3;
const selectionAtBlockEnd =
state.selection.from === blockContent.afterPos - 1;
const selectionEmpty = state.selection.empty;
const hasChildBlocks = childContainer !== undefined;
if (
!blockAtDocEnd &&
selectionAtBlockEnd &&
selectionEmpty &&
!hasChildBlocks
) {
let oldDepth = depth;
let newPos = blockContainer.afterPos + 1;
let newDepth = state.doc.resolve(newPos).depth;
while (newDepth < oldDepth) {
oldDepth = newDepth;
newPos += 2;
newDepth = state.doc.resolve(newPos).depth;
}
return commands.command(mergeBlocksCommand(newPos - 1));
}
return false;
}),
]);
const handleEnter = (withShift = false) => {
return this.editor.commands.first(({ commands }) => [
// Removes a level of nesting if the block is empty & indented, while the selection is also empty & at the start
// of the block.
() =>
commands.command(({ state }) => {
const blockInfo = getBlockInfoFromSelection(state);
if (!blockInfo.isBlockContainer) {
return false;
}
const { bnBlock: blockContainer, blockContent } = blockInfo;
const { depth } = state.doc.resolve(blockContainer.beforePos);
const selectionAtBlockStart =
state.selection.$anchor.parentOffset === 0;
const selectionEmpty =
state.selection.anchor === state.selection.head;
const blockEmpty = blockContent.node.childCount === 0;
const blockIndented = depth > 1;
if (
selectionAtBlockStart &&
selectionEmpty &&
blockEmpty &&
blockIndented
) {
return commands.liftListItem("blockContainer");
}
return false;
}),
// Creates a hard break if block is configured to do so.
() =>
commands.command(({ state }) => {
const blockInfo = getBlockInfoFromSelection(state);
const blockHardBreakShortcut: "shift+enter" | "enter" | "none" =
this.options.editor.schema.blockSchema[blockInfo.blockNoteType]
.hardBreakShortcut ?? "shift+enter";
if (blockHardBreakShortcut === "none") {
return false;
}
if (
// If shortcut is not configured, or is configured as "shift+enter",
// create a hard break for shift+enter, but not for enter.
(blockHardBreakShortcut === "shift+enter" && withShift) ||
// If shortcut is configured as "enter", create a hard break for
// both enter and shift+enter.
blockHardBreakShortcut === "enter"
) {
return commands.insertContent({
type: "hardBreak",
});
}
return false;
}),
// Creates a new block and moves the selection to it if the current one is empty, while the selection is also
// empty & at the start of the block.
() =>
commands.command(({ state, dispatch }) => {
const blockInfo = getBlockInfoFromSelection(state);
if (!blockInfo.isBlockContainer) {
return false;
}
const { bnBlock: blockContainer, blockContent } = blockInfo;
const selectionAtBlockStart =
state.selection.$anchor.parentOffset === 0;
const selectionEmpty =
state.selection.anchor === state.selection.head;
const blockEmpty = blockContent.node.childCount === 0;
if (selectionAtBlockStart && selectionEmpty && blockEmpty) {
const newBlockInsertionPos = blockContainer.afterPos;
const newBlockContentPos = newBlockInsertionPos + 2;
if (dispatch) {
const newBlock =
state.schema.nodes["blockContainer"].createAndFill()!;
state.tr
.insert(newBlockInsertionPos, newBlock)
.scrollIntoView();
state.tr.setSelection(
new TextSelection(state.doc.resolve(newBlockContentPos)),
);
}
return true;
}
return false;
}),
// Splits the current block, moving content inside that's after the cursor to a new text block below. Also
// deletes the selection beforehand, if it's not empty.
() =>
commands.command(({ state, chain }) => {
const blockInfo = getBlockInfoFromSelection(state);
if (!blockInfo.isBlockContainer) {
return false;
}
const { blockContent } = blockInfo;
const selectionAtBlockStart =
state.selection.$anchor.parentOffset === 0;
const blockEmpty = blockContent.node.childCount === 0;
if (!blockEmpty) {
chain()
.deleteSelection()
.command(
splitBlockCommand(
state.selection.from,
selectionAtBlockStart,
selectionAtBlockStart,
),
)
.run();
return true;
}
return false;
}),
]);
};
return {
Backspace: handleBackspace,
Delete: handleDelete,
Enter: () => handleEnter(),
"Shift-Enter": () => handleEnter(true),
// Always returning true for tab key presses ensures they're not captured by the browser. Otherwise, they blur the
// editor since the browser will try to use tab for keyboard navigation.
Tab: () => {
if (
this.options.tabBehavior !== "prefer-indent" &&
(this.options.editor.formattingToolbar?.shown ||
this.options.editor.linkToolbar?.shown ||
this.options.editor.filePanel?.shown)
) {
// don't handle tabs if a toolbar is shown, so we can tab into / out of it
return false;
}
return nestBlock(this.options.editor);
// return true;
},
"Shift-Tab": () => {
if (
this.options.tabBehavior !== "prefer-indent" &&
(this.options.editor.formattingToolbar?.shown ||
this.options.editor.linkToolbar?.shown ||
this.options.editor.filePanel?.shown)
) {
// don't handle tabs if a toolbar is shown, so we can tab into / out of it
return false;
}
this.editor.commands.liftListItem("blockContainer");
return true;
},
"Shift-Mod-ArrowUp": () => {
this.options.editor.moveBlocksUp();
return true;
},
"Shift-Mod-ArrowDown": () => {
this.options.editor.moveBlocksDown();
return true;
},
"Mod-z": () => this.options.editor.undo(),
"Mod-y": () => this.options.editor.redo(),
"Shift-Mod-z": () => this.options.editor.redo(),
};
},
});