@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
209 lines (187 loc) • 6.26 kB
text/typescript
import { Fragment, NodeRange, NodeType, Slice } from "prosemirror-model";
import { Transaction } from "prosemirror-state";
import { canJoin, liftTarget, ReplaceAroundStep } from "prosemirror-transform";
import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
import { getBlockInfoFromSelection } from "../../../getBlockInfoFromPos.js";
/**
* Modified version of prosemirror-schema-list's sinkItem.
* https://github.com/ProseMirror/prosemirror-schema-list/blob/master/src/schema-list.ts
*
* Changes from the original:
* 1. Range predicate checks node.type instead of firstChild.type
* 2. nestedBefore checks groupType instead of parent.type
* 3. Slice creates groupType instead of parent.type
* 4. Operates on Transaction directly instead of state+dispatch
*/
function sinkItem(tr: Transaction, itemType: NodeType, groupType: NodeType) {
const { $from, $to } = tr.selection;
const range = $from.blockRange(
$to,
(node) =>
node.childCount > 0 &&
(node.type.name === "blockGroup" || node.type.name === "column"), // change 1
);
if (!range) {
return false;
}
const startIndex = range.startIndex;
if (startIndex === 0) {
return false;
}
const parent = range.parent;
const nodeBefore = parent.child(startIndex - 1);
if (nodeBefore.type !== itemType) {
return false;
}
const nestedBefore =
nodeBefore.lastChild && nodeBefore.lastChild.type === groupType; // change 2
const inner = Fragment.from(nestedBefore ? itemType.create() : null);
const slice = new Slice(
Fragment.from(
itemType.create(null, Fragment.from(groupType.create(null, inner))), // change 3
),
nestedBefore ? 3 : 1,
0,
);
const before = range.start;
const after = range.end;
tr.step(
new ReplaceAroundStep(
before - (nestedBefore ? 3 : 1),
after,
before,
after,
slice,
1,
true,
),
).scrollIntoView();
return true;
}
export function nestBlock(editor: BlockNoteEditor<any, any, any>) {
return editor.transact((tr) => {
return sinkItem(
tr,
editor.pmSchema.nodes["blockContainer"],
editor.pmSchema.nodes["blockGroup"],
);
});
}
/**
* Modified version of prosemirror-schema-list's liftToOuterList.
* https://github.com/ProseMirror/prosemirror-schema-list/blob/master/src/schema-list.ts
*
* Changes from the original:
* 1. Operates on Transaction directly instead of state+dispatch (TipTap compat)
* 2. When the lifted block already has children (a groupType child), uses deeper
* openStart/offset so siblings merge into the existing group instead of
* creating a second one (which would violate blockContainer's schema)
* 3. Uses groupType.create() instead of range.parent.copy() (same as sinkItem)
*/
function liftToOuterList(
tr: Transaction,
itemType: NodeType,
groupType: NodeType, // change 3
range: NodeRange,
) {
const end = range.end;
const endOfList = range.$to.end(range.depth);
if (end < endOfList) {
// There are siblings after the lifted items, which must become
// children of the last item
const blockBeingLifted = range.parent.child(range.endIndex - 1);
const nestedAfter =
blockBeingLifted.lastChild &&
blockBeingLifted.lastChild.type === groupType; // change 2
tr.step(
new ReplaceAroundStep(
end - (nestedAfter ? 2 : 1), // change 2: go deeper when merging into existing children
endOfList,
end,
endOfList,
new Slice(
Fragment.from(
itemType.create(null, groupType.create()), // change 3
),
nestedAfter ? 2 : 1, // change 2: open deeper when merging into existing children
0,
),
nestedAfter ? 0 : 1, // change 2: Slice.insertAt offsets by openStart, so 0+2=2 lands inside existing bg
true,
),
);
range = new NodeRange(
tr.doc.resolve(range.$from.pos),
tr.doc.resolve(endOfList),
range.depth,
);
}
const target = liftTarget(range);
if (target == null) {
return false;
}
tr.lift(range, target);
const $after = tr.doc.resolve(tr.mapping.map(end, -1) - 1);
if (
canJoin(tr.doc, $after.pos) &&
$after.nodeBefore!.type === $after.nodeAfter!.type
) {
tr.join($after.pos);
}
tr.scrollIntoView();
return true;
}
/**
* Modified version of prosemirror-schema-list's liftListItem.
* https://github.com/ProseMirror/prosemirror-schema-list/blob/master/src/schema-list.ts
*
* Changes from the original:
* 1. Range predicate checks node.type instead of firstChild.type (same as sinkItem)
* 2. Passes groupType to liftToOuterList
* 3. Operates on Transaction directly instead of state+dispatch
* 4. Skips liftOutOfList (root-level blocks can't be unnested in BlockNote)
*/
export function liftItem(
tr: Transaction,
itemType: NodeType,
groupType: NodeType, // change 2
) {
const { $from, $to } = tr.selection;
const range = $from.blockRange(
$to,
(node) =>
node.childCount > 0 &&
(node.type.name === "blockGroup" || node.type.name === "column"), // change 1
);
if (!range) {
return false;
}
if ($from.node(range.depth - 1).type === itemType) {
// Inside a parent node
return liftToOuterList(tr, itemType, groupType, range); // change 2
}
// This is the "liftOutOfList" path — lifting out of a list entirely.
// Not applicable to BlockNote (root-level blocks can't be unnested). // change 4
return false;
}
export function unnestBlock(editor: BlockNoteEditor<any, any, any>) {
return editor.transact((tr) =>
liftItem(
tr,
editor.pmSchema.nodes["blockContainer"],
editor.pmSchema.nodes["blockGroup"],
),
);
}
export function canNestBlock(editor: BlockNoteEditor<any, any, any>) {
return editor.transact((tr) => {
const { bnBlock: blockContainer } = getBlockInfoFromSelection(tr);
return tr.doc.resolve(blockContainer.beforePos).nodeBefore !== null;
});
}
export function canUnnestBlock(editor: BlockNoteEditor<any, any, any>) {
return editor.transact((tr) => {
const { bnBlock: blockContainer } = getBlockInfoFromSelection(tr);
return tr.doc.resolve(blockContainer.beforePos).depth > 1;
});
}