@tiptap/extensions 3.27.2 → 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/dist/index.cjs +236 -189
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -15
- package/dist/index.d.ts +7 -15
- package/dist/index.js +225 -178
- package/dist/index.js.map +1 -1
- package/dist/placeholder/index.cjs +225 -178
- package/dist/placeholder/index.cjs.map +1 -1
- package/dist/placeholder/index.d.cts +7 -15
- package/dist/placeholder/index.d.ts +7 -15
- package/dist/placeholder/index.js +223 -176
- package/dist/placeholder/index.js.map +1 -1
- package/package.json +5 -5
- package/src/placeholder/constants.ts +3 -12
- package/src/placeholder/plugins/PlaceholderPlugin.ts +19 -5
- package/src/placeholder/types.ts +0 -10
- package/src/placeholder/utils/buildPlaceholderDecorations.ts +73 -38
- package/src/placeholder/utils/index.ts +0 -3
- package/src/placeholder/utils/placeholderStateField.ts +204 -0
- package/src/placeholder/utils/resolveTopLevelRange.ts +93 -0
- package/src/placeholder/utils/findScrollParent.ts +0 -38
- package/src/placeholder/utils/getViewportBoundaryPositions.ts +0 -74
- package/src/placeholder/utils/throttle.ts +0 -28
- package/src/placeholder/utils/viewportTracking.ts +0 -137
|
@@ -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
|
|
9
|
-
export const PLUGIN_KEY = new PluginKey<
|
|
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
|
-
|
|
29
|
-
|
|
31
|
+
...(useResolvedPath
|
|
32
|
+
? {}
|
|
33
|
+
: {
|
|
34
|
+
state: createPlaceholderStateField({ editor, options, dataAttribute }),
|
|
35
|
+
}),
|
|
30
36
|
props: {
|
|
31
|
-
decorations:
|
|
32
|
-
|
|
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
|
}
|
package/src/placeholder/types.ts
CHANGED
|
@@ -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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
}
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
export function throttle<T extends (...args: any[]) => void>(
|
|
2
|
-
fn: T,
|
|
3
|
-
delay: number,
|
|
4
|
-
): { call: T; cancel: () => void } {
|
|
5
|
-
let timer: ReturnType<typeof setTimeout> | null = null
|
|
6
|
-
|
|
7
|
-
const call = ((...args: any[]) => {
|
|
8
|
-
if (timer) {
|
|
9
|
-
return
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
// Leading-edge: fire immediately, then prevent subsequent calls
|
|
13
|
-
// until the timer fires and resets.
|
|
14
|
-
fn(...args)
|
|
15
|
-
timer = setTimeout(() => {
|
|
16
|
-
timer = null
|
|
17
|
-
}, delay)
|
|
18
|
-
}) as T
|
|
19
|
-
|
|
20
|
-
const cancel = () => {
|
|
21
|
-
if (timer) {
|
|
22
|
-
clearTimeout(timer)
|
|
23
|
-
timer = null
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
return { call, cancel }
|
|
28
|
-
}
|