Skip to content

Commit b4380ae

Browse files
committed
Refactored getSelection/setSelection to use MultipleNodeSelection when spanning multiple blocks.
1 parent 9a502b6 commit b4380ae

File tree

5 files changed

+183
-80
lines changed

5 files changed

+183
-80
lines changed

packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { CellSelection } from "prosemirror-tables";
88

99
import { Block } from "../../../../blocks/defaultBlocks.js";
1010
import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor";
11+
import { MultipleNodeSelection } from "../../../../extensions-shared/MultipleNodeSelection.js";
1112
import { BlockIdentifier } from "../../../../schema/index.js";
1213
import { getNearestBlockPos } from "../../../getBlockInfoFromPos.js";
1314
import { getNodeById } from "../../../nodeUtil.js";
@@ -22,6 +23,10 @@ type BlockSelectionData = (
2223
| {
2324
type: "node";
2425
}
26+
| {
27+
type: "multiple-node";
28+
headBlockId: string;
29+
}
2530
| {
2631
type: "cell";
2732
anchorCellOffset: number;
@@ -60,6 +65,17 @@ function getBlockSelectionData(
6065
type: "node" as const,
6166
anchorBlockId: anchorBlockPosInfo.node.attrs.id,
6267
};
68+
} else if (tr.selection instanceof MultipleNodeSelection) {
69+
const headBlockPosInfo = getNearestBlockPos(
70+
tr.doc,
71+
tr.selection.head - tr.selection.$head.nodeBefore!.nodeSize,
72+
);
73+
74+
return {
75+
type: "multiple-node" as const,
76+
anchorBlockId: anchorBlockPosInfo.node.attrs.id,
77+
headBlockId: headBlockPosInfo.node.attrs.id,
78+
};
6379
} else {
6480
const headBlockPosInfo = getNearestBlockPos(tr.doc, tr.selection.head);
6581

@@ -105,6 +121,19 @@ function updateBlockSelectionFromData(
105121
);
106122
} else if (data.type === "node") {
107123
selection = NodeSelection.create(tr.doc, anchorBlockPos + 1);
124+
} else if (data.type === "multiple-node") {
125+
const headBlockPos = getNodeById(data.headBlockId, tr.doc)?.posBeforeNode;
126+
if (headBlockPos === undefined) {
127+
throw new Error(
128+
`Could not find block with ID ${data.headBlockId} to update selection`,
129+
);
130+
}
131+
132+
selection = MultipleNodeSelection.create(
133+
tr.doc,
134+
anchorBlockPos,
135+
headBlockPos + tr.doc.resolve(headBlockPos).nodeAfter!.nodeSize,
136+
);
108137
} else {
109138
const headBlockPos = getNodeById(data.headBlockId, tr.doc)?.posBeforeNode;
110139
if (headBlockPos === undefined) {

packages/core/src/api/blockManipulation/selections/selection.ts

Lines changed: 130 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1-
import { TextSelection, type Transaction } from "prosemirror-state";
2-
import { TableMap } from "prosemirror-tables";
1+
import {
2+
NodeSelection,
3+
TextSelection,
4+
type Transaction,
5+
} from "prosemirror-state";
6+
import { CellSelection } from "prosemirror-tables";
37

48
import { Block } from "../../../blocks/defaultBlocks.js";
59
import { Selection } from "../../../editor/selectionTypes.js";
10+
import { MultipleNodeSelection } from "../../../extensions-shared/MultipleNodeSelection.js";
611
import {
712
BlockIdentifier,
813
BlockSchema,
@@ -15,7 +20,7 @@ import {
1520
prosemirrorSliceToSlicedBlocks,
1621
} from "../../nodeConversions/nodeToBlock.js";
1722
import { getNodeById } from "../../nodeUtil.js";
18-
import { getBlockNoteSchema, getPmSchema } from "../../pmUtil.js";
23+
import { getPmSchema } from "../../pmUtil.js";
1924

2025
export function getSelection<
2126
BSchema extends BlockSchema,
@@ -24,16 +29,24 @@ export function getSelection<
2429
>(tr: Transaction): Selection<BSchema, I, S> | undefined {
2530
const pmSchema = getPmSchema(tr);
2631
// Return undefined if the selection is collapsed or a node is selected.
27-
if (tr.selection.empty || "node" in tr.selection) {
32+
if (tr.selection.empty || tr.selection instanceof NodeSelection) {
2833
return undefined;
2934
}
3035

31-
const $startBlockBeforePos = tr.doc.resolve(
32-
getNearestBlockPos(tr.doc, tr.selection.from).posBeforeNode,
33-
);
34-
const $endBlockBeforePos = tr.doc.resolve(
35-
getNearestBlockPos(tr.doc, tr.selection.to).posBeforeNode,
36-
);
36+
const $startBlockBeforePos =
37+
tr.selection instanceof MultipleNodeSelection
38+
? tr.selection.$anchor
39+
: tr.doc.resolve(
40+
getNearestBlockPos(tr.doc, tr.selection.from).posBeforeNode,
41+
);
42+
const $endBlockBeforePos =
43+
tr.selection instanceof MultipleNodeSelection
44+
? tr.doc.resolve(
45+
tr.selection.head - tr.selection.$head.nodeBefore!.nodeSize,
46+
)
47+
: tr.doc.resolve(
48+
getNearestBlockPos(tr.doc, tr.selection.to).posBeforeNode,
49+
);
3750

3851
// Converts the node at the given index and depth around `$startBlockBeforePos`
3952
// to a block. Used to get blocks at given indices at the shared depth and
@@ -140,84 +153,123 @@ export function setSelection(
140153
const startBlockId =
141154
typeof startBlock === "string" ? startBlock : startBlock.id;
142155
const endBlockId = typeof endBlock === "string" ? endBlock : endBlock.id;
143-
const pmSchema = getPmSchema(tr);
144-
const schema = getBlockNoteSchema(pmSchema);
145156

146157
if (startBlockId === endBlockId) {
147-
throw new Error(
148-
`Attempting to set selection with the same anchor and head blocks (id ${startBlockId})`,
149-
);
150-
}
151-
const anchorPosInfo = getNodeById(startBlockId, tr.doc);
152-
if (!anchorPosInfo) {
153-
throw new Error(`Block with ID ${startBlockId} not found`);
154-
}
155-
const headPosInfo = getNodeById(endBlockId, tr.doc);
156-
if (!headPosInfo) {
157-
throw new Error(`Block with ID ${endBlockId} not found`);
158-
}
158+
// If the same block is provided for the start and end, its content gets
159+
// selected.
160+
const posInfo = getNodeById(startBlockId, tr.doc);
161+
if (!posInfo) {
162+
throw new Error(`Block with ID ${startBlockId} not found`);
163+
}
159164

160-
const anchorBlockInfo = getBlockInfo(anchorPosInfo);
161-
const headBlockInfo = getBlockInfo(headPosInfo);
162-
163-
const anchorBlockConfig =
164-
schema.blockSchema[
165-
anchorBlockInfo.blockNoteType as keyof typeof schema.blockSchema
166-
];
167-
const headBlockConfig =
168-
schema.blockSchema[
169-
headBlockInfo.blockNoteType as keyof typeof schema.blockSchema
170-
];
171-
172-
if (
173-
!anchorBlockInfo.isBlockContainer ||
174-
anchorBlockConfig.content === "none"
175-
) {
176-
throw new Error(
177-
`Attempting to set selection anchor in block without content (id ${startBlockId})`,
178-
);
165+
const blockInfo = getBlockInfo(posInfo);
166+
167+
// Case for regular blocks.
168+
if (blockInfo.isBlockContainer) {
169+
const content = blockInfo.blockContent.node.type.spec.content!;
170+
171+
// Set `NodeSelection` on the `blockContent` node if it has no content.
172+
if (content === "") {
173+
tr.setSelection(
174+
NodeSelection.create(tr.doc, blockInfo.blockContent.beforePos),
175+
);
176+
177+
return;
178+
}
179+
180+
// Set a `TextSelection` spanning the block's inline content, if it has
181+
// inline content.
182+
if (content === "inline*") {
183+
tr.setSelection(
184+
TextSelection.create(
185+
tr.doc,
186+
blockInfo.blockContent.beforePos + 1,
187+
blockInfo.blockContent.afterPos - 1,
188+
),
189+
);
190+
191+
return;
192+
}
193+
194+
// Set a `CellSelection` spanning all cells in the table, if it has table
195+
// content.
196+
if (content === "tableRow+") {
197+
const firstRowBeforePos = blockInfo.blockContent.beforePos + 1;
198+
const firstCellBeforePos = firstRowBeforePos + 1;
199+
const lastRowAfterPos = blockInfo.blockContent.afterPos - 1;
200+
const lastCellAfterPos = lastRowAfterPos - 1;
201+
202+
tr.setSelection(
203+
CellSelection.create(
204+
tr.doc,
205+
firstCellBeforePos,
206+
lastCellAfterPos -
207+
tr.doc.resolve(lastCellAfterPos).nodeBefore!.nodeSize,
208+
),
209+
);
210+
211+
return;
212+
}
213+
214+
throw new Error(
215+
`Invalid content type: ${content} for node type ${blockInfo.blockContent.node.type.name}`,
216+
);
217+
}
218+
219+
// Case for when block is a `columnList`.
220+
if (blockInfo.blockNoteType === "columnList") {
221+
const firstColumnBeforePos = blockInfo.bnBlock.beforePos + 1;
222+
const firstBlockBeforePos = firstColumnBeforePos + 1;
223+
const lastColumnAfterPos = blockInfo.bnBlock.afterPos - 1;
224+
const lastBlockAfterPos = lastColumnAfterPos - 1;
225+
226+
tr.setSelection(
227+
MultipleNodeSelection.create(
228+
tr.doc,
229+
firstBlockBeforePos,
230+
lastBlockAfterPos -
231+
tr.doc.resolve(lastBlockAfterPos).nodeBefore!.nodeSize,
232+
),
233+
);
234+
}
235+
236+
// Case for when block is a `column`.
237+
if (blockInfo.blockNoteType === "column") {
238+
const firstBlockBeforePos = blockInfo.bnBlock.beforePos + 1;
239+
const lastBlockAfterPos = blockInfo.bnBlock.afterPos - 1;
240+
241+
// Run recursively as the column may only have one block.
242+
setSelection(
243+
tr,
244+
tr.doc.resolve(firstBlockBeforePos).nodeAfter!.attrs.id,
245+
tr.doc.resolve(lastBlockAfterPos).nodeBefore!.attrs.id,
246+
);
247+
}
248+
249+
throw new Error(`Invalid block node: ${blockInfo.blockNoteType}`);
179250
}
180-
if (!headBlockInfo.isBlockContainer || headBlockConfig.content === "none") {
181-
throw new Error(
182-
`Attempting to set selection anchor in block without content (id ${endBlockId})`,
183-
);
251+
252+
const startPosInfo = getNodeById(startBlockId, tr.doc);
253+
if (!startPosInfo) {
254+
throw new Error(`Block with ID ${startBlockId} not found`);
184255
}
185256

186-
let startPos: number;
187-
let endPos: number;
257+
const startBlockInfo = getBlockInfo(startPosInfo);
188258

189-
if (anchorBlockConfig.content === "table") {
190-
const tableMap = TableMap.get(anchorBlockInfo.blockContent.node);
191-
const firstCellPos =
192-
anchorBlockInfo.blockContent.beforePos +
193-
tableMap.positionAt(0, 0, anchorBlockInfo.blockContent.node) +
194-
1;
195-
startPos = firstCellPos + 2;
196-
} else {
197-
startPos = anchorBlockInfo.blockContent.beforePos + 1;
259+
const endPosInfo = getNodeById(endBlockId, tr.doc);
260+
if (!endPosInfo) {
261+
throw new Error(`Block with ID ${endBlockId} not found`);
198262
}
199263

200-
if (headBlockConfig.content === "table") {
201-
const tableMap = TableMap.get(headBlockInfo.blockContent.node);
202-
const lastCellPos =
203-
headBlockInfo.blockContent.beforePos +
204-
tableMap.positionAt(
205-
tableMap.height - 1,
206-
tableMap.width - 1,
207-
headBlockInfo.blockContent.node,
208-
) +
209-
1;
210-
const lastCellNodeSize = tr.doc.resolve(lastCellPos).nodeAfter!.nodeSize;
211-
endPos = lastCellPos + lastCellNodeSize - 2;
212-
} else {
213-
endPos = headBlockInfo.blockContent.afterPos - 1;
214-
}
264+
const endBlockInfo = getBlockInfo(endPosInfo);
215265

216-
// TODO: We should polish up the `MultipleNodeSelection` and use that instead.
217-
// Right now it's missing a few things like a jsonID and styling to show
218-
// which nodes are selected. `TextSelection` is ok for now, but has the
219-
// restriction that the start/end blocks must have content.
220-
tr.setSelection(TextSelection.create(tr.doc, startPos, endPos));
266+
tr.setSelection(
267+
MultipleNodeSelection.create(
268+
tr.doc,
269+
startBlockInfo.bnBlock.beforePos,
270+
endBlockInfo.bnBlock.afterPos,
271+
),
272+
);
221273
}
222274

223275
export function getSelectionCutBlocks(tr: Transaction) {

packages/core/src/extensions/SideMenu/MultipleNodeSelection.ts renamed to packages/core/src/extensions-shared/MultipleNodeSelection.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Mappable } from "prosemirror-transform";
77
* to drag multiple blocks at the same time. Expects the selection anchor and head to be between nodes, i.e. just before
88
* the first target node and just after the last, and that anchor and head are at the same nesting level.
99
*
10-
* Partially based on ProseMirror's NodeSelection implementation:
10+
* Based on ProseMirror's NodeSelection implementation:
1111
* (https://github.com/ProseMirror/prosemirror-state/blob/master/src/selection.ts)
1212
* MultipleNodeSelection differs from NodeSelection in the following ways:
1313
* 1. Stores which nodes are included in the selection instead of just a single node.
@@ -84,6 +84,17 @@ export class MultipleNodeSelection extends Selection {
8484
toJSON(): any {
8585
return { type: "multiple-node", anchor: this.anchor, head: this.head };
8686
}
87+
88+
static fromJSON(doc: Node, json: any) {
89+
if (typeof json.anchor != "number" || json.head !== "number") {
90+
throw new RangeError("Invalid input for NodeSelection.fromJSON");
91+
}
92+
93+
return new MultipleNodeSelection(
94+
doc.resolve(json.anchor),
95+
doc.resolve(json.head),
96+
);
97+
}
8798
}
8899

89100
Selection.jsonID("multiple-node", MultipleNodeSelection);

packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,17 @@ export const KeyboardShortcutsExtension = Extension.create<{
621621
"Mod-z": () => this.options.editor.undo(),
622622
"Mod-y": () => this.options.editor.redo(),
623623
"Shift-Mod-z": () => this.options.editor.redo(),
624+
// By default, ProseMirror tries to find a `TextSelection` that spans the
625+
// whole editor. This can cause issues if the first/last block is a table
626+
// or node without content. So we use `editor.setSelection` instead
627+
"Mod-a": () => {
628+
this.options.editor.setSelection(
629+
this.options.editor.document[0],
630+
this.options.editor.document[this.options.editor.document.length - 1],
631+
);
632+
633+
return true;
634+
},
624635
};
625636
},
626637
});

packages/core/src/extensions/SideMenu/dragging.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
InlineContentSchema,
1515
StyleSchema,
1616
} from "../../schema/index.js";
17-
import { MultipleNodeSelection } from "./MultipleNodeSelection.js";
17+
import { MultipleNodeSelection } from "../../extensions-shared/MultipleNodeSelection.js";
1818

1919
let dragImageElement: Element | undefined;
2020

0 commit comments

Comments
 (0)