prosemirror-view 1.42.0 → 1.42.1

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/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## 1.42.1 (2026-07-11)
2
+
3
+ ### Bug fixes
4
+
5
+ Don't scroll to a bogus position when trying to scroll an element that has no layout into view.
6
+
1
7
  ## 1.42.0 (2026-07-01)
2
8
 
3
9
  ### Bug fixes
package/dist/index.cjs CHANGED
@@ -202,6 +202,7 @@ function clientRect(node) {
202
202
  };
203
203
  }
204
204
  function scrollRectIntoView(view, rect, startDOM) {
205
+ if (!nonZero(rect) && rect.left == 0) return;
205
206
  var scrollThreshold = view.someProp("scrollThreshold") || 0,
206
207
  scrollMargin = view.someProp("scrollMargin") || 5;
207
208
  var doc = view.dom.ownerDocument;
package/dist/index.js CHANGED
@@ -207,6 +207,9 @@ function clientRect(node) {
207
207
  top: rect.top, bottom: rect.top + node.clientHeight * scaleY };
208
208
  }
209
209
  function scrollRectIntoView(view, rect, startDOM) {
210
+ // Skip empty rects with all sides at 0, for example, when the element has no CSS box (display: none)
211
+ if (!nonZero(rect) && rect.left == 0)
212
+ return;
210
213
  let scrollThreshold = view.someProp("scrollThreshold") || 0, scrollMargin = view.someProp("scrollMargin") || 5;
211
214
  let doc = view.dom.ownerDocument;
212
215
  for (let parent = startDOM || view.dom;;) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prosemirror-view",
3
- "version": "1.42.0",
3
+ "version": "1.42.1",
4
4
  "description": "ProseMirror's view component",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -0,0 +1,275 @@
1
+ import {Slice, Fragment, DOMParser, DOMSerializer, ResolvedPos, NodeType, Node} from "prosemirror-model"
2
+ import * as browser from "./browser"
3
+ import {EditorView} from "./index"
4
+
5
+ export function serializeForClipboard(view: EditorView, slice: Slice) {
6
+ view.someProp("transformCopied", f => { slice = f(slice!, view) })
7
+
8
+ let context = [], {content, openStart, openEnd} = slice
9
+ while (openStart > 1 && openEnd > 1 && content.childCount == 1 && content.firstChild!.childCount == 1) {
10
+ openStart--
11
+ openEnd--
12
+ let node = content.firstChild!
13
+ context.push(node.type.name, node.attrs != node.type.defaultAttrs ? node.attrs : null)
14
+ content = node.content
15
+ }
16
+
17
+ let serializer = view.someProp("clipboardSerializer") || DOMSerializer.fromSchema(view.state.schema)
18
+ let doc = detachedDoc(), wrap = doc.createElement("div")
19
+ wrap.appendChild(serializer.serializeFragment(content, {document: doc}))
20
+
21
+ let firstChild = wrap.firstChild, needsWrap, wrappers = 0
22
+ while (firstChild && firstChild.nodeType == 1 && (needsWrap = wrapMap[firstChild.nodeName.toLowerCase()])) {
23
+ for (let i = needsWrap.length - 1; i >= 0; i--) {
24
+ let wrapper = doc.createElement(needsWrap[i])
25
+ while (wrap.firstChild) wrapper.appendChild(wrap.firstChild)
26
+ wrap.appendChild(wrapper)
27
+ wrappers++
28
+ }
29
+ firstChild = wrap.firstChild
30
+ }
31
+
32
+ if (firstChild && firstChild.nodeType == 1)
33
+ (firstChild as HTMLElement).setAttribute(
34
+ "data-pm-slice", `${openStart} ${openEnd}${wrappers ? ` -${wrappers}` : ""} ${JSON.stringify(context)}`)
35
+
36
+ let text = view.someProp("clipboardTextSerializer", f => f(slice, view)) ||
37
+ slice.content.textBetween(0, slice.content.size, "\n\n")
38
+
39
+ return {dom: wrap, text, slice}
40
+ }
41
+
42
+ // Read a slice of content from the clipboard (or drop data).
43
+ export function parseFromClipboard(view: EditorView, text: string, html: string | null, plainText: boolean, $context: ResolvedPos) {
44
+ let inCode = $context.parent.type.spec.code
45
+ let dom: HTMLElement | undefined, slice: Slice | undefined
46
+ if (!html && !text) return null
47
+ let asText = !!text && (plainText || inCode || !html)
48
+ if (asText) {
49
+ view.someProp("transformPastedText", f => { text = f(text, inCode || plainText, view) })
50
+ if (inCode) {
51
+ slice = new Slice(Fragment.from(view.state.schema.text(text.replace(/\r\n?/g, "\n"))), 0, 0)
52
+ view.someProp("transformPasted", f => { slice = f(slice!, view, true) })
53
+ return slice
54
+ }
55
+ let parsed = view.someProp("clipboardTextParser", f => f(text, $context, plainText, view))
56
+ if (parsed) {
57
+ slice = parsed
58
+ } else {
59
+ let marks = $context.marks()
60
+ let {schema} = view.state, serializer = DOMSerializer.fromSchema(schema)
61
+ dom = document.createElement("div")
62
+ text.split(/(?:\r\n?|\n)+/).forEach(block => {
63
+ let p = dom!.appendChild(document.createElement("p"))
64
+ if (block) p.appendChild(serializer.serializeNode(schema.text(block, marks)))
65
+ })
66
+ }
67
+ } else {
68
+ view.someProp("transformPastedHTML", f => { html = f(html!, view) })
69
+ dom = readHTML(html!)
70
+ if (browser.webkit) restoreReplacedSpaces(dom)
71
+ }
72
+
73
+ let contextNode = dom && dom.querySelector("[data-pm-slice]")
74
+ let sliceData = contextNode && /^(\d+) (\d+)(?: -(\d+))? (.*)/.exec(contextNode.getAttribute("data-pm-slice") || "")
75
+ if (sliceData && sliceData[3]) for (let i = +sliceData[3]; i > 0; i--) {
76
+ let child = dom!.firstChild
77
+ while (child && child.nodeType != 1) child = child.nextSibling
78
+ if (!child) break
79
+ dom = child as HTMLElement
80
+ }
81
+
82
+ if (!slice) {
83
+ let parser = view.someProp("clipboardParser") || view.someProp("domParser") || DOMParser.fromSchema(view.state.schema)
84
+ slice = parser.parseSlice(dom!, {
85
+ preserveWhitespace: !!(asText || sliceData),
86
+ context: $context,
87
+ ruleFromNode(dom) {
88
+ if (dom.nodeName == "BR" && !dom.nextSibling &&
89
+ dom.parentNode && !inlineParents.test(dom.parentNode.nodeName)) return {ignore: true}
90
+ return null
91
+ }
92
+ })
93
+ }
94
+ if (sliceData) {
95
+ slice = addContext(closeSlice(slice, +sliceData[1], +sliceData[2]), sliceData[4])
96
+ } else { // HTML wasn't created by ProseMirror. Make sure top-level siblings are coherent
97
+ slice = Slice.maxOpen(normalizeSiblings(slice.content, $context), true)
98
+ if (slice.openStart || slice.openEnd) {
99
+ let openStart = 0, openEnd = 0
100
+ for (let node = slice.content.firstChild; openStart < slice.openStart && !node!.type.spec.isolating;
101
+ openStart++, node = node!.firstChild) {}
102
+ for (let node = slice.content.lastChild; openEnd < slice.openEnd && !node!.type.spec.isolating;
103
+ openEnd++, node = node!.lastChild) {}
104
+ slice = closeSlice(slice, openStart, openEnd)
105
+ }
106
+ }
107
+
108
+ view.someProp("transformPasted", f => { slice = f(slice!, view, asText) })
109
+ return slice
110
+ }
111
+
112
+ const inlineParents = /^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var)$/i
113
+
114
+ // Takes a slice parsed with parseSlice, which means there hasn't been
115
+ // any content-expression checking done on the top nodes, tries to
116
+ // find a parent node in the current context that might fit the nodes,
117
+ // and if successful, rebuilds the slice so that it fits into that parent.
118
+ //
119
+ // This addresses the problem that Transform.replace expects a
120
+ // coherent slice, and will fail to place a set of siblings that don't
121
+ // fit anywhere in the schema.
122
+ function normalizeSiblings(fragment: Fragment, $context: ResolvedPos) {
123
+ if (fragment.childCount < 2) return fragment
124
+ for (let d = $context.depth; d >= 0; d--) {
125
+ let parent = $context.node(d)
126
+ let match = parent.contentMatchAt($context.index(d))
127
+ let lastWrap: readonly NodeType[] | undefined, result: Node[] | null = []
128
+ fragment.forEach(node => {
129
+ if (!result) return
130
+ let wrap = match.findWrapping(node.type), inLast
131
+ if (!wrap) return result = null
132
+ if (inLast = result.length && lastWrap!.length && addToSibling(wrap, lastWrap!, node, result[result.length - 1], 0)) {
133
+ result[result.length - 1] = inLast
134
+ } else {
135
+ if (result.length) result[result.length - 1] = closeRight(result[result.length - 1], lastWrap!.length)
136
+ let wrapped = withWrappers(node, wrap)
137
+ result.push(wrapped)
138
+ match = match.matchType(wrapped.type)!
139
+ lastWrap = wrap
140
+ }
141
+ })
142
+ if (result) return Fragment.from(result)
143
+ }
144
+ return fragment
145
+ }
146
+
147
+ function withWrappers(node: Node, wrap: readonly NodeType[], from = 0) {
148
+ for (let i = wrap.length - 1; i >= from; i--)
149
+ node = wrap[i].create(null, Fragment.from(node))
150
+ return node
151
+ }
152
+
153
+ // Used to group adjacent nodes wrapped in similar parents by
154
+ // normalizeSiblings into the same parent node
155
+ function addToSibling(wrap: readonly NodeType[], lastWrap: readonly NodeType[],
156
+ node: Node, sibling: Node, depth: number): Node | undefined {
157
+ if (depth < wrap.length && depth < lastWrap.length && wrap[depth] == lastWrap[depth]) {
158
+ let inner = addToSibling(wrap, lastWrap, node, sibling.lastChild!, depth + 1)
159
+ if (inner) return sibling.copy(sibling.content.replaceChild(sibling.childCount - 1, inner))
160
+ let match = sibling.contentMatchAt(sibling.childCount)
161
+ if (match.matchType(depth == wrap.length - 1 ? node.type : wrap[depth + 1]))
162
+ return sibling.copy(sibling.content.append(Fragment.from(withWrappers(node, wrap, depth + 1))))
163
+ }
164
+ }
165
+
166
+ function closeRight(node: Node, depth: number) {
167
+ if (depth == 0) return node
168
+ let fragment = node.content.replaceChild(node.childCount - 1, closeRight(node.lastChild!, depth - 1))
169
+ let fill = node.contentMatchAt(node.childCount).fillBefore(Fragment.empty, true)!
170
+ return node.copy(fragment.append(fill))
171
+ }
172
+
173
+ function closeRange(fragment: Fragment, side: number, from: number, to: number, depth: number, openEnd: number) {
174
+ let node = side < 0 ? fragment.firstChild! : fragment.lastChild!, inner = node.content
175
+ if (fragment.childCount > 1) openEnd = 0
176
+ if (depth < to - 1) inner = closeRange(inner, side, from, to, depth + 1, openEnd)
177
+ if (depth >= from)
178
+ inner = side < 0 ? node.contentMatchAt(0)!.fillBefore(inner, openEnd <= depth)!.append(inner)
179
+ : inner.append(node.contentMatchAt(node.childCount)!.fillBefore(Fragment.empty, true)!)
180
+ return fragment.replaceChild(side < 0 ? 0 : fragment.childCount - 1, node.copy(inner))
181
+ }
182
+
183
+ function closeSlice(slice: Slice, openStart: number, openEnd: number) {
184
+ if (openStart < slice.openStart)
185
+ slice = new Slice(closeRange(slice.content, -1, openStart, slice.openStart, 0, slice.openEnd), openStart, slice.openEnd)
186
+ if (openEnd < slice.openEnd)
187
+ slice = new Slice(closeRange(slice.content, 1, openEnd, slice.openEnd, 0, 0), slice.openStart, openEnd)
188
+ return slice
189
+ }
190
+
191
+ // Trick from jQuery -- some elements must be wrapped in other
192
+ // elements for innerHTML to work. I.e. if you do `div.innerHTML =
193
+ // "<td>..</td>"` the table cells are ignored.
194
+ const wrapMap: {[node: string]: string[]} = {
195
+ thead: ["table"],
196
+ tbody: ["table"],
197
+ tfoot: ["table"],
198
+ caption: ["table"],
199
+ colgroup: ["table"],
200
+ col: ["table", "colgroup"],
201
+ tr: ["table", "tbody"],
202
+ td: ["table", "tbody", "tr"],
203
+ th: ["table", "tbody", "tr"]
204
+ }
205
+
206
+ function detachedDoc() {
207
+ return document.implementation.createHTMLDocument("title")
208
+ }
209
+
210
+ let _policy: any = null
211
+
212
+ function maybeWrapTrusted(html: string): string {
213
+ let trustedTypes = (window as any).trustedTypes
214
+ if (!trustedTypes) return html
215
+ // With the require-trusted-types-for CSP, Chrome will block
216
+ // innerHTML, even on a detached document. This wraps the string in
217
+ // a way that makes the browser allow us to use its parser again.
218
+ if (!_policy)
219
+ _policy = trustedTypes.defaultPolicy || trustedTypes.createPolicy("ProseMirrorClipboard", {createHTML: (s: string) => s})
220
+ return _policy.createHTML(html)
221
+ }
222
+
223
+ function readHTML(html: string) {
224
+ html = "\n\n" + html + "\n\n"
225
+ let metas = /^(\s*<meta [^>]*>)*/.exec(html)
226
+ if (metas) html = html.slice(metas[0].length)
227
+ let doc = detachedDoc(), elt = doc.createElement("div")
228
+ let firstTag = /<([a-z][^>\s]+)/i.exec(html), wrap
229
+ if (wrap = firstTag && wrapMap[firstTag[1].toLowerCase()])
230
+ html = wrap.map(n => "<" + n + ">").join("") + html + wrap.map(n => "</" + n + ">").reverse().join("")
231
+ elt.innerHTML = maybeWrapTrusted(html)
232
+ if (wrap) for (let i = 0; i < wrap.length; i++) elt = elt.querySelector(wrap[i]) || elt
233
+ // Inline styles defined in the pasted content, so that parse rules pick them up
234
+ for (let i = 0; i < doc.styleSheets.length; i++) {
235
+ let style = doc.styleSheets[i]
236
+ for (let j = 0; j < style.rules.length; j++) {
237
+ let rule = style.rules[j]
238
+ if (rule instanceof CSSStyleRule) {
239
+ let matches = elt.querySelectorAll(rule.selectorText)
240
+ for (let k = 0; k < matches.length; k++)
241
+ (matches[k] as HTMLElement).style.cssText += rule.style.cssText
242
+ }
243
+ }
244
+ }
245
+ return elt
246
+ }
247
+
248
+ // Webkit browsers do some hard-to-predict replacement of regular
249
+ // spaces with non-breaking spaces when putting content on the
250
+ // clipboard. This tries to convert such non-breaking spaces (which
251
+ // will be wrapped in a plain span on Chrome, a span with class
252
+ // Apple-converted-space on Safari) back to regular spaces.
253
+ function restoreReplacedSpaces(dom: HTMLElement) {
254
+ let nodes = dom.querySelectorAll(browser.chrome ? "span:not([class]):not([style])" : "span.Apple-converted-space")
255
+ for (let i = 0; i < nodes.length; i++) {
256
+ let node = nodes[i]
257
+ if (node.childNodes.length == 1 && node.textContent == "\u00a0" && node.parentNode)
258
+ node.parentNode.replaceChild(dom.ownerDocument.createTextNode(" "), node)
259
+ }
260
+ }
261
+
262
+ function addContext(slice: Slice, context: string) {
263
+ if (!slice.size) return slice
264
+ let schema = slice.content.firstChild!.type.schema, array
265
+ try { array = JSON.parse(context) }
266
+ catch(e) { return slice }
267
+ let {content, openStart, openEnd} = slice
268
+ for (let i = array.length - 2; i >= 0; i -= 2) {
269
+ let type = schema.nodes[array[i]]
270
+ if (!type || type.hasRequiredAttrs()) break
271
+ content = Fragment.from(type.create(array[i + 1], content))
272
+ openStart++; openEnd++
273
+ }
274
+ return new Slice(content, openStart, openEnd)
275
+ }
package/src/domcoords.ts CHANGED
@@ -30,6 +30,9 @@ function clientRect(node: HTMLElement): Rect {
30
30
  }
31
31
 
32
32
  export function scrollRectIntoView(view: EditorView, rect: Rect, startDOM: Node) {
33
+ // Skip empty rects with all sides at 0, for example, when the element has no CSS box (display: none)
34
+ if (!nonZero(rect) && rect.left == 0) return
35
+
33
36
  let scrollThreshold = view.someProp("scrollThreshold") || 0, scrollMargin = view.someProp("scrollMargin") || 5
34
37
  let doc = view.dom.ownerDocument
35
38
  for (let parent: Node | null = startDOM || view.dom;;) {
@@ -328,7 +331,7 @@ export function posAtCoords(view: EditorView, coords: {top: number, left: number
328
331
  return {pos, inside: desc ? desc.posAtStart - desc.border : -1}
329
332
  }
330
333
 
331
- function nonZero(rect: DOMRect) {
334
+ function nonZero(rect: Rect) {
332
335
  return rect.top < rect.bottom || rect.left < rect.right
333
336
  }
334
337