Skip to content

Commit cccf9f7

Browse files
committed
feat: enhance text cursor positioning in blocks
- Added offset calculation for cursor position within block's inline content. - Updated `setTextCursorPosition` to accept numeric offsets, allowing precise cursor placement. - Improved handling of blocks with no inline content and wrapper nodes. - Documented new functionality in relevant areas.
1 parent 18bd907 commit cccf9f7

File tree

3 files changed

+112
-30
lines changed

3 files changed

+112
-30
lines changed

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

Lines changed: 95 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -46,19 +46,75 @@ export function getTextCursorPosition<
4646
}
4747
}
4848

49+
// Compute the offset of the cursor within the block’s inline content. We do
50+
// this by determining the type of content the block holds and then
51+
// calculating the difference between the selection’s anchor position and the
52+
// start of the block’s content.
53+
let offset = 0;
54+
try {
55+
const info = getBlockInfoFromTransaction(tr);
56+
const pmSchemaForOffset = getPmSchema(tr.doc);
57+
const schema = getBlockNoteSchema(pmSchemaForOffset);
58+
const contentType = schema.blockSchema[info.blockNoteType]!.content;
59+
let basePos: number | undefined;
60+
if (info.isBlockContainer) {
61+
const blockContent = info.blockContent;
62+
if (contentType === "inline") {
63+
// Inline content starts immediately after the opening of the blockContent
64+
basePos = blockContent.beforePos + 1;
65+
} else if (contentType === "table") {
66+
// Table content starts a few positions after blockContent to skip table wrappers
67+
basePos = blockContent.beforePos + 4;
68+
} else if (contentType === "none") {
69+
// Blocks with no inline content have the cursor anchored at the blockContent itself
70+
basePos = blockContent.beforePos;
71+
}
72+
} else {
73+
// For wrapper nodes (e.g. columns) there is no meaningful inline offset;
74+
// use the childContainer as a reference if available.
75+
// ChildContainer holds the block’s children; we assume the first child’s
76+
// content starts one position after the container.
77+
const childContainer = (info as any).childContainer;
78+
if (childContainer && childContainer.beforePos !== undefined) {
79+
basePos = childContainer.beforePos + 1;
80+
}
81+
}
82+
if (basePos !== undefined) {
83+
offset = tr.selection.anchor - basePos;
84+
if (offset < 0) {
85+
offset = 0;
86+
}
87+
}
88+
} catch (e) {
89+
// In case of any unexpected errors during offset computation, default to 0.
90+
offset = 0;
91+
}
92+
4993
return {
5094
block: nodeToBlock(bnBlock.node, pmSchema),
5195
prevBlock: prevNode === null ? undefined : nodeToBlock(prevNode, pmSchema),
5296
nextBlock: nextNode === null ? undefined : nodeToBlock(nextNode, pmSchema),
5397
parentBlock:
5498
parentNode === undefined ? undefined : nodeToBlock(parentNode, pmSchema),
99+
offset,
55100
};
56101
}
57102

103+
/**
104+
* Places the text cursor within a block. By default you can specify
105+
* "start" or "end" to move the cursor to the beginning or end of the block.
106+
* Alternatively, pass a numeric offset to place the cursor that many
107+
* characters into the block’s inline content. Offsets beyond the block’s
108+
* length will be clamped to the end of the block.
109+
*
110+
* @param tr The ProseMirror transaction.
111+
* @param targetBlock Identifier of the block to move the cursor into.
112+
* @param placementOrOffset Either "start", "end", or a zero‑based offset.
113+
*/
58114
export function setTextCursorPosition(
59115
tr: Transaction,
60116
targetBlock: BlockIdentifier,
61-
placement: "start" | "end" = "start",
117+
placementOrOffset: "start" | "end" | number = "start",
62118
) {
63119
const id = typeof targetBlock === "string" ? targetBlock : targetBlock.id;
64120
const pmSchema = getPmSchema(tr.doc);
@@ -76,43 +132,54 @@ export function setTextCursorPosition(
76132

77133
if (info.isBlockContainer) {
78134
const blockContent = info.blockContent;
135+
// Handle blocks that have no inline content
79136
if (contentType === "none") {
80137
tr.setSelection(NodeSelection.create(tr.doc, blockContent.beforePos));
81138
return;
82139
}
83140

141+
// Determine base position for inline or table content
142+
let basePos: number;
84143
if (contentType === "inline") {
85-
if (placement === "start") {
86-
tr.setSelection(
87-
TextSelection.create(tr.doc, blockContent.beforePos + 1),
88-
);
89-
} else {
90-
tr.setSelection(
91-
TextSelection.create(tr.doc, blockContent.afterPos - 1),
92-
);
93-
}
144+
basePos = blockContent.beforePos + 1;
94145
} else if (contentType === "table") {
95-
if (placement === "start") {
96-
// Need to offset the position as we have to get through the `tableRow`
97-
// and `tableCell` nodes to get to the `tableParagraph` node we want to
98-
// set the selection in.
99-
tr.setSelection(
100-
TextSelection.create(tr.doc, blockContent.beforePos + 4),
101-
);
102-
} else {
103-
tr.setSelection(
104-
TextSelection.create(tr.doc, blockContent.afterPos - 4),
105-
);
106-
}
146+
basePos = blockContent.beforePos + 4;
107147
} else {
108148
throw new UnreachableCaseError(contentType);
109149
}
110-
} else {
111-
const child =
112-
placement === "start"
113-
? info.childContainer.node.firstChild!
114-
: info.childContainer.node.lastChild!;
115150

116-
setTextCursorPosition(tr, child.attrs.id, placement);
151+
// Determine target position
152+
let targetPos: number;
153+
if (typeof placementOrOffset === "number") {
154+
// Clamp the offset to the range of the block’s content
155+
const maxOffset = blockContent.afterPos - basePos - 1;
156+
const offset = Math.max(0, Math.min(placementOrOffset, maxOffset));
157+
targetPos = basePos + offset;
158+
} else if (placementOrOffset === "start") {
159+
targetPos = basePos;
160+
} else {
161+
// end
162+
if (contentType === "inline") {
163+
targetPos = blockContent.afterPos - 1;
164+
} else if (contentType === "table") {
165+
targetPos = blockContent.afterPos - 4;
166+
} else {
167+
throw new UnreachableCaseError(contentType);
168+
}
169+
}
170+
tr.setSelection(TextSelection.create(tr.doc, targetPos));
171+
} else {
172+
// For wrapper nodes, delegate to the first or last child when using start/end.
173+
const isNumberPlacement = typeof placementOrOffset === "number";
174+
const child = !isNumberPlacement && placementOrOffset === "end"
175+
? info.childContainer.node.lastChild!
176+
: info.childContainer.node.firstChild!;
177+
if (isNumberPlacement) {
178+
// For numeric offsets inside wrapper nodes, we cannot determine a meaningful
179+
// character position at this level, so recurse into the first child with the same offset.
180+
setTextCursorPosition(tr, child.attrs.id, placementOrOffset);
181+
} else {
182+
setTextCursorPosition(tr, child.attrs.id, placementOrOffset);
183+
}
117184
}
118185
}

packages/core/src/editor/BlockNoteEditor.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1219,12 +1219,18 @@ export class BlockNoteEditor<
12191219
* @param targetBlock The identifier of an existing block that the text cursor should be moved to.
12201220
* @param placement Whether the text cursor should be placed at the start or end of the block.
12211221
*/
1222+
/**
1223+
* Sets the text cursor position within a block. Pass "start" or "end" to
1224+
* move the cursor to the start or end of the block, or a numeric offset to
1225+
* place the cursor that many characters into the block’s inline content.
1226+
* Offsets beyond the block’s length are clamped to the block’s end.
1227+
*/
12221228
public setTextCursorPosition(
12231229
targetBlock: BlockIdentifier,
1224-
placement: "start" | "end" = "start",
1230+
placementOrOffset: "start" | "end" | number = "start",
12251231
) {
12261232
return this.transact((tr) =>
1227-
setTextCursorPosition(tr, targetBlock, placement),
1233+
setTextCursorPosition(tr, targetBlock, placementOrOffset),
12281234
);
12291235
}
12301236

packages/core/src/editor/cursorPositionTypes.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,13 @@ export type TextCursorPosition<
1414
prevBlock: Block<BSchema, I, S> | undefined;
1515
nextBlock: Block<BSchema, I, S> | undefined;
1616
parentBlock: Block<BSchema, I, S> | undefined;
17+
/**
18+
* Offset from the start of the block’s inline content to the current cursor position.
19+
*
20+
* For inline blocks this value is the number of characters from the start of the
21+
* block’s content (i.e. `0` when the cursor is at the very beginning). For block
22+
* types that do not contain inline content (e.g. empty blocks or structural
23+
* wrappers) this value will be `0`.
24+
*/
25+
offset: number;
1726
};

0 commit comments

Comments
 (0)