Skip to content

Commit 8621163

Browse files
committed
feat: resolve positions to locations
1 parent d50efd3 commit 8621163

File tree

4 files changed

+1016
-1
lines changed

4 files changed

+1016
-1
lines changed
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import type { Node, Transaction } from "prosemirror-model";
2+
import { TextSelection } from "prosemirror-state";
3+
import { Block } from "../blocks/defaultBlocks.js";
4+
import {
5+
BlockSchema,
6+
InlineContentSchema,
7+
StyleSchema,
8+
} from "../schema/index.js";
9+
import {
10+
DefaultBlockSchema,
11+
DefaultInlineContentSchema,
12+
DefaultStyleSchema,
13+
} from "../blocks/defaultBlocks.js";
14+
import { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
15+
import { getNodeById } from "../api/nodeUtil.js";
16+
import {
17+
getBlockInfo,
18+
getBlockInfoFromTransaction,
19+
getNearestBlockPos,
20+
} from "../api/getBlockInfoFromPos.js";
21+
import { nodeToBlock } from "../api/nodeConversions/nodeToBlock.js";
22+
import { getPmSchema } from "../api/pmUtil.js";
23+
import { BlockId, BlockIdentifier, Location, Point, Range } from "./types.js";
24+
import {
25+
isBlockId,
26+
isBlockIdentifier,
27+
isPoint,
28+
isRange,
29+
normalizeToRange,
30+
toId,
31+
} from "./utils.js";
32+
33+
/**
34+
* LocationManager provides methods to resolve and work with locations within a BlockNote document.
35+
* It handles the conversion between Location types and ProseMirror positions.
36+
*/
37+
export class LocationManager<
38+
BSchema extends BlockSchema = DefaultBlockSchema,
39+
ISchema extends InlineContentSchema = DefaultInlineContentSchema,
40+
SSchema extends StyleSchema = DefaultStyleSchema,
41+
> {
42+
constructor(private editor: BlockNoteEditor<BSchema, ISchema, SSchema>) {}
43+
44+
/**
45+
* Resolves a location to a ProseMirror position or range.
46+
* @param location The location to resolve
47+
* @returns The resolved ProseMirror position or range
48+
*/
49+
public resolveLocation(
50+
location: Location,
51+
opts: {
52+
/**
53+
* Whether to include child blocks as part of the response
54+
* @default true
55+
*/
56+
includeChildren: boolean;
57+
},
58+
): {
59+
from: number;
60+
to: number;
61+
blockIds: BlockId[];
62+
} {
63+
return this.resolveRange(normalizeToRange(location));
64+
}
65+
66+
/**
67+
* Resolves a BlockId to a ProseMirror position.
68+
* @param blockId The block ID to resolve
69+
* @returns The point that this block represents
70+
*/
71+
private resolveBlockIdToPoint(blockId: BlockId): Point {
72+
return {
73+
id: blockId,
74+
offset: -1,
75+
};
76+
}
77+
78+
/**
79+
* Resolves a Point to a ProseMirror position.
80+
* @param point The point to resolve
81+
* @returns The resolved ProseMirror position
82+
*/
83+
private resolvePoint(point: Point): {
84+
from: number;
85+
to: number;
86+
blockIds: BlockId[];
87+
} {
88+
const posInfo = getNodeById(point.id, this.editor.prosemirrorState.doc);
89+
if (!posInfo) {
90+
// TODO should we be throwing errors here?
91+
throw new Error(`Block with ID ${point.id} not found`);
92+
}
93+
94+
// If offset is -1, treat as block-level operation
95+
if (point.offset === -1) {
96+
return {
97+
from: posInfo.posBeforeNode,
98+
to: posInfo.posBeforeNode + posInfo.node.nodeSize,
99+
blockIds: [point.id],
100+
};
101+
}
102+
103+
const block = nodeToBlock(posInfo.node);
104+
105+
const blockContent = blockInfo.blockContent;
106+
const contentType = this.getBlockContentType(blockInfo);
107+
108+
let from: number;
109+
let to: number;
110+
111+
if (contentType === "none") {
112+
// For blocks with no content, position at the block level
113+
from = posInfo.posBeforeNode;
114+
to = posInfo.posBeforeNode + posInfo.node.nodeSize;
115+
} else if (contentType === "inline") {
116+
// For inline content, calculate position within the content
117+
const contentStart = blockContent.beforePos + 1;
118+
const contentEnd = blockContent.afterPos - 1;
119+
const maxOffset = contentEnd - contentStart;
120+
121+
if (point.offset > maxOffset) {
122+
throw new Error(
123+
`Offset ${point.offset} exceeds block content length ${maxOffset}`,
124+
);
125+
}
126+
127+
from = contentStart + point.offset;
128+
to = contentStart + point.offset;
129+
} else if (contentType === "table") {
130+
// For table content, we need to navigate to the first cell
131+
const cellStart = blockContent.beforePos + 4; // Skip table, tableRow, tableCell
132+
const cellEnd = blockContent.afterPos - 4;
133+
const maxOffset = cellEnd - cellStart;
134+
135+
if (point.offset > maxOffset) {
136+
throw new Error(
137+
`Offset ${point.offset} exceeds table content length ${maxOffset}`,
138+
);
139+
}
140+
141+
from = cellStart + point.offset;
142+
to = cellStart + point.offset;
143+
} else {
144+
throw new Error(`Unsupported content type: ${contentType}`);
145+
}
146+
147+
return {
148+
from,
149+
to,
150+
blockId: point.id,
151+
};
152+
}
153+
154+
/**
155+
* Resolves a Range to ProseMirror positions.
156+
* @param range The range to resolve
157+
* @returns The resolved ProseMirror positions
158+
*/
159+
private resolveRange(range: Range): {
160+
from: number;
161+
to: number;
162+
blockIds: BlockId[];
163+
} {
164+
const anchorPos = this.resolvePoint(range.anchor);
165+
const headPos = this.resolvePoint(range.head);
166+
167+
return {
168+
from: Math.min(anchorPos.from, headPos.from),
169+
to: Math.max(anchorPos.to, headPos.to),
170+
blockId: range.anchor.id, // Use anchor block ID as primary
171+
};
172+
}
173+
174+
/**
175+
* Gets the content type of a block for position calculations.
176+
* @param blockInfo The block info
177+
* @returns The content type
178+
*/
179+
private getBlockContentType(blockInfo: any): "none" | "inline" | "table" {
180+
const pmSchema = getPmSchema(this.editor.prosemirrorState.doc);
181+
const schema = this.editor.schema;
182+
183+
const blockType = blockInfo.blockNoteType;
184+
const blockConfig = schema.blockSchema[blockType];
185+
186+
if (!blockConfig) {
187+
throw new Error(`Unknown block type: ${blockType}`);
188+
}
189+
190+
return blockConfig.content;
191+
}
192+
193+
/**
194+
* Sets the selection to a location.
195+
* @param location The location to select
196+
*/
197+
public setSelectionToLocation(location: Location): void {
198+
const resolved = this.resolveLocation(location);
199+
200+
this.editor.transact((tr) => {
201+
tr.setSelection(TextSelection.create(tr.doc, resolved.from, resolved.to));
202+
});
203+
}
204+
205+
/**
206+
* Gets the current selection as a Location.
207+
* @returns The current selection as a Location, or undefined if no selection
208+
*/
209+
public getCurrentSelectionAsLocation(): Location | undefined {
210+
const selection = this.editor.prosemirrorState.selection;
211+
212+
if (selection.empty) {
213+
return undefined;
214+
}
215+
216+
// Get block info for anchor and head positions
217+
const anchorBlockInfo = getBlockInfo(
218+
this.editor.prosemirrorState.doc.resolve(selection.from),
219+
);
220+
const headBlockInfo = getBlockInfo(
221+
this.editor.prosemirrorState.doc.resolve(selection.to),
222+
);
223+
224+
// If selection is within a single block, return a Point
225+
if (anchorBlockInfo.blockId === headBlockInfo.blockId) {
226+
const offset = this.calculateOffsetInBlock(
227+
anchorBlockInfo,
228+
selection.from,
229+
);
230+
return {
231+
id: anchorBlockInfo.blockId,
232+
offset,
233+
};
234+
}
235+
236+
// If selection spans multiple blocks, return a Range
237+
const anchorOffset = this.calculateOffsetInBlock(
238+
anchorBlockInfo,
239+
selection.from,
240+
);
241+
const headOffset = this.calculateOffsetInBlock(headBlockInfo, selection.to);
242+
243+
return {
244+
anchor: {
245+
id: anchorBlockInfo.blockId,
246+
offset: anchorOffset,
247+
},
248+
head: {
249+
id: headBlockInfo.blockId,
250+
offset: headOffset,
251+
},
252+
};
253+
}
254+
255+
/**
256+
* Calculates the character offset within a block.
257+
* @param blockInfo The block info
258+
* @param pos The ProseMirror position
259+
* @returns The character offset
260+
*/
261+
private calculateOffsetInBlock(blockInfo: any, pos: number): number {
262+
if (!blockInfo.isBlockContainer) {
263+
return -1; // Block-level operation
264+
}
265+
266+
const blockContent = blockInfo.blockContent;
267+
const contentType = this.getBlockContentType(blockInfo);
268+
269+
if (contentType === "none") {
270+
return -1; // Block-level operation
271+
} else if (contentType === "inline") {
272+
return pos - (blockContent.beforePos + 1);
273+
} else if (contentType === "table") {
274+
return pos - (blockContent.beforePos + 4);
275+
}
276+
277+
return -1;
278+
}
279+
}

0 commit comments

Comments
 (0)