@tiptap/extensions 3.27.1 → 3.27.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "name": "@tiptap/extensions",
3
- "version": "3.27.1",
3
+ "version": "3.27.3",
4
4
  "description": "various extensions for tiptap",
5
5
  "keywords": [
6
6
  "tiptap",
7
7
  "tiptap extension"
8
8
  ],
9
9
  "homepage": "https://tiptap.dev",
10
+ "bugs": {
11
+ "url": "https://github.com/ueberdosis/tiptap/issues"
12
+ },
10
13
  "license": "MIT",
11
14
  "repository": {
12
15
  "type": "git",
13
16
  "url": "https://github.com/ueberdosis/tiptap",
14
17
  "directory": "packages/extension"
15
18
  },
16
- "bugs": {
17
- "url": "https://github.com/ueberdosis/tiptap/issues"
18
- },
19
19
  "funding": {
20
20
  "type": "github",
21
21
  "url": "https://github.com/sponsors/ueberdosis"
@@ -103,12 +103,12 @@
103
103
  }
104
104
  },
105
105
  "devDependencies": {
106
- "@tiptap/core": "^3.27.1",
107
- "@tiptap/pm": "^3.27.1"
106
+ "@tiptap/core": "^3.27.3",
107
+ "@tiptap/pm": "^3.27.3"
108
108
  },
109
109
  "peerDependencies": {
110
- "@tiptap/core": "3.27.1",
111
- "@tiptap/pm": "3.27.1"
110
+ "@tiptap/core": "3.27.3",
111
+ "@tiptap/pm": "3.27.3"
112
112
  },
113
113
  "scripts": {
114
114
  "build": "tsup"
@@ -1,17 +1,8 @@
1
1
  import { PluginKey } from '@tiptap/pm/state'
2
-
3
- import type { ViewportState } from './types.js'
2
+ import type { DecorationSet } from '@tiptap/pm/view'
4
3
 
5
4
  /** The default data attribute label */
6
5
  export const DEFAULT_DATA_ATTRIBUTE = 'placeholder'
7
6
 
8
- /** The plugin key used to store and read the placeholder viewport state */
9
- export const PLUGIN_KEY = new PluginKey<ViewportState>('tiptap__placeholder')
10
-
11
- /**
12
- * Extra pixels added above and below the visible viewport when computing the
13
- * range of nodes to decorate. Decorating slightly beyond the fold means a
14
- * node already has its placeholder before it scrolls into view, which hides
15
- * the latency introduced by the throttled viewport recompute.
16
- */
17
- export const VIEWPORT_OVERSCAN_PX = 200
7
+ /** The plugin key used to store and read the placeholder decoration set */
8
+ export const PLUGIN_KEY = new PluginKey<DecorationSet>('tiptap__placeholder')
@@ -1,11 +1,12 @@
1
1
  import type { Editor } from '@tiptap/core'
2
2
  import { Plugin } from '@tiptap/pm/state'
3
+ import { DecorationSet } from '@tiptap/pm/view'
3
4
 
4
5
  import { DEFAULT_DATA_ATTRIBUTE, PLUGIN_KEY } from '../constants.js'
5
6
  import type { PlaceholderOptions } from '../types.js'
6
7
  import { buildPlaceholderDecorations } from '../utils/buildPlaceholderDecorations.js'
8
+ import { createPlaceholderStateField } from '../utils/placeholderStateField.js'
7
9
  import { preparePlaceholderAttribute } from '../utils/preparePlaceholderAttribute.js'
8
- import { createViewportPluginView, viewportPluginState } from '../utils/viewportTracking.js'
9
10
 
10
11
  export type CreatePluginOptions = {
11
12
  editor: Editor
@@ -23,13 +24,26 @@ export function createPlaceholderPlugin({ editor, options }: CreatePluginOptions
23
24
  ? `data-${preparePlaceholderAttribute(options.dataAttribute)}`
24
25
  : `data-${DEFAULT_DATA_ATTRIBUTE}`
25
26
 
27
+ const useResolvedPath = options.showOnlyCurrent && !options.includeChildren
28
+
26
29
  return new Plugin({
27
30
  key: PLUGIN_KEY,
28
- state: viewportPluginState,
29
- view: createViewportPluginView,
31
+ ...(useResolvedPath
32
+ ? {}
33
+ : {
34
+ state: createPlaceholderStateField({ editor, options, dataAttribute }),
35
+ }),
30
36
  props: {
31
- decorations: ({ doc, selection }) =>
32
- buildPlaceholderDecorations({ editor, options, dataAttribute, doc, selection }),
37
+ decorations: useResolvedPath
38
+ ? ({ doc, selection }) =>
39
+ buildPlaceholderDecorations({ editor, options, dataAttribute, doc, selection })
40
+ : state => {
41
+ if (options.showOnlyWhenEditable && !editor.isEditable) {
42
+ return DecorationSet.empty
43
+ }
44
+
45
+ return PLUGIN_KEY.getState(state) ?? DecorationSet.empty
46
+ },
33
47
  },
34
48
  })
35
49
  }
@@ -1,16 +1,6 @@
1
1
  import type { Editor } from '@tiptap/core'
2
2
  import type { Node as ProsemirrorNode } from '@tiptap/pm/model'
3
3
 
4
- /**
5
- * The viewport positions tracked by the placeholder plugin.
6
- * `null` means "no viewport info yet" — the decoration callback falls back to
7
- * a full document scan until the scroll handler fires.
8
- */
9
- export interface ViewportState {
10
- topPos: number | null
11
- bottomPos: number | null
12
- }
13
-
14
4
  export interface PlaceholderOptions {
15
5
  /**
16
6
  * **The class name for the empty editor**
@@ -5,7 +5,6 @@ import type { Selection } from '@tiptap/pm/state'
5
5
  import type { Decoration } from '@tiptap/pm/view'
6
6
  import { DecorationSet } from '@tiptap/pm/view'
7
7
 
8
- import { PLUGIN_KEY } from '../constants.js'
9
8
  import type { PlaceholderOptions } from '../types.js'
10
9
  import { createPlaceholderDecoration } from './createPlaceholderDecoration.js'
11
10
 
@@ -16,6 +15,68 @@ function resolveEmptyNodeClass(
16
15
  return typeof emptyNodeClass === 'function' ? emptyNodeClass(props) : emptyNodeClass
17
16
  }
18
17
 
18
+ /**
19
+ * Scans a document range for empty textblocks that should receive placeholder
20
+ * decorations. Used by the slow path and incremental state updates.
21
+ */
22
+ export function scanRangeForDecorations({
23
+ editor,
24
+ options,
25
+ dataAttribute,
26
+ doc,
27
+ selection,
28
+ from,
29
+ to,
30
+ }: {
31
+ editor: Editor
32
+ options: PlaceholderOptions
33
+ dataAttribute: string
34
+ doc: Node
35
+ selection: Selection
36
+ from: number
37
+ to: number
38
+ }): Decoration[] {
39
+ const { anchor } = selection
40
+ const decorations: Decoration[] = []
41
+ const isEmptyDoc = editor.isEmpty
42
+
43
+ doc.nodesBetween(from, to, (node, pos) => {
44
+ const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize
45
+ const isEmpty = !node.isLeaf && isNodeEmpty(node)
46
+
47
+ if (!node.type.isTextblock) {
48
+ return options.includeChildren
49
+ }
50
+
51
+ if ((hasAnchor || !options.showOnlyCurrent) && isEmpty) {
52
+ decorations.push(
53
+ createPlaceholderDecoration({
54
+ editor,
55
+ isEmptyDoc,
56
+ dataAttribute,
57
+ hasAnchor,
58
+ placeholder: options.placeholder,
59
+ classes: {
60
+ emptyEditor: options.emptyEditorClass,
61
+ emptyNode: resolveEmptyNodeClass(options.emptyNodeClass, {
62
+ editor,
63
+ node,
64
+ pos,
65
+ hasAnchor,
66
+ }),
67
+ },
68
+ node,
69
+ pos,
70
+ }),
71
+ )
72
+ }
73
+
74
+ return options.includeChildren
75
+ })
76
+
77
+ return decorations
78
+ }
79
+
19
80
  /**
20
81
  * Builds the placeholder decorations for the current document state.
21
82
  * @param options.editor - The editor instance.
@@ -86,43 +147,17 @@ export function buildPlaceholderDecorations({
86
147
  )
87
148
  }
88
149
  } else {
89
- const pluginState = PLUGIN_KEY.getState(editor.state)
90
- const from = pluginState?.topPos ?? 0
91
- const to = pluginState?.bottomPos ?? doc.content.size
92
-
93
- doc.nodesBetween(from, to, (node, pos) => {
94
- const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize
95
- const isEmpty = !node.isLeaf && isNodeEmpty(node)
96
-
97
- if (!node.type.isTextblock) {
98
- return options.includeChildren
99
- }
100
-
101
- if ((hasAnchor || !options.showOnlyCurrent) && isEmpty) {
102
- decorations.push(
103
- createPlaceholderDecoration({
104
- editor,
105
- isEmptyDoc,
106
- dataAttribute,
107
- hasAnchor,
108
- placeholder: options.placeholder,
109
- classes: {
110
- emptyEditor: options.emptyEditorClass,
111
- emptyNode: resolveEmptyNodeClass(options.emptyNodeClass, {
112
- editor,
113
- node,
114
- pos,
115
- hasAnchor,
116
- }),
117
- },
118
- node,
119
- pos,
120
- }),
121
- )
122
- }
123
-
124
- return options.includeChildren
125
- })
150
+ decorations.push(
151
+ ...scanRangeForDecorations({
152
+ editor,
153
+ options,
154
+ dataAttribute,
155
+ doc,
156
+ selection,
157
+ from: 0,
158
+ to: doc.content.size,
159
+ }),
160
+ )
126
161
  }
127
162
 
128
163
  return DecorationSet.create(doc, decorations)
@@ -1,6 +1,3 @@
1
1
  export * from './buildPlaceholderDecorations.js'
2
2
  export * from './createPlaceholderDecoration.js'
3
- export * from './findScrollParent.js'
4
- export * from './getViewportBoundaryPositions.js'
5
3
  export * from './preparePlaceholderAttribute.js'
6
- export * from './viewportTracking.js'
@@ -0,0 +1,204 @@
1
+ import type { Editor } from '@tiptap/core'
2
+ import { getChangedRanges } from '@tiptap/core'
3
+ import type { Node } from '@tiptap/pm/model'
4
+ import type { EditorState, StateField, Transaction } from '@tiptap/pm/state'
5
+ import type { Selection } from '@tiptap/pm/state'
6
+ import { DecorationSet } from '@tiptap/pm/view'
7
+
8
+ import type { PlaceholderOptions } from '../types.js'
9
+ import {
10
+ buildPlaceholderDecorations,
11
+ scanRangeForDecorations,
12
+ } from './buildPlaceholderDecorations.js'
13
+ import {
14
+ getTopLevelBlocksInRange,
15
+ mergeRanges,
16
+ resolveTopLevelRange,
17
+ toContentRelativeRange,
18
+ } from './resolveTopLevelRange.js'
19
+
20
+ /** Options passed to {@link createPlaceholderStateField}. */
21
+ export type CreatePlaceholderStateFieldOptions = {
22
+ editor: Editor
23
+ options: PlaceholderOptions
24
+ dataAttribute: string
25
+ }
26
+
27
+ /**
28
+ * Expands a single changed range to the top-level blocks it touches.
29
+ * Also resolves blocks at range boundaries so split/merge edits update
30
+ * adjacent empty nodes (e.g. a new paragraph after Enter).
31
+ */
32
+ function collectBlocksForChange(
33
+ doc: Node,
34
+ change: { from: number; to: number },
35
+ ): Array<{ from: number; to: number }> {
36
+ const ranges = getTopLevelBlocksInRange(doc, change.from, change.to)
37
+
38
+ ranges.push(toContentRelativeRange(doc, resolveTopLevelRange(doc, change.from)))
39
+
40
+ if (change.to > change.from) {
41
+ ranges.push(
42
+ toContentRelativeRange(
43
+ doc,
44
+ resolveTopLevelRange(doc, Math.min(change.to, doc.content.size + 1) - 1),
45
+ ),
46
+ )
47
+ } else if (change.from < doc.content.size + 1) {
48
+ ranges.push(
49
+ toContentRelativeRange(
50
+ doc,
51
+ resolveTopLevelRange(doc, Math.min(change.from + 1, doc.content.size)),
52
+ ),
53
+ )
54
+ }
55
+
56
+ return ranges
57
+ }
58
+
59
+ /**
60
+ * Collects content-relative top-level block ranges that need placeholder
61
+ * decorations recomputed after a transaction.
62
+ */
63
+ function collectRescanRanges(
64
+ tr: Transaction,
65
+ oldState: EditorState,
66
+ newState: EditorState,
67
+ ): Array<{ from: number; to: number }> {
68
+ const ranges: Array<{ from: number; to: number }> = []
69
+
70
+ if (tr.docChanged) {
71
+ const changes = getChangedRanges(tr)
72
+
73
+ for (const change of changes) {
74
+ ranges.push(...collectBlocksForChange(newState.doc, change.newRange))
75
+ }
76
+ }
77
+
78
+ if (tr.selectionSet) {
79
+ ranges.push(
80
+ toContentRelativeRange(
81
+ newState.doc,
82
+ resolveTopLevelRange(newState.doc, tr.mapping.map(oldState.selection.anchor)),
83
+ ),
84
+ )
85
+ ranges.push(
86
+ toContentRelativeRange(
87
+ newState.doc,
88
+ resolveTopLevelRange(newState.doc, newState.selection.anchor),
89
+ ),
90
+ )
91
+ }
92
+
93
+ return mergeRanges(ranges)
94
+ }
95
+
96
+ /** Clamps a content-relative range to `[0, doc.content.size]`. */
97
+ function clampRange(from: number, to: number, doc: Node): { from: number; to: number } {
98
+ const clampedFrom = Math.max(0, Math.min(from, doc.content.size))
99
+ const clampedTo = Math.max(clampedFrom, Math.min(to, doc.content.size))
100
+
101
+ return { from: clampedFrom, to: clampedTo }
102
+ }
103
+
104
+ /**
105
+ * Removes and rebuilds placeholder decorations within the given ranges.
106
+ * Only drops decorations fully contained in a range so mapped decorations
107
+ * on neighbouring blocks (e.g. at a block boundary) are kept intact.
108
+ */
109
+ function updateDecorationsInRanges({
110
+ decorations,
111
+ ranges,
112
+ editor,
113
+ options,
114
+ dataAttribute,
115
+ doc,
116
+ selection,
117
+ }: {
118
+ decorations: DecorationSet
119
+ ranges: Array<{ from: number; to: number }>
120
+ editor: Editor
121
+ options: PlaceholderOptions
122
+ dataAttribute: string
123
+ doc: Node
124
+ selection: Selection
125
+ }): DecorationSet {
126
+ let next = decorations
127
+
128
+ for (const range of ranges) {
129
+ const { from, to } = clampRange(range.from, range.to, doc)
130
+ const existing = next
131
+ .find(from, to)
132
+ .filter(decoration => decoration.from >= from && decoration.to <= to)
133
+
134
+ if (existing.length) {
135
+ next = next.remove(existing)
136
+ }
137
+
138
+ const newDecos = scanRangeForDecorations({
139
+ editor,
140
+ options,
141
+ dataAttribute,
142
+ doc,
143
+ selection,
144
+ from,
145
+ to,
146
+ })
147
+
148
+ if (newDecos.length) {
149
+ next = next.add(doc, newDecos)
150
+ }
151
+ }
152
+
153
+ return next
154
+ }
155
+
156
+ /**
157
+ * Creates the incremental `StateField<DecorationSet>` used by the slow path
158
+ * (`showOnlyCurrent: false` or `includeChildren: true`).
159
+ *
160
+ * Decorations are mapped through each transaction and only recomputed for
161
+ * top-level blocks touched by document or selection changes.
162
+ * @param options.editor - The editor instance.
163
+ * @param options.options - The resolved placeholder options.
164
+ * @param options.dataAttribute - The prepared `data-*` attribute name.
165
+ * @returns A ProseMirror state field storing the placeholder decoration set.
166
+ */
167
+ export function createPlaceholderStateField({
168
+ editor,
169
+ options,
170
+ dataAttribute,
171
+ }: CreatePlaceholderStateFieldOptions): StateField<DecorationSet> {
172
+ return {
173
+ init(_config, state: EditorState) {
174
+ const decorations = buildPlaceholderDecorations({
175
+ editor,
176
+ options,
177
+ dataAttribute,
178
+ doc: state.doc,
179
+ selection: state.selection,
180
+ })
181
+
182
+ return decorations ?? DecorationSet.empty
183
+ },
184
+
185
+ apply(tr: Transaction, prev: DecorationSet, oldState: EditorState, newState: EditorState) {
186
+ if (!tr.docChanged && !tr.selectionSet) {
187
+ return prev
188
+ }
189
+
190
+ const mapped = prev.map(tr.mapping, tr.doc)
191
+ const ranges = collectRescanRanges(tr, oldState, newState)
192
+
193
+ return updateDecorationsInRanges({
194
+ decorations: mapped,
195
+ ranges,
196
+ editor,
197
+ options,
198
+ dataAttribute,
199
+ doc: newState.doc,
200
+ selection: newState.selection,
201
+ })
202
+ },
203
+ }
204
+ }
@@ -0,0 +1,93 @@
1
+ import type { Node } from '@tiptap/pm/model'
2
+
3
+ /**
4
+ * Resolves a document position to the `[from, to)` range of its containing
5
+ * top-level block node in absolute document positions.
6
+ */
7
+ export function resolveTopLevelRange(doc: Node, pos: number): { from: number; to: number } {
8
+ const resolved = doc.resolve(pos)
9
+
10
+ if (resolved.depth === 0) {
11
+ const node = resolved.nodeAfter ?? resolved.nodeBefore
12
+
13
+ if (!node) {
14
+ return { from: pos, to: pos }
15
+ }
16
+
17
+ const nodePos = resolved.nodeAfter ? pos : pos - node.nodeSize
18
+
19
+ return { from: nodePos, to: nodePos + node.nodeSize }
20
+ }
21
+
22
+ const topLevelPos = resolved.before(1)
23
+ const node = resolved.node(1)
24
+
25
+ return { from: topLevelPos, to: topLevelPos + node.nodeSize }
26
+ }
27
+
28
+ /**
29
+ * Converts an absolute document range to content-relative positions used by
30
+ * `Node#nodesBetween` and `Node#forEach` offsets.
31
+ */
32
+ export function toContentRelativeRange(
33
+ doc: Node,
34
+ range: { from: number; to: number },
35
+ ): { from: number; to: number } {
36
+ return {
37
+ from: Math.max(0, range.from - 1),
38
+ to: Math.min(doc.content.size, range.to - 1),
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Returns the top-level block ranges that intersect a document change range.
44
+ * Input `from`/`to` are absolute positions (e.g. from `getChangedRanges`).
45
+ * Returned ranges are content-relative, matching `Node#forEach` offsets.
46
+ */
47
+ export function getTopLevelBlocksInRange(
48
+ doc: Node,
49
+ from: number,
50
+ to: number,
51
+ ): Array<{ from: number; to: number }> {
52
+ const ranges: Array<{ from: number; to: number }> = []
53
+
54
+ doc.forEach((node, offset) => {
55
+ const nodeStart = offset
56
+ const nodeEnd = nodeStart + node.nodeSize
57
+ const absNodeStart = nodeStart + 1
58
+ const absNodeEnd = nodeEnd + 1
59
+
60
+ if (absNodeStart < to && absNodeEnd > from) {
61
+ ranges.push({ from: nodeStart, to: nodeEnd })
62
+ }
63
+ })
64
+
65
+ return ranges
66
+ }
67
+
68
+ /**
69
+ * Sorts ranges by start position and merges overlapping or adjacent ranges.
70
+ */
71
+ export function mergeRanges(
72
+ ranges: Array<{ from: number; to: number }>,
73
+ ): Array<{ from: number; to: number }> {
74
+ if (ranges.length === 0) {
75
+ return []
76
+ }
77
+
78
+ const sorted = [...ranges].sort((a, b) => a.from - b.from)
79
+ const merged: Array<{ from: number; to: number }> = [{ ...sorted[0] }]
80
+
81
+ for (let i = 1; i < sorted.length; i += 1) {
82
+ const last = merged[merged.length - 1]
83
+ const current = sorted[i]
84
+
85
+ if (current.from <= last.to) {
86
+ last.to = Math.max(last.to, current.to)
87
+ } else {
88
+ merged.push({ ...current })
89
+ }
90
+ }
91
+
92
+ return merged
93
+ }
@@ -1,38 +0,0 @@
1
- /**
2
- * Checks if an element is scrollable by testing its overflow properties.
3
- * Elements with `overflow: hidden` or `overflow: clip` are intentionally
4
- * excluded — they clip content but don't emit scroll events.
5
- */
6
- function isScrollable(el: HTMLElement): boolean {
7
- const style = getComputedStyle(el)
8
- const overflow = `${style.overflow} ${style.overflowY} ${style.overflowX}`
9
-
10
- return /auto|scroll|overlay/.test(overflow)
11
- }
12
-
13
- export function findScrollParent(element: HTMLElement): HTMLElement | Window {
14
- let el: HTMLElement | null = element
15
-
16
- while (el) {
17
- if (isScrollable(el)) {
18
- return el
19
- }
20
-
21
- // Check if we hit a Shadow DOM boundary. If so, jump to the shadow host
22
- // and continue traversing the light DOM.
23
- const parent = el.parentElement
24
- if (!parent) {
25
- const root = el.getRootNode()
26
- if (root instanceof ShadowRoot) {
27
- el = root.host as HTMLElement
28
- continue
29
- }
30
-
31
- return window
32
- }
33
-
34
- el = parent
35
- }
36
-
37
- return window
38
- }
@@ -1,74 +0,0 @@
1
- import type { EditorView } from '@tiptap/pm/view'
2
-
3
- import { VIEWPORT_OVERSCAN_PX } from '../constants.js'
4
-
5
- function getContainerRect(container: HTMLElement | Window): { top: number; bottom: number } {
6
- if (container === window) {
7
- return { top: 0, bottom: window.innerHeight }
8
- }
9
-
10
- return (container as HTMLElement).getBoundingClientRect()
11
- }
12
-
13
- /**
14
- * Computes the document positions bounding the currently visible viewport.
15
- */
16
- export function getViewportBoundaryPositions({
17
- view,
18
- scrollContainer,
19
- }: {
20
- view: EditorView
21
- scrollContainer?: HTMLElement | Window
22
- }): { top: number; bottom: number } | null {
23
- const editorRect = view.dom.getBoundingClientRect()
24
-
25
- // No usable layout — can't probe a coordinate inside the box.
26
- if (editorRect.width <= 0 || editorRect.height <= 0) {
27
- return null
28
- }
29
-
30
- const containerRect = scrollContainer
31
- ? getContainerRect(scrollContainer)
32
- : { top: 0, bottom: window.innerHeight }
33
-
34
- const visibleTop = Math.max(editorRect.top, containerRect.top) - VIEWPORT_OVERSCAN_PX
35
- const visibleBottom = Math.min(editorRect.bottom, containerRect.bottom) + VIEWPORT_OVERSCAN_PX
36
-
37
- if (visibleTop >= visibleBottom) {
38
- // Editor is scrolled fully out of its container so not measurable.
39
- return null
40
- }
41
-
42
- // Probe the start edge (left in LTR, right in RTL), clamped inside the box
43
- // so a too-narrow editor doesn't push the probe out.
44
- const minX = editorRect.left + 1
45
- const maxX = editorRect.right - 1
46
-
47
- if (minX > maxX) {
48
- return null
49
- }
50
-
51
- const isRTL = getComputedStyle(view.dom).direction === 'rtl'
52
- const targetX = isRTL ? editorRect.right - 2 : editorRect.left + 2
53
- const x = Math.min(Math.max(targetX, minX), maxX)
54
-
55
- // Clamp the probe y inside the box so the overscan doesn't push it past the
56
- // editor's own content (which would make `posAtCoords` null for no reason).
57
- const probeTop = Math.max(visibleTop + 2, editorRect.top + 1)
58
- const probeBottom = Math.min(visibleBottom - 2, editorRect.bottom - 1)
59
-
60
- if (probeTop > probeBottom) {
61
- return null
62
- }
63
-
64
- const topPos = view.posAtCoords({ left: x, top: probeTop })
65
- const bottomPos = view.posAtCoords({ left: x, top: probeBottom })
66
-
67
- // Null usually means occlusion (but can be benign). Either way it's
68
- // unreliable, so freeze the previous window instead of decorating the whole doc.
69
- if (!topPos || !bottomPos) {
70
- return null
71
- }
72
-
73
- return { top: topPos.pos, bottom: bottomPos.pos }
74
- }