0
# Position Mapping
1
2
Position mapping provides bidirectional conversion between document positions (abstract numerical positions in the ProseMirror document) and DOM coordinates (pixel positions in the browser viewport). This is essential for handling user interactions, managing selections, and implementing features like tooltips and contextual menus.
3
4
## Capabilities
5
6
### Document Position to Coordinates
7
8
Convert document positions to viewport coordinates.
9
10
```typescript { .api }
11
class EditorView {
12
/**
13
* Returns the viewport rectangle at a given document position.
14
* `left` and `right` will be the same number, as this returns a
15
* flat cursor-ish rectangle. If the position is between two things
16
* that aren't directly adjacent, `side` determines which element
17
* is used. When < 0, the element before the position is used,
18
* otherwise the element after.
19
*/
20
coordsAtPos(
21
pos: number,
22
side?: number
23
): {left: number, right: number, top: number, bottom: number};
24
}
25
```
26
27
**Usage Examples:**
28
29
```typescript
30
import { EditorView } from "prosemirror-view";
31
32
// Get coordinates at document position
33
const coords = view.coordsAtPos(15);
34
console.log(`Position 15 is at: ${coords.left}px, ${coords.top}px`);
35
36
// Get coordinates with side preference
37
const coordsBefore = view.coordsAtPos(15, -1); // Prefer element before
38
const coordsAfter = view.coordsAtPos(15, 1); // Prefer element after
39
40
// Position a tooltip at a specific document position
41
function showTooltipAtPosition(view, pos, content) {
42
const coords = view.coordsAtPos(pos);
43
const tooltip = document.createElement("div");
44
tooltip.className = "tooltip";
45
tooltip.textContent = content;
46
tooltip.style.position = "absolute";
47
tooltip.style.left = coords.left + "px";
48
tooltip.style.top = (coords.top - 30) + "px";
49
document.body.appendChild(tooltip);
50
}
51
```
52
53
### Coordinates to Document Position
54
55
Convert viewport coordinates to document positions.
56
57
```typescript { .api }
58
class EditorView {
59
/**
60
* Given a pair of viewport coordinates, return the document
61
* position that corresponds to them. May return null if the given
62
* coordinates aren't inside of the editor. When an object is
63
* returned, its `pos` property is the position nearest to the
64
* coordinates, and its `inside` property holds the position of the
65
* inner node that the position falls inside of, or -1 if it is at
66
* the top level, not in any node.
67
*/
68
posAtCoords(coords: {left: number, top: number}): {
69
pos: number,
70
inside: number
71
} | null;
72
}
73
```
74
75
**Usage Examples:**
76
77
```typescript
78
// Handle mouse click to get document position
79
view.dom.addEventListener("click", (event) => {
80
const result = view.posAtCoords({
81
left: event.clientX,
82
top: event.clientY
83
});
84
85
if (result) {
86
console.log(`Clicked at document position: ${result.pos}`);
87
console.log(`Inside node at position: ${result.inside}`);
88
89
// Create selection at click position
90
const tr = view.state.tr.setSelection(
91
TextSelection.create(view.state.doc, result.pos)
92
);
93
view.dispatch(tr);
94
}
95
});
96
97
// Implement drag-to-select functionality
98
let isDragging = false;
99
let startPos = null;
100
101
view.dom.addEventListener("mousedown", (event) => {
102
const result = view.posAtCoords({
103
left: event.clientX,
104
top: event.clientY
105
});
106
107
if (result) {
108
isDragging = true;
109
startPos = result.pos;
110
}
111
});
112
113
view.dom.addEventListener("mousemove", (event) => {
114
if (!isDragging || startPos === null) return;
115
116
const result = view.posAtCoords({
117
left: event.clientX,
118
top: event.clientY
119
});
120
121
if (result) {
122
const selection = TextSelection.create(
123
view.state.doc,
124
Math.min(startPos, result.pos),
125
Math.max(startPos, result.pos)
126
);
127
view.dispatch(view.state.tr.setSelection(selection));
128
}
129
});
130
```
131
132
### DOM Position Mapping
133
134
Convert between document positions and DOM node/offset pairs.
135
136
```typescript { .api }
137
class EditorView {
138
/**
139
* Find the DOM position that corresponds to the given document
140
* position. When `side` is negative, find the position as close as
141
* possible to the content before the position. When positive,
142
* prefer positions close to the content after the position. When
143
* zero, prefer as shallow a position as possible.
144
*
145
* Note that you should **not** mutate the editor's internal DOM,
146
* only inspect it.
147
*/
148
domAtPos(pos: number, side?: number): {node: DOMNode, offset: number};
149
150
/**
151
* Find the document position that corresponds to a given DOM
152
* position. The `bias` parameter can be used to influence which
153
* side of a DOM node to use when the position is inside a leaf node.
154
*/
155
posAtDOM(node: DOMNode, offset: number, bias?: number): number;
156
}
157
```
158
159
**Usage Examples:**
160
161
```typescript
162
// Get DOM position for document position
163
const domPos = view.domAtPos(20);
164
console.log("DOM node:", domPos.node);
165
console.log("Offset within node:", domPos.offset);
166
167
// Create a DOM range at document position
168
function createRangeAtPosition(view, pos, length) {
169
const startDOM = view.domAtPos(pos);
170
const endDOM = view.domAtPos(pos + length);
171
172
const range = document.createRange();
173
range.setStart(startDOM.node, startDOM.offset);
174
range.setEnd(endDOM.node, endDOM.offset);
175
176
return range;
177
}
178
179
// Convert DOM selection to document position
180
function getDOMSelectionPos(view) {
181
const selection = window.getSelection();
182
if (!selection.rangeCount) return null;
183
184
const range = selection.getRangeAt(0);
185
const startPos = view.posAtDOM(range.startContainer, range.startOffset);
186
const endPos = view.posAtDOM(range.endContainer, range.endOffset);
187
188
return { from: startPos, to: endPos };
189
}
190
191
// Handle paste at specific DOM position
192
view.dom.addEventListener("paste", (event) => {
193
const selection = window.getSelection();
194
if (!selection.rangeCount) return;
195
196
const range = selection.getRangeAt(0);
197
const pos = view.posAtDOM(range.startContainer, range.startOffset);
198
199
// Handle paste at document position `pos`
200
const clipboardData = event.clipboardData.getData("text/plain");
201
const tr = view.state.tr.insertText(clipboardData, pos);
202
view.dispatch(tr);
203
204
event.preventDefault();
205
});
206
```
207
208
### Node DOM Access
209
210
Get DOM nodes that represent specific document nodes.
211
212
```typescript { .api }
213
class EditorView {
214
/**
215
* Find the DOM node that represents the document node after the
216
* given position. May return `null` when the position doesn't point
217
* in front of a node or if the node is inside an opaque node view.
218
*
219
* This is intended to be able to call things like
220
* `getBoundingClientRect` on that DOM node. Do **not** mutate the
221
* editor DOM directly, or add styling this way, since that will be
222
* immediately overridden by the editor as it redraws the node.
223
*/
224
nodeDOM(pos: number): DOMNode | null;
225
}
226
```
227
228
**Usage Examples:**
229
230
```typescript
231
// Get DOM node at position for measurement
232
const nodeDOM = view.nodeDOM(25);
233
if (nodeDOM) {
234
const rect = nodeDOM.getBoundingClientRect();
235
console.log("Node dimensions:", rect.width, "x", rect.height);
236
237
// Check if node is visible in viewport
238
const isVisible = rect.top >= 0 &&
239
rect.left >= 0 &&
240
rect.bottom <= window.innerHeight &&
241
rect.right <= window.innerWidth;
242
243
console.log("Node is visible:", isVisible);
244
}
245
246
// Highlight a specific node temporarily
247
function highlightNodeAtPosition(view, pos, duration = 2000) {
248
const nodeDOM = view.nodeDOM(pos);
249
if (!nodeDOM) return;
250
251
const originalStyle = nodeDOM.style.cssText;
252
nodeDOM.style.outline = "2px solid #007acc";
253
nodeDOM.style.outlineOffset = "2px";
254
255
setTimeout(() => {
256
nodeDOM.style.cssText = originalStyle;
257
}, duration);
258
}
259
```
260
261
### Text Block Navigation
262
263
Determine if cursor is at the edge of text blocks.
264
265
```typescript { .api }
266
class EditorView {
267
/**
268
* Find out whether the selection is at the end of a textblock when
269
* moving in a given direction. When, for example, given `"left"`,
270
* it will return true if moving left from the current cursor
271
* position would leave that position's parent textblock. Will apply
272
* to the view's current state by default, but it is possible to
273
* pass a different state.
274
*/
275
endOfTextblock(
276
dir: "up" | "down" | "left" | "right" | "forward" | "backward",
277
state?: EditorState
278
): boolean;
279
}
280
```
281
282
**Usage Examples:**
283
284
```typescript
285
// Check if at text block boundaries
286
const atLeftEdge = view.endOfTextblock("left");
287
const atRightEdge = view.endOfTextblock("right");
288
const atTopEdge = view.endOfTextblock("up");
289
const atBottomEdge = view.endOfTextblock("down");
290
291
console.log("At text block edges:", {
292
left: atLeftEdge,
293
right: atRightEdge,
294
top: atTopEdge,
295
bottom: atBottomEdge
296
});
297
298
// Custom key handler using textblock detection
299
function handleArrowKey(view, event) {
300
const { key } = event;
301
302
if (key === "ArrowLeft" && view.endOfTextblock("left")) {
303
// At left edge of text block - custom behavior
304
console.log("At left edge of text block");
305
306
// Maybe jump to previous block or show navigation
307
const selection = view.state.selection;
308
const $pos = selection.$from;
309
const prevBlock = $pos.nodeBefore;
310
311
if (prevBlock) {
312
const newPos = $pos.pos - prevBlock.nodeSize;
313
const newSelection = TextSelection.create(view.state.doc, newPos);
314
view.dispatch(view.state.tr.setSelection(newSelection));
315
event.preventDefault();
316
}
317
}
318
319
// Similar handling for other directions...
320
}
321
322
view.dom.addEventListener("keydown", (event) => {
323
if (event.key.startsWith("Arrow")) {
324
handleArrowKey(view, event);
325
}
326
});
327
```
328
329
**Complete Usage Example:**
330
331
```typescript
332
import { EditorView } from "prosemirror-view";
333
import { EditorState, TextSelection } from "prosemirror-state";
334
335
class PositionTracker {
336
constructor(view) {
337
this.view = view;
338
this.tooltip = this.createTooltip();
339
this.setupEventListeners();
340
}
341
342
createTooltip() {
343
const tooltip = document.createElement("div");
344
tooltip.className = "position-tooltip";
345
tooltip.style.cssText = `
346
position: absolute;
347
background: #333;
348
color: white;
349
padding: 4px 8px;
350
border-radius: 4px;
351
font-size: 12px;
352
pointer-events: none;
353
z-index: 1000;
354
display: none;
355
`;
356
document.body.appendChild(tooltip);
357
return tooltip;
358
}
359
360
setupEventListeners() {
361
// Track mouse position and show document position
362
this.view.dom.addEventListener("mousemove", (event) => {
363
const result = this.view.posAtCoords({
364
left: event.clientX,
365
top: event.clientY
366
});
367
368
if (result) {
369
this.tooltip.textContent = `Pos: ${result.pos}, Inside: ${result.inside}`;
370
this.tooltip.style.left = (event.clientX + 10) + "px";
371
this.tooltip.style.top = (event.clientY - 30) + "px";
372
this.tooltip.style.display = "block";
373
} else {
374
this.tooltip.style.display = "none";
375
}
376
});
377
378
this.view.dom.addEventListener("mouseleave", () => {
379
this.tooltip.style.display = "none";
380
});
381
382
// Track selection changes
383
this.view.dom.addEventListener("selectionchange", () => {
384
this.logSelectionInfo();
385
});
386
}
387
388
logSelectionInfo() {
389
const selection = this.view.state.selection;
390
console.log("Selection changed:");
391
console.log(`From: ${selection.from}, To: ${selection.to}`);
392
393
// Get coordinates of selection endpoints
394
const fromCoords = this.view.coordsAtPos(selection.from);
395
const toCoords = this.view.coordsAtPos(selection.to);
396
397
console.log("From coordinates:", fromCoords);
398
console.log("To coordinates:", toCoords);
399
400
// Check text block boundaries
401
const boundaries = {
402
left: this.view.endOfTextblock("left"),
403
right: this.view.endOfTextblock("right"),
404
up: this.view.endOfTextblock("up"),
405
down: this.view.endOfTextblock("down")
406
};
407
408
console.log("At text block boundaries:", boundaries);
409
}
410
411
// Utility method to scroll to a document position
412
scrollToPosition(pos) {
413
const coords = this.view.coordsAtPos(pos);
414
window.scrollTo({
415
left: coords.left - window.innerWidth / 2,
416
top: coords.top - window.innerHeight / 2,
417
behavior: "smooth"
418
});
419
}
420
421
// Create a visual indicator at a document position
422
showIndicatorAtPosition(pos, text = "•", duration = 3000) {
423
const coords = this.view.coordsAtPos(pos);
424
const indicator = document.createElement("div");
425
426
indicator.textContent = text;
427
indicator.style.cssText = `
428
position: absolute;
429
left: ${coords.left}px;
430
top: ${coords.top}px;
431
color: red;
432
font-weight: bold;
433
font-size: 16px;
434
pointer-events: none;
435
z-index: 1000;
436
animation: pulse 1s infinite;
437
`;
438
439
document.body.appendChild(indicator);
440
441
setTimeout(() => {
442
document.body.removeChild(indicator);
443
}, duration);
444
}
445
446
destroy() {
447
if (this.tooltip.parentNode) {
448
this.tooltip.parentNode.removeChild(this.tooltip);
449
}
450
}
451
}
452
453
// Usage
454
const view = new EditorView(document.querySelector("#editor"), {
455
state: myEditorState
456
});
457
458
const tracker = new PositionTracker(view);
459
460
// Example usage of position mapping
461
tracker.showIndicatorAtPosition(50, "📍");
462
tracker.scrollToPosition(100);
463
```