@handlewithcare/react-prosemirror 3.1.0-tiptap.51 → 3.1.0-tiptap.53

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.
Files changed (58) hide show
  1. package/dist/cjs/ReactEditorView.js +6 -2
  2. package/dist/cjs/components/ChildNodeViews.js +3 -2
  3. package/dist/cjs/components/CursorWrapper.js +3 -4
  4. package/dist/cjs/components/ProseMirror.js +5 -3
  5. package/dist/cjs/components/TextNodeView.js +176 -34
  6. package/dist/cjs/components/TrailingHackView.js +42 -1
  7. package/dist/cjs/components/WidgetView.js +4 -1
  8. package/dist/cjs/contexts/ChildDescriptionsContext.js +3 -1
  9. package/dist/cjs/decorations/viewDecorations.js +1 -6
  10. package/dist/cjs/hooks/useComponentEventListeners.js +6 -14
  11. package/dist/cjs/hooks/useEditor.js +2 -10
  12. package/dist/cjs/hooks/useMarkViewDescription.js +61 -3
  13. package/dist/cjs/hooks/useNodeViewDescription.js +45 -23
  14. package/dist/cjs/plugins/beforeInputPlugin.js +116 -12
  15. package/dist/cjs/plugins/componentEventListeners.js +2 -9
  16. package/dist/cjs/plugins/reactKeys.js +21 -14
  17. package/dist/cjs/tiptap/utils/ssrJSDOMPatch.js +59 -0
  18. package/dist/cjs/viewdesc.js +43 -2
  19. package/dist/esm/ReactEditorView.js +6 -2
  20. package/dist/esm/components/ChildNodeViews.js +4 -3
  21. package/dist/esm/components/CursorWrapper.js +4 -5
  22. package/dist/esm/components/ProseMirror.js +5 -3
  23. package/dist/esm/components/TextNodeView.js +125 -32
  24. package/dist/esm/components/TrailingHackView.js +42 -1
  25. package/dist/esm/components/WidgetView.js +4 -1
  26. package/dist/esm/contexts/ChildDescriptionsContext.js +3 -1
  27. package/dist/esm/decorations/viewDecorations.js +1 -6
  28. package/dist/esm/hooks/useComponentEventListeners.js +6 -14
  29. package/dist/esm/hooks/useEditor.js +2 -10
  30. package/dist/esm/hooks/useMarkViewDescription.js +62 -4
  31. package/dist/esm/hooks/useNodeViewDescription.js +46 -24
  32. package/dist/esm/plugins/beforeInputPlugin.js +116 -12
  33. package/dist/esm/plugins/componentEventListeners.js +2 -9
  34. package/dist/esm/plugins/reactKeys.js +21 -14
  35. package/dist/esm/tiptap/hooks/useTiptapEditor.js +7 -1
  36. package/dist/esm/tiptap/utils/ssrJSDOMPatch.js +56 -0
  37. package/dist/esm/viewdesc.js +42 -2
  38. package/dist/tsconfig.tsbuildinfo +1 -1
  39. package/dist/types/ReactEditorView.d.ts +3 -2
  40. package/dist/types/components/CursorWrapper.d.ts +2 -4
  41. package/dist/types/components/TextNodeView.d.ts +11 -6
  42. package/dist/types/components/WidgetViewComponentProps.d.ts +4 -3
  43. package/dist/types/components/__tests__/ProseMirror.composition.test.d.ts +17 -1
  44. package/dist/types/constants.d.ts +1 -1
  45. package/dist/types/contexts/ChildDescriptionsContext.d.ts +5 -3
  46. package/dist/types/decorations/viewDecorations.d.ts +2 -2
  47. package/dist/types/hooks/useComponentEventListeners.d.ts +1 -1
  48. package/dist/types/hooks/useEditor.d.ts +1 -2
  49. package/dist/types/hooks/useMarkViewDescription.d.ts +2 -1
  50. package/dist/types/hooks/useNodeViewDescription.d.ts +2 -1
  51. package/dist/types/plugins/beforeInputPlugin.d.ts +1 -2
  52. package/dist/types/plugins/componentEventListeners.d.ts +2 -3
  53. package/dist/types/plugins/reactKeys.d.ts +9 -8
  54. package/dist/types/props.d.ts +26 -26
  55. package/dist/types/tiptap/hooks/useTiptapEditor.d.ts +7 -0
  56. package/dist/types/tiptap/utils/ssrJSDOMPatch.d.ts +1 -0
  57. package/dist/types/viewdesc.d.ts +3 -2
  58. package/package.json +2 -1
@@ -1,10 +1,8 @@
1
1
  import { useCallback, useContext, useMemo, useRef } from "react";
2
2
  import { ReactEditorView } from "../ReactEditorView.js";
3
- import { CursorWrapper } from "../components/CursorWrapper.js";
4
3
  import { ChildDescriptionsContext } from "../contexts/ChildDescriptionsContext.js";
5
4
  import { EditorContext } from "../contexts/EditorContext.js";
6
- import { ReactWidgetType } from "../decorations/ReactWidgetType.js";
7
- import { CompositionViewDesc, MarkViewDesc, ReactNodeViewDesc, WidgetViewDesc, sortViewDescs } from "../viewdesc.js";
5
+ import { ReactNodeViewDesc, TextViewDesc, sortViewDescs } from "../viewdesc.js";
8
6
  import { useClientLayoutEffect } from "./useClientLayoutEffect.js";
9
7
  import { useEffectEvent } from "./useEffectEvent.js";
10
8
  export function useNodeViewDescription(getDOM, getContentDOM, constructor, props) {
@@ -134,30 +132,51 @@ export function useNodeViewDescription(getDOM, getContentDOM, constructor, props
134
132
  for (const child of children){
135
133
  child.parent = viewDesc;
136
134
  }
135
+ });
136
+ const findCompositionDOM = useCallback((compositionViewDesc)=>{
137
137
  if (!props.node.isTextblock) return;
138
+ const children = childrenRef.current;
138
139
  // Because TextNodeViews can't locate the DOM nodes
139
140
  // for compositions, we need to override them here
140
141
  if (!viewDescRef.current?.contentDOM) return;
141
- const compositionChildIndex = children.findIndex((child)=>child instanceof CompositionViewDesc);
142
- if (compositionChildIndex === -1) return;
143
- const compositionViewDesc = children[compositionChildIndex];
144
- if (!(compositionViewDesc instanceof CompositionViewDesc)) return;
145
142
  let compositionTopDOM = null;
146
- let search = children[compositionChildIndex - 1];
147
- while(search instanceof MarkViewDesc){
148
- search = search.children[0];
149
- }
150
- if (search instanceof WidgetViewDesc && search.widget.type instanceof ReactWidgetType && search.widget.type.Component === CursorWrapper) {
151
- compositionTopDOM = search.dom.nextSibling;
152
- } else {
153
- for (const childNode of viewDescRef.current.contentDOM.childNodes){
154
- if (children.every((child)=>child.dom !== childNode)) {
155
- compositionTopDOM = childNode;
156
- break;
157
- }
143
+ for (const childNode of viewDescRef.current.contentDOM.childNodes){
144
+ if (children.every((child)=>child.dom !== childNode)) {
145
+ compositionTopDOM = childNode;
146
+ break;
158
147
  }
159
148
  }
160
- if (!compositionTopDOM) return;
149
+ if (!compositionTopDOM) {
150
+ // Otherwise the IME extended an existing tracked text node. Take it over.
151
+ const reactView = view;
152
+ const imeTextNode = reactView.input.compositionNode;
153
+ if (!imeTextNode || !viewDescRef.current.contentDOM.contains(imeTextNode.parentNode)) {
154
+ return;
155
+ }
156
+ const claimedDesc = imeTextNode.pmViewDesc;
157
+ if (!(claimedDesc instanceof TextViewDesc)) return;
158
+ if (claimedDesc.node.text === imeTextNode.nodeValue) return; // not extended
159
+ // Walk up to the direct child of contentDOM that contains the IME text node
160
+ // (could be the text node itself, could be wrapped in a mark span).
161
+ let topDOM = imeTextNode;
162
+ while(topDOM.parentNode !== viewDescRef.current.contentDOM){
163
+ const next = topDOM.parentNode;
164
+ if (!next) return;
165
+ topDOM = next;
166
+ }
167
+ // Detach the displaced TextViewDesc from the sibling list so sibling-size
168
+ // accounting (used by posBeforeChild) doesn't double-count this text node.
169
+ const displacedIdx = children.indexOf(claimedDesc);
170
+ if (displacedIdx >= 0) children.splice(displacedIdx, 1);
171
+ reactView.displacedNodes.push(claimedDesc);
172
+ compositionViewDesc.dom = topDOM;
173
+ compositionViewDesc.textDOM = imeTextNode;
174
+ compositionViewDesc.text = imeTextNode.data;
175
+ imeTextNode.pmViewDesc = compositionViewDesc;
176
+ compositionViewDesc._displacedDesc = claimedDesc;
177
+ reactView.input.compositionNodes.push(compositionViewDesc);
178
+ return;
179
+ }
161
180
  let textDOM = compositionTopDOM;
162
181
  while(textDOM.firstChild){
163
182
  textDOM = textDOM.firstChild;
@@ -171,13 +190,16 @@ export function useNodeViewDescription(getDOM, getContentDOM, constructor, props
171
190
  compositionViewDesc.text = textDOM.data;
172
191
  compositionViewDesc.textDOM.pmViewDesc = compositionViewDesc;
173
192
  view.input.compositionNodes.push(compositionViewDesc);
174
- });
193
+ }, [
194
+ props.node.isTextblock,
195
+ view
196
+ ]);
175
197
  const childContextValue = useMemo(()=>({
176
198
  parentRef: viewDescRef,
177
- siblingsRef: childrenRef
199
+ siblingsRef: childrenRef,
200
+ findCompositionDOM
178
201
  }), [
179
- childrenRef,
180
- viewDescRef
202
+ findCompositionDOM
181
203
  ]);
182
204
  return {
183
205
  childContextValue,
@@ -3,6 +3,8 @@ import { Plugin, TextSelection } from "prosemirror-state";
3
3
  import { ReactEditorView } from "../ReactEditorView.js";
4
4
  import { CursorWrapper } from "../components/CursorWrapper.js";
5
5
  import { widget } from "../decorations/ReactWidgetType.js";
6
+ import { TextViewDesc, sortViewDescs } from "../viewdesc.js";
7
+ import { reactKeysPluginKey } from "./reactKeys.js";
6
8
  function insertText(view, eventData) {
7
9
  let options = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : {};
8
10
  if (eventData === null) return false;
@@ -45,26 +47,64 @@ function handleGapCursorComposition(view) {
45
47
  tr.setSelection(TextSelection.near(tr.doc.resolve($from.pos + 1)));
46
48
  view.dispatch(tr);
47
49
  }
48
- export function beforeInputPlugin(setCursorWrapper) {
50
+ export function beforeInputPlugin() {
49
51
  let compositionMarks = null;
50
52
  return new Plugin({
51
53
  props: {
52
54
  handleDOMEvents: {
53
55
  compositionstart (view) {
54
56
  if (!(view instanceof ReactEditorView)) return false;
55
- view.input.composing = true;
57
+ view.compositionStarting = true;
58
+ const { state } = view;
59
+ const { selection } = state;
60
+ const isEmptyTr = state.tr.delete(selection.from, selection.to);
61
+ const $from = isEmptyTr.doc.resolve(isEmptyTr.mapping.map(selection.from));
62
+ const isEmptyTextblock = $from.parent.isTextblock && $from.parent.childCount === 0;
56
63
  compositionMarks = view.state.storedMarks;
57
- const tr = view.state.tr.deleteSelection().setStoredMarks(null);
64
+ // Render a CursorWrapper with empty marks if starting a composition in an
65
+ // empty textblock with no marks. This prevents the browser from adding a
66
+ // <br> to the text block when it becomes empty (either via canceling the
67
+ // composition with the escape key or deleting all composition text when
68
+ // the composition node is the only text node in the text block)
69
+ if (compositionMarks === null && isEmptyTextblock) {
70
+ compositionMarks = [];
71
+ }
72
+ const tr = view.state.tr.setStoredMarks(null);
58
73
  view.dispatch(tr);
59
74
  handleGapCursorComposition(view);
60
- const { state } = view;
61
- if (compositionMarks?.length) {
62
- setCursorWrapper(widget(state.selection.from, CursorWrapper, {
63
- key: "cursor-wrapper",
64
- marks: compositionMarks,
65
- side: 1
75
+ if (compositionMarks) {
76
+ view.dispatch(view.state.tr.setMeta(reactKeysPluginKey, {
77
+ cursorWrapper: widget(state.selection.from, CursorWrapper, {
78
+ key: "cursor-wrapper",
79
+ marks: compositionMarks,
80
+ side: 0,
81
+ raw: true
82
+ })
66
83
  }));
84
+ // Pin the DOM cursor to PM's canonical position before the IME
85
+ // captures wherever the browser happened to leave it. Without this,
86
+ // a cursor at a mark boundary lands in either the left or right text
87
+ // node depending on the user's last navigation direction, and the
88
+ // IME composes into whichever one it found.
89
+ } else if (view.state.selection.empty) {
90
+ // @ts-expect-error internal method
91
+ view.domObserver.disconnectSelection();
92
+ try {
93
+ view.docView.setSelection(view.state.selection.anchor, view.state.selection.head, view, true // force — skip the isEquivalentPosition early-return
94
+ );
95
+ } finally{
96
+ // @ts-expect-error internal method
97
+ view.domObserver.setCurSelection();
98
+ // @ts-expect-error internal method
99
+ view.domObserver.connectSelection();
100
+ }
67
101
  }
102
+ view.compositionStarting = false;
103
+ // We set composing to true after creating the cursor wrapper
104
+ // so that no existing text nodes try to protect themselves
105
+ // while we're creating the cursor wrapper, which may need
106
+ // to split a text node.
107
+ view.input.composing = true;
68
108
  return true;
69
109
  },
70
110
  compositionupdate () {
@@ -75,17 +115,40 @@ export function beforeInputPlugin(setCursorWrapper) {
75
115
  if (!view.composing) return false;
76
116
  view.input.composing = false;
77
117
  compositionMarks = null;
78
- setCursorWrapper(null);
79
- if (view.input.compositionNode && !view.input.compositionNode.pmViewDesc) {
118
+ for (const displaced of view.displacedNodes){
119
+ // Put the displaced TextViewDesc back into its parent's child list.
120
+ const parent = displaced.parent;
121
+ if (parent && !parent.children.includes(displaced)) {
122
+ parent.children.push(displaced);
123
+ parent.children.sort(sortViewDescs);
124
+ }
125
+ // Restore pmViewDesc claim on the text node.
126
+ displaced.dom.pmViewDesc = displaced;
127
+ // Truncate the IME text node back to what the displaced PM node says it
128
+ // is. The composed content lives in PM state; the next React render will
129
+ // mount a sibling TextNodeView that inserts its own DOM (e.g.
130
+ // `<span class="word">k</span>`) right after this node.
131
+ const claimedText = displaced.node.text ?? "";
132
+ if (displaced.nodeDOM.nodeValue !== claimedText) {
133
+ displaced.nodeDOM.nodeValue = claimedText;
134
+ }
135
+ }
136
+ view.dispatch(view.state.tr.setMeta(reactKeysPluginKey, {
137
+ cursorWrapper: null
138
+ }));
139
+ if (view.input.compositionNode && isCompositionNodeOrphaned(view.input.compositionNode)) {
80
140
  view.input.compositionNode.remove();
81
141
  }
82
142
  view.input.compositionEndedAt = event.timeStamp;
83
143
  view.input.compositionNode = null;
144
+ view.input.compositionNodes = [];
84
145
  view.input.compositionID++;
85
146
  return true;
86
147
  },
87
148
  beforeinput (view, event) {
88
- event.preventDefault();
149
+ if (event.inputType !== "insertFromComposition") {
150
+ event.preventDefault();
151
+ }
89
152
  switch(event.inputType){
90
153
  case "insertParagraph":
91
154
  case "insertLineBreak":
@@ -131,7 +194,10 @@ export function beforeInputPlugin(setCursorWrapper) {
131
194
  break;
132
195
  }
133
196
  case "insertCompositionText":
197
+ case "deleteCompositionText":
198
+ case "insertFromComposition":
134
199
  {
200
+ if (!(view instanceof ReactEditorView)) break;
135
201
  const { tr } = view.state;
136
202
  // There's always a range on insertCompositionText beforeinput events
137
203
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -147,7 +213,37 @@ export function beforeInputPlugin(setCursorWrapper) {
147
213
  } else {
148
214
  tr.delete(start, end);
149
215
  }
216
+ // When updating a composition within an existing text node,
217
+ // we need to avoid remounting it. If the composition is at
218
+ // the very beginning of the text node, the start position of
219
+ // that node will either be mapped forward (if inserting new
220
+ // content) or deleted (if replacing existing content).
221
+ //
222
+ // This will cause the reactKeys plugin to mint a new key for
223
+ // that node, which triggers a remount. So we check to see whether
224
+ // we're working on a composition at the very beginning of a text
225
+ // node, and if so, tell the react keys plugin not to change the
226
+ // key for that node.
227
+ //
228
+ // We need to check that the marks are the same — if they're not,
229
+ // then we're inserting text _before_ this text node, not at the
230
+ // start of it, so we actually _do_ want to map the exsting node
231
+ // forward.
232
+ const $start = view.state.doc.resolve(start);
233
+ const $end = view.state.doc.resolve(end);
234
+ const marks = compositionMarks ?? $start.marksAcross($end) ?? [];
235
+ if ($start.textOffset === 0 && $end.nodeAfter?.marks.every((m)=>m.isInSet(marks))) {
236
+ tr.setMeta(reactKeysPluginKey, {
237
+ overrides: {
238
+ [start]: start
239
+ }
240
+ });
241
+ }
150
242
  view.dom.addEventListener("input", ()=>{
243
+ const sel = view.domSelectionRange();
244
+ if (sel.focusNode && sel.focusNode.nodeType === 3) {
245
+ view.input.compositionNode = sel.focusNode;
246
+ }
151
247
  view.dispatch(tr);
152
248
  }, {
153
249
  once: true
@@ -187,3 +283,11 @@ export function beforeInputPlugin(setCursorWrapper) {
187
283
  }
188
284
  });
189
285
  }
286
+ function isCompositionNodeOrphaned(tn) {
287
+ if (tn.pmViewDesc) return false;
288
+ for(let parent = tn.parentNode; parent; parent = parent.parentNode){
289
+ const desc = parent.pmViewDesc;
290
+ if (desc instanceof TextViewDesc && desc.nodeDOM === tn) return false;
291
+ }
292
+ return true;
293
+ }
@@ -1,4 +1,3 @@
1
- import { Plugin, PluginKey } from "prosemirror-state";
2
1
  import { unstable_batchedUpdates as batch } from "react-dom";
3
2
  export function componentEventListeners(eventHandlerRegistry) {
4
3
  const domEventHandlers = {};
@@ -7,7 +6,7 @@ export function componentEventListeners(eventHandlerRegistry) {
7
6
  for (const handler of handlers){
8
7
  let handled = false;
9
8
  batch(()=>{
10
- handled = !!handler.call(this, view, event);
9
+ handled = !!handler(view, event);
11
10
  });
12
11
  if (handled || event.defaultPrevented) return true;
13
12
  }
@@ -15,11 +14,5 @@ export function componentEventListeners(eventHandlerRegistry) {
15
14
  }
16
15
  domEventHandlers[eventType] = handleEvent;
17
16
  }
18
- const plugin = new Plugin({
19
- key: new PluginKey("@handlewithcare/react-prosemirror/componentEventListeners"),
20
- props: {
21
- handleDOMEvents: domEventHandlers
22
- }
23
- });
24
- return plugin;
17
+ return domEventHandlers;
25
18
  }
@@ -1,4 +1,6 @@
1
1
  import { Plugin, PluginKey } from "prosemirror-state";
2
+ import { DecorationSet } from "prosemirror-view";
3
+ import { widget } from "../decorations/ReactWidgetType.js";
2
4
  export function createNodeKey() {
3
5
  const key = Math.floor(Math.random() * 0xffffffffffff).toString(16);
4
6
  return key;
@@ -11,14 +13,14 @@ export const reactKeysPluginKey = new PluginKey("@handlewithcare/react-prosemirr
11
13
  * key for a given node can be accessed by that node's
12
14
  * current position in the document, and vice versa.
13
15
  */ export function reactKeys() {
14
- let composing = false;
15
16
  return new Plugin({
16
17
  key: reactKeysPluginKey,
17
18
  state: {
18
19
  init (_, state) {
19
20
  const next = {
20
21
  posToKey: new Map(),
21
- keyToPos: new Map()
22
+ keyToPos: new Map(),
23
+ cursorWrapper: null
22
24
  };
23
25
  state.doc.descendants((_, pos)=>{
24
26
  const key = createNodeKey();
@@ -36,14 +38,20 @@ export const reactKeysPluginKey = new PluginKey("@handlewithcare/react-prosemirr
36
38
  * and assign its key to that new position, dropping it if the
37
39
  * node was deleted.
38
40
  */ apply (tr, value, _, newState) {
39
- if (!tr.docChanged || composing) {
40
- return value;
41
- }
42
- const overrides = tr.getMeta(reactKeysPluginKey)?.overrides;
41
+ const meta = tr.getMeta(reactKeysPluginKey);
42
+ const overrides = meta && "overrides" in meta ? meta.overrides : {};
43
+ const cursorWrapper = meta && "cursorWrapper" in meta ? meta.cursorWrapper : undefined;
43
44
  const next = {
44
45
  posToKey: new Map(),
45
- keyToPos: new Map()
46
+ keyToPos: new Map(),
47
+ cursorWrapper: cursorWrapper === undefined ? value.cursorWrapper ? widget(tr.mapping.map(value.cursorWrapper.from, -1), value.cursorWrapper.type.Component, value.cursorWrapper.spec) : null : cursorWrapper
46
48
  };
49
+ if (!tr.docChanged) {
50
+ return {
51
+ ...value,
52
+ cursorWrapper: next.cursorWrapper
53
+ };
54
+ }
47
55
  const posToKeyEntries = Array.from(value.posToKey.entries()).sort((param, param1)=>{
48
56
  let [a] = param, [b] = param1;
49
57
  return a - b;
@@ -69,13 +77,12 @@ export const reactKeysPluginKey = new PluginKey("@handlewithcare/react-prosemirr
69
77
  }
70
78
  },
71
79
  props: {
72
- handleDOMEvents: {
73
- compositionstart: ()=>{
74
- composing = true;
75
- },
76
- compositionend: ()=>{
77
- composing = false;
78
- }
80
+ decorations (state) {
81
+ const deco = reactKeysPluginKey.getState(state)?.cursorWrapper;
82
+ if (!deco) return DecorationSet.empty;
83
+ return DecorationSet.create(state.doc, [
84
+ deco
85
+ ]);
79
86
  }
80
87
  }
81
88
  });
@@ -2,7 +2,13 @@ import { StaticEditorView } from "../../StaticEditorView.js";
2
2
  import { ReactProseMirror } from "../extensions/ReactProseMirror.js";
3
3
  import { ReactProseMirrorCommands } from "../extensions/ReactProseMirrorCommands.js";
4
4
  import { useEditor } from "./useEditor.js";
5
- export function useTiptapEditor(options, deps) {
5
+ /**
6
+ * Create a React ProseMirror integrated Tiptap Editor instance.
7
+ * @param options The editor options
8
+ * @param deps The dependencies to watch for changes
9
+ * @returns The editor instance
10
+ * @example const editor = useEditor({ extensions: [...] })
11
+ */ export function useTiptapEditor(options, deps) {
6
12
  const extensions = [
7
13
  ReactProseMirror,
8
14
  ...options.extensions ?? []
@@ -0,0 +1,56 @@
1
+ /**
2
+ * This file is used to patch global DOM variables in a NodeJS environment.
3
+ * This is needed for ProseMirror to work in a NodeJS environment.
4
+ */ if (typeof window === "undefined") {
5
+ // Make sure to import JSDOM only in a NodeJS environment.
6
+ // The magic comments prevent bundlers from statically analyzing this require:
7
+ // - webpackIgnore: true → webpack / Next.js
8
+ // - @vite-ignore → Vite
9
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
10
+ const jsdom = require(/* webpackIgnore: true */ /* @vite-ignore */ "jsdom");
11
+ const html = `
12
+ <!DOCTYPE html>
13
+ <html>
14
+ <head>
15
+ <title>Testing</title>
16
+ </head>
17
+ <body></body>
18
+ </html>
19
+ `;
20
+ const { window: window1 } = new jsdom.JSDOM(html);
21
+ global.window = window1;
22
+ global.document = window1.document;
23
+ // Use Object.defineProperty for navigator since it's read-only in Node.js 22+
24
+ Object.defineProperty(global, "navigator", {
25
+ value: window1.navigator,
26
+ writable: true,
27
+ configurable: true
28
+ });
29
+ global.innerHeight = 0;
30
+ global.SVGElement = window1.SVGElement;
31
+ // @ts-expect-error stub getSelection for SSR
32
+ document.getSelection = ()=>({});
33
+ document.createRange = ()=>({
34
+ setStart () {},
35
+ setEnd () {},
36
+ // @ts-expect-error stub getBoundingClientRect for SSR
37
+ getClientRects () {
38
+ return {
39
+ left: 0,
40
+ top: 0,
41
+ right: 0,
42
+ bottom: 0
43
+ };
44
+ },
45
+ // @ts-expect-error stub getBoundingClientRect for SSR
46
+ getBoundingClientRect () {
47
+ return {
48
+ left: 0,
49
+ top: 0,
50
+ right: 0,
51
+ bottom: 0
52
+ };
53
+ }
54
+ });
55
+ }
56
+ export { };
@@ -16,7 +16,17 @@ import { domIndex, isEquivalentPosition } from "./dom.js";
16
16
  export function sortViewDescs(a, b) {
17
17
  if (a instanceof TrailingHackViewDesc) return 1;
18
18
  if (b instanceof TrailingHackViewDesc) return -1;
19
- return a.getPos() - b.getPos();
19
+ const posDiff = a.getPos() - b.getPos();
20
+ if (posDiff !== 0) return posDiff;
21
+ // When two descs share the same PM position (e.g. a zero-width widget
22
+ // and a text node that starts at the same position), fall back to DOM
23
+ // order so that the viewdesc children match the actual DOM layout.
24
+ // Without this, position computations like `posBeforeChild` can return
25
+ // the wrong PM position for the widget's container.
26
+ const cmp = a.dom.compareDocumentPosition(b.dom);
27
+ if (cmp & 4 /* DOCUMENT_POSITION_FOLLOWING */ ) return -1;
28
+ if (cmp & 2 /* DOCUMENT_POSITION_PRECEDING */ ) return 1;
29
+ return 0;
20
30
  }
21
31
  const NOT_DIRTY = 0, CHILD_DIRTY = 1, CONTENT_DIRTY = 2, NODE_DIRTY = 3;
22
32
  // Superclass for the various kinds of descriptions. Defines their
@@ -372,7 +382,7 @@ export class ViewDesc {
372
382
  const after = selRange.focusNode.childNodes[selRange.focusOffset];
373
383
  if (after && after.contentEditable == "false") force = true;
374
384
  }
375
- if (!(force || brKludge && browser.safari) && isEquivalentPosition(anchorDOM.node, anchorDOM.offset, selRange.anchorNode, selRange.anchorOffset) && isEquivalentPosition(headDOM.node, headDOM.offset, selRange.focusNode, selRange.focusOffset)) {
385
+ if (view.composing || !(force || brKludge && browser.safari) && isEquivalentPosition(anchorDOM.node, anchorDOM.offset, selRange.anchorNode, selRange.anchorOffset) && isEquivalentPosition(headDOM.node, headDOM.offset, selRange.focusNode, selRange.focusOffset)) {
376
386
  return;
377
387
  }
378
388
  // Selection.extend can be used to create an 'inverted' selection
@@ -770,3 +780,33 @@ export function sameOuterDeco(a, b) {
770
780
  for(let i = 0; i < a.length; i++)if (!a[i].type.eq(b[i].type)) return false;
771
781
  return true;
772
782
  }
783
+ // Find a piece of text in an inline fragment, overlapping from-to.
784
+ // Ported from prosemirror-view's findTextInFragment.
785
+ export function findTextInFragment(frag, text, from, to) {
786
+ for(let i = 0, pos = 0; i < frag.childCount && pos <= to;){
787
+ const child = frag.child(i++);
788
+ const childStart = pos;
789
+ pos += child.nodeSize;
790
+ if (!child.isText) continue;
791
+ let str = child.text;
792
+ while(i < frag.childCount){
793
+ const next = frag.child(i++);
794
+ pos += next.nodeSize;
795
+ if (!next.isText) break;
796
+ str += next.text;
797
+ }
798
+ if (pos >= from) {
799
+ if (pos >= to && str.slice(to - text.length - childStart, to - childStart) === text) {
800
+ return to - text.length;
801
+ }
802
+ const found = childStart < to ? str.lastIndexOf(text, to - childStart - 1) : -1;
803
+ if (found >= 0 && found + text.length + childStart >= from) {
804
+ return childStart + found;
805
+ }
806
+ if (from === to && str.length >= to + text.length - childStart && str.slice(to - childStart, to - childStart + text.length) === text) {
807
+ return to;
808
+ }
809
+ }
810
+ }
811
+ return -1;
812
+ }