@handlewithcare/react-prosemirror 3.1.0-tiptap.52 → 3.1.0

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 (75) hide show
  1. package/README.md +3 -0
  2. package/dist/cjs/ReactEditorView.js +18 -1
  3. package/dist/cjs/components/ChildNodeViews.js +4 -4
  4. package/dist/cjs/components/CursorWrapper.js +9 -11
  5. package/dist/cjs/components/ProseMirror.js +13 -3
  6. package/dist/cjs/components/TextNodeView.js +54 -50
  7. package/dist/cjs/components/WidgetView.js +3 -1
  8. package/dist/cjs/components/nodes/NodeView.js +40 -4
  9. package/dist/cjs/contexts/CompositionContext.js +14 -0
  10. package/dist/cjs/decorations/viewDecorations.js +1 -6
  11. package/dist/cjs/hooks/useEditor.js +2 -10
  12. package/dist/cjs/hooks/useMarkViewDescription.js +1 -4
  13. package/dist/cjs/hooks/useNodeViewDescription.js +1 -22
  14. package/dist/cjs/plugins/beforeInputPlugin.js +162 -50
  15. package/dist/cjs/plugins/reactKeys.js +34 -15
  16. package/dist/cjs/tiptap/tiptapNodeView.js +10 -9
  17. package/dist/cjs/viewdesc.js +55 -4
  18. package/dist/esm/ReactEditorView.js +18 -1
  19. package/dist/esm/components/ChildNodeViews.js +5 -5
  20. package/dist/esm/components/CursorWrapper.js +9 -11
  21. package/dist/esm/components/ProseMirror.js +13 -3
  22. package/dist/esm/components/TextNodeView.js +56 -52
  23. package/dist/esm/components/WidgetView.js +3 -1
  24. package/dist/esm/components/nodes/NodeView.js +38 -5
  25. package/dist/esm/contexts/CompositionContext.js +4 -0
  26. package/dist/esm/decorations/viewDecorations.js +1 -6
  27. package/dist/esm/hooks/useEditor.js +2 -10
  28. package/dist/esm/hooks/useIsEditorStatic.js +4 -1
  29. package/dist/esm/hooks/useMarkViewDescription.js +1 -4
  30. package/dist/esm/hooks/useNodeViewDescription.js +2 -23
  31. package/dist/esm/plugins/beforeInputPlugin.js +162 -50
  32. package/dist/esm/plugins/reactKeys.js +34 -15
  33. package/dist/esm/tiptap/ReactProseMirrorNodeView.js +1 -1
  34. package/dist/esm/tiptap/TiptapEditorContent.js +8 -1
  35. package/dist/esm/tiptap/hooks/useIsInReactProseMirror.js +5 -1
  36. package/dist/esm/tiptap/tiptapNodeView.js +13 -14
  37. package/dist/esm/viewdesc.js +54 -4
  38. package/dist/tsconfig.tsbuildinfo +1 -1
  39. package/dist/types/ReactEditorView.d.ts +10 -1
  40. package/dist/types/components/ChildNodeViews.d.ts +2 -2
  41. package/dist/types/components/CursorWrapper.d.ts +1 -1
  42. package/dist/types/components/TextNodeView.d.ts +7 -4
  43. package/dist/types/components/__tests__/ProseMirror.composition.test.d.ts +17 -1
  44. package/dist/types/components/marks/DefaultMarkView.d.ts +1 -1
  45. package/dist/types/components/marks/MarkView.d.ts +1 -1
  46. package/dist/types/components/marks/MarkViewConstructorView.d.ts +1 -1
  47. package/dist/types/components/marks/ReactMarkView.d.ts +1 -1
  48. package/dist/types/components/nodes/DefaultNodeView.d.ts +1 -1
  49. package/dist/types/components/nodes/NodeView.d.ts +3 -1
  50. package/dist/types/components/nodes/NodeViewConstructorView.d.ts +1 -1
  51. package/dist/types/components/nodes/ReactNodeView.d.ts +1 -1
  52. package/dist/types/contexts/ChildDescriptionsContext.d.ts +3 -2
  53. package/dist/types/contexts/CompositionContext.d.ts +4 -0
  54. package/dist/types/decorations/viewDecorations.d.ts +2 -2
  55. package/dist/types/hooks/useEditor.d.ts +3 -4
  56. package/dist/types/hooks/useIsEditorStatic.d.ts +4 -0
  57. package/dist/types/hooks/useReactKeys.d.ts +2 -5
  58. package/dist/types/plugins/beforeInputPlugin.d.ts +1 -2
  59. package/dist/types/plugins/reactKeys.d.ts +10 -9
  60. package/dist/types/props.d.ts +225 -225
  61. package/dist/types/tiptap/ReactProseMirrorNodeView.d.ts +1 -1
  62. package/dist/types/tiptap/TiptapEditorContent.d.ts +10 -1
  63. package/dist/types/tiptap/hooks/useIsInReactProseMirror.d.ts +5 -0
  64. package/dist/types/tiptap/tiptapNodeView.d.ts +5 -6
  65. package/dist/types/viewdesc.d.ts +5 -3
  66. package/package.json +22 -6
  67. package/dist/cjs/plugins/componentEventListeners.js +0 -28
  68. package/dist/cjs/plugins/componentEventListenersPlugin.js +0 -35
  69. package/dist/cjs/tiptap/utils/ssrJSDOMPatch.js +0 -59
  70. package/dist/esm/plugins/componentEventListeners.js +0 -18
  71. package/dist/esm/plugins/componentEventListenersPlugin.js +0 -25
  72. package/dist/esm/tiptap/utils/ssrJSDOMPatch.js +0 -56
  73. package/dist/types/plugins/componentEventListeners.d.ts +0 -3
  74. package/dist/types/plugins/componentEventListenersPlugin.d.ts +0 -4
  75. package/dist/types/tiptap/utils/ssrJSDOMPatch.d.ts +0 -1
@@ -1,7 +1,8 @@
1
1
  import { DecorationSet } from "prosemirror-view";
2
- import { Component } from "react";
2
+ import { Component, createRef } from "react";
3
+ import { ReactEditorView } from "../ReactEditorView.js";
3
4
  import { findDOMNode } from "../findDOMNode.js";
4
- import { CompositionViewDesc, TextViewDesc, sortViewDescs } from "../viewdesc.js";
5
+ import { TextViewDesc, sortViewDescs } from "../viewdesc.js";
5
6
  import { wrapInDeco } from "./ChildNodeViews.js";
6
7
  function shallowEqual(objA, objB) {
7
8
  if (objA === objB) {
@@ -26,75 +27,78 @@ function shallowEqual(objA, objB) {
26
27
  return true;
27
28
  }
28
29
  export class TextNodeView extends Component {
29
- viewDescRef = null;
30
- renderRef = null;
31
- updateEffect() {
30
+ viewDescRef = createMutRef();
31
+ create() {
32
32
  const { view, decorations, siblingsRef, parentRef, getPos, node } = this.props;
33
- // There simply is no other way to ref a text node
34
- // eslint-disable-next-line react/no-find-dom-node
35
33
  const dom = findDOMNode(this);
36
- // We only need to explicitly create a CompositionViewDesc
37
- // when a composition was started that produces a new text node.
38
- // Otherwise we just rely on re-rendering the renderRef
39
- if (!dom) {
40
- if (!view.composing) return;
41
- this.viewDescRef = new CompositionViewDesc(parentRef.current, getPos, // These are just placeholders/dummies. We can't
42
- // actually find the correct DOM nodes from here,
43
- // so we let our parent do it.
44
- // Passing a valid element here just so that the
45
- // ViewDesc constructor doesn't blow up.
46
- document.createElement("div"), document.createTextNode(node.text ?? ""), node.text ?? "");
47
- return;
48
- }
34
+ if (!dom && !view.composing) return null;
49
35
  let textNode = dom;
50
- while(textNode.firstChild){
36
+ while(textNode?.firstChild){
51
37
  textNode = textNode.firstChild;
52
38
  }
53
- if (!this.viewDescRef || this.viewDescRef instanceof CompositionViewDesc) {
54
- this.viewDescRef = new TextViewDesc(undefined, [], getPos, node, decorations, DecorationSet.empty, dom, textNode);
55
- } else {
56
- this.viewDescRef.parent = parentRef.current;
57
- this.viewDescRef.children = [];
58
- this.viewDescRef.node = node;
59
- this.viewDescRef.outerDeco = decorations;
60
- this.viewDescRef.innerDeco = DecorationSet.empty;
61
- this.viewDescRef.dom = dom;
62
- this.viewDescRef.dom.pmViewDesc = this.viewDescRef;
63
- this.viewDescRef.nodeDOM = textNode;
64
- }
65
- if (!siblingsRef.current.includes(this.viewDescRef)) {
66
- siblingsRef.current.push(this.viewDescRef);
39
+ if (!(textNode instanceof Text)) {
40
+ textNode = null;
67
41
  }
42
+ if (!dom || !textNode) return null;
43
+ const viewDesc = new TextViewDesc(parentRef.current, [], getPos, node, decorations, DecorationSet.empty, dom, textNode);
44
+ siblingsRef.current.push(viewDesc);
68
45
  siblingsRef.current.sort(sortViewDescs);
46
+ return viewDesc;
47
+ }
48
+ update() {
49
+ const { view, node, decorations } = this.props;
50
+ if (!(view instanceof ReactEditorView)) return false;
51
+ const viewDesc = this.viewDescRef.current;
52
+ if (!viewDesc) return false;
53
+ const dom = findDOMNode(this);
54
+ if (!dom || dom !== viewDesc.dom) return false;
55
+ if (!dom.contains(viewDesc.nodeDOM)) return false;
56
+ return viewDesc.matchesNode(node, decorations, DecorationSet.empty) || viewDesc.update(node, decorations, DecorationSet.empty, view);
57
+ }
58
+ destroy() {
59
+ const viewDesc = this.viewDescRef.current;
60
+ if (!viewDesc) return;
61
+ viewDesc.destroy();
62
+ const siblings = this.props.siblingsRef.current;
63
+ if (siblings.includes(viewDesc)) {
64
+ const index = siblings.indexOf(viewDesc);
65
+ siblings.splice(index, 1);
66
+ }
67
+ }
68
+ updateEffect() {
69
+ if (!this.update()) {
70
+ this.destroy();
71
+ this.viewDescRef.current = this.create();
72
+ }
69
73
  }
70
74
  shouldComponentUpdate(nextProps) {
71
75
  return !shallowEqual(this.props, nextProps);
72
76
  }
77
+ constructor(props){
78
+ super(props);
79
+ this.viewDescRef.current = null;
80
+ }
73
81
  componentDidMount() {
82
+ this.viewDescRef.current = this.create();
74
83
  this.updateEffect();
75
84
  }
76
85
  componentDidUpdate() {
77
86
  this.updateEffect();
87
+ const { view } = this.props;
88
+ if (!(view instanceof ReactEditorView)) return;
78
89
  }
79
90
  componentWillUnmount() {
80
- const { siblingsRef } = this.props;
81
- if (!this.viewDescRef) return;
82
- if (siblingsRef.current.includes(this.viewDescRef)) {
83
- const index = siblingsRef.current.indexOf(this.viewDescRef);
84
- siblingsRef.current.splice(index, 1);
85
- }
91
+ this.destroy();
86
92
  }
87
93
  render() {
88
- const { view, getPos, node, decorations } = this.props;
89
- // During a composition, it's crucial that we don't try to
90
- // update the DOM that the user is working in. If there's
91
- // an active composition and the selection is in this node,
92
- // we freeze the DOM of this element so that it doesn't
93
- // interrupt the composition
94
- if (view.composing && view.state.selection.from >= getPos() && view.state.selection.from <= getPos() + node.nodeSize) {
95
- return this.renderRef;
96
- }
97
- this.renderRef = decorations.reduce(wrapInDeco, node.text);
98
- return this.renderRef;
94
+ const { node, decorations } = this.props;
95
+ return decorations.reduce(wrapInDeco, node.text);
99
96
  }
100
97
  }
98
+ /**
99
+ * createRef returns a RefObject, even though the docs
100
+ * say that it's acceptible to manage the ref's value
101
+ * yourself.
102
+ */ function createMutRef() {
103
+ return /*#__PURE__*/ createRef();
104
+ }
@@ -39,6 +39,8 @@ export function WidgetView(param) {
39
39
  ref: domRef,
40
40
  widget: widget,
41
41
  getPos: getPos,
42
- contentEditable: false
42
+ ...!widget.type.spec.raw && {
43
+ contentEditable: false
44
+ }
43
45
  });
44
46
  }
@@ -1,10 +1,15 @@
1
- import React, { createContext, memo, useContext, useMemo } from "react";
1
+ import React, { createContext, memo, useContext, useLayoutEffect, useMemo, useReducer, useRef } from "react";
2
+ import { CompositionContext } from "../../contexts/CompositionContext.js";
2
3
  import { NodeViewContext } from "../../contexts/NodeViewContext.js";
3
4
  import { DefaultNodeView } from "./DefaultNodeView.js";
4
5
  import { NodeViewConstructorView } from "./NodeViewConstructorView.js";
5
6
  import { ReactNodeView } from "./ReactNodeView.js";
6
- export const NodeView = /*#__PURE__*/ memo(function NodeView(props) {
7
+ export const NodeView = /*#__PURE__*/ memo(function NodeView(param) {
8
+ let { forceRemount, ...props } = param;
9
+ const renderRef = useRef(null);
10
+ const { freezeFrom } = useContext(CompositionContext);
7
11
  const { components, constructors } = useContext(NodeViewContext);
12
+ const committedFrozenRef = useRef(false);
8
13
  const component = components[props.node.type.name] ?? DefaultNodeView;
9
14
  const constructor = constructors[props.node.type.name];
10
15
  // Construct a wrapper component so that the node view remounts when either
@@ -31,8 +36,36 @@ export const NodeView = /*#__PURE__*/ memo(function NodeView(props) {
31
36
  constructor,
32
37
  component
33
38
  ]);
34
- return /*#__PURE__*/ React.createElement(GetPosContext.Provider, {
35
- value: props.getPos
36
- }, /*#__PURE__*/ React.createElement(Component, props));
39
+ // It's not generally safe to access getPos during render, because the
40
+ // component may not re-render when its return value would change. Here it's
41
+ // safe because we only use it to _suppress_ commits that would otherwise
42
+ // have happened.
43
+ const frozen = props.getPos() === freezeFrom;
44
+ // Protect content while frozen, and also through the single render where we
45
+ // leave the frozen state: `committedFrozenRef` still reflects the previous
46
+ // commit, so we keep returning the exact same cached element reference.
47
+ const protecting = (frozen || committedFrozenRef.current) && renderRef.current != null;
48
+ if (!protecting) {
49
+ renderRef.current = /*#__PURE__*/ React.createElement(GetPosContext.Provider, {
50
+ value: props.getPos
51
+ }, /*#__PURE__*/ React.createElement(Component, props));
52
+ }
53
+ useLayoutEffect(()=>{
54
+ const wasFrozen = committedFrozenRef.current;
55
+ committedFrozenRef.current = frozen;
56
+ if (wasFrozen && !frozen) forceRemount();
57
+ }, [
58
+ frozen,
59
+ forceRemount
60
+ ]);
61
+ return renderRef.current;
37
62
  });
38
63
  export const GetPosContext = /*#__PURE__*/ createContext(null);
64
+ export function RemountableNodeView(props) {
65
+ const [key, forceRemount] = useReducer((x)=>x + 1, 0);
66
+ return /*#__PURE__*/ React.createElement(NodeView, {
67
+ key: key.toString(),
68
+ ...props,
69
+ forceRemount: forceRemount
70
+ });
71
+ }
@@ -0,0 +1,4 @@
1
+ import { createContext } from "react";
2
+ export const CompositionContext = createContext({
3
+ freezeFrom: null
4
+ });
@@ -123,17 +123,12 @@ const ViewDecorationsCache = new WeakMap();
123
123
  *
124
124
  * This makes it safe to call in a React render function, even
125
125
  * if its result is used in a dependencies array for a hook.
126
- */ export function viewDecorations(view, cursorWrapper) {
126
+ */ export function viewDecorations(view) {
127
127
  const found = [];
128
128
  view.someProp("decorations", (f)=>{
129
129
  const result = f(view.state);
130
130
  if (result && result != empty) found.push(result);
131
131
  });
132
- if (cursorWrapper) {
133
- found.push(DecorationSet.create(view.state.doc, [
134
- cursorWrapper
135
- ]));
136
- }
137
132
  const previous = ViewDecorationsCache.get(view);
138
133
  if (!previous) {
139
134
  const result = DecorationGroup.from(found);
@@ -25,23 +25,16 @@ let didWarnValueDefaultValue = false;
25
25
  }
26
26
  }
27
27
  const flushSyncRef = useRef(true);
28
- const [cursorWrapper, _setCursorWrapper] = useState(null);
29
28
  const forceUpdate = useForceUpdate();
30
29
  const defaultState = options.defaultState ?? EMPTY_STATE;
31
30
  const [_state, setState] = useState(defaultState);
32
31
  const state = options.state ?? _state;
33
32
  const { handleDOMEvents, registerEventListener, unregisterEventListener } = useComponentEventListeners(options.handleDOMEvents);
34
- const setCursorWrapper = useCallback((deco)=>{
35
- flushSync(()=>{
36
- _setCursorWrapper(deco);
37
- });
38
- }, []);
39
33
  const plugins = useMemo(()=>[
40
34
  ...options.plugins ?? [],
41
- beforeInputPlugin(setCursorWrapper)
35
+ beforeInputPlugin()
42
36
  ], [
43
- options.plugins,
44
- setCursorWrapper
37
+ options.plugins
45
38
  ]);
46
39
  const dispatchTransaction = useCallback(function dispatchTransaction(tr) {
47
40
  if (flushSyncRef.current) {
@@ -120,7 +113,6 @@ let didWarnValueDefaultValue = false;
120
113
  ]);
121
114
  return {
122
115
  editor,
123
- cursorWrapper,
124
116
  state
125
117
  };
126
118
  }
@@ -1,5 +1,8 @@
1
1
  import { useContext } from "react";
2
2
  import { EditorContext } from "../contexts/EditorContext.js";
3
- export function useIsEditorStatic() {
3
+ /**
4
+ * Returns true if the nearest ProseMirror component
5
+ * is rendered with the `static` prop set to `true`.
6
+ */ export function useIsEditorStatic() {
4
7
  return useContext(EditorContext)?.isStatic ?? false;
5
8
  }
@@ -113,10 +113,7 @@ export function useMarkViewDescription(getDOM, getContentDOM, constructor, props
113
113
  const childContextValue = useMemo(()=>({
114
114
  parentRef: viewDescRef,
115
115
  siblingsRef: childrenRef
116
- }), [
117
- childrenRef,
118
- viewDescRef
119
- ]);
116
+ }), []);
120
117
  return {
121
118
  childContextValue,
122
119
  contentDOM: contentDOMRef.current ?? viewDescRef.current?.dom,
@@ -2,7 +2,7 @@ import { useCallback, useContext, useMemo, useRef } from "react";
2
2
  import { ReactEditorView } from "../ReactEditorView.js";
3
3
  import { ChildDescriptionsContext } from "../contexts/ChildDescriptionsContext.js";
4
4
  import { EditorContext } from "../contexts/EditorContext.js";
5
- import { CompositionViewDesc, ReactNodeViewDesc, sortViewDescs } from "../viewdesc.js";
5
+ import { ReactNodeViewDesc, sortViewDescs } from "../viewdesc.js";
6
6
  import { useClientLayoutEffect } from "./useClientLayoutEffect.js";
7
7
  import { useEffectEvent } from "./useEffectEvent.js";
8
8
  export function useNodeViewDescription(getDOM, getContentDOM, constructor, props) {
@@ -131,33 +131,12 @@ export function useNodeViewDescription(getDOM, getContentDOM, constructor, props
131
131
  children.sort(sortViewDescs);
132
132
  for (const child of children){
133
133
  child.parent = viewDesc;
134
- // Because TextNodeViews can't locate the DOM nodes
135
- // for compositions, we need to override them here
136
- if (child instanceof CompositionViewDesc) {
137
- const compositionTopDOM = viewDesc?.contentDOM?.firstChild;
138
- if (!compositionTopDOM) throw new Error(`Started a composition but couldn't find the text node it belongs to.`);
139
- let textDOM = compositionTopDOM;
140
- while(textDOM.firstChild){
141
- textDOM = textDOM.firstChild;
142
- }
143
- if (!textDOM || !(textDOM instanceof Text)) throw new Error(`Started a composition but couldn't find the text node it belongs to.`);
144
- child.dom = compositionTopDOM;
145
- child.textDOM = textDOM;
146
- child.text = textDOM.data;
147
- child.textDOM.pmViewDesc = child;
148
- // It should not be possible to be in a composition because one could
149
- // not start between the renders that switch the view type.
150
- view.input.compositionNodes.push(child);
151
- }
152
134
  }
153
135
  });
154
136
  const childContextValue = useMemo(()=>({
155
137
  parentRef: viewDescRef,
156
138
  siblingsRef: childrenRef
157
- }), [
158
- childrenRef,
159
- viewDescRef
160
- ]);
139
+ }), []);
161
140
  return {
162
141
  childContextValue,
163
142
  contentDOM: contentDOMRef.current,
@@ -1,7 +1,10 @@
1
1
  import { Fragment, Slice } from "prosemirror-model";
2
2
  import { Plugin, TextSelection } from "prosemirror-state";
3
+ import { ReactEditorView } from "../ReactEditorView.js";
3
4
  import { CursorWrapper } from "../components/CursorWrapper.js";
4
5
  import { widget } from "../decorations/ReactWidgetType.js";
6
+ import { CompositionViewDesc, TextViewDesc, findTextInFragment } from "../viewdesc.js";
7
+ import { reactKeysPluginKey } from "./reactKeys.js";
5
8
  function insertText(view, eventData) {
6
9
  let options = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : {};
7
10
  if (eventData === null) return false;
@@ -44,74 +47,127 @@ function handleGapCursorComposition(view) {
44
47
  tr.setSelection(TextSelection.near(tr.doc.resolve($from.pos + 1)));
45
48
  view.dispatch(tr);
46
49
  }
47
- export function beforeInputPlugin(setCursorWrapper) {
48
- let compositionMarks = null;
49
- let precompositionSnapshot = null;
50
+ const observeOptions = {
51
+ childList: true,
52
+ characterData: true,
53
+ characterDataOldValue: true,
54
+ attributes: true,
55
+ attributeOldValue: true,
56
+ subtree: true
57
+ };
58
+ export function beforeInputPlugin() {
59
+ let observer = null;
60
+ let preCompositionSnapshot = null;
61
+ function teardownComposition(view, endedAt) {
62
+ view.input.composing = false;
63
+ if (observer) {
64
+ if (view.input.compositionNode && view.dom.contains(view.input.compositionNode)) {
65
+ view.domObserver.queue.push(...observer.takeRecords());
66
+ view.domObserver.flush();
67
+ } else {
68
+ const freezeFrom = reactKeysPluginKey.getState(view.state)?.freezeFrom;
69
+ const frozenNode = freezeFrom == null ? null : view.state.doc.nodeAt(freezeFrom);
70
+ if (freezeFrom != null && frozenNode != null && preCompositionSnapshot) {
71
+ // This is a little hacky — it only works because we always abort
72
+ // compositions if the node after freezeFrom changes, so we can
73
+ // be sure that if a composition was canceled by the user/browser,
74
+ // the content hasn't changed since the composition started
75
+ view.dispatch(view.state.tr.replaceWith(freezeFrom + 1, freezeFrom + 1 + frozenNode.content.size, preCompositionSnapshot));
76
+ }
77
+ }
78
+ observer.disconnect();
79
+ observer = null;
80
+ }
81
+ view.input.compositionEndedAt = endedAt;
82
+ view.input.compositionNode = null;
83
+ view.input.compositionNodes = [];
84
+ view.input.compositionID++;
85
+ }
50
86
  return new Plugin({
87
+ view () {
88
+ return {
89
+ update (view) {
90
+ if (!(view instanceof ReactEditorView)) return;
91
+ const frozen = reactKeysPluginKey.getState(view.state)?.freezeFrom != null;
92
+ if (observer && view.composing && !frozen) {
93
+ teardownComposition(view, Date.now());
94
+ }
95
+ }
96
+ };
97
+ },
51
98
  props: {
52
99
  handleDOMEvents: {
53
100
  compositionstart (view) {
54
- compositionMarks = view.state.storedMarks ?? view.state.selection.$from.marks();
55
- view.dispatch(view.state.tr.deleteSelection());
101
+ if (!(view instanceof ReactEditorView)) return false;
102
+ const storedMarks = view.state.selection.empty ? view.state.storedMarks : view.state.storedMarks ?? (view.state.selection instanceof TextSelection ? view.state.selection.$from.marksAcross(view.state.selection.$to) : null);
103
+ view.dispatch(view.state.tr.deleteSelection().setStoredMarks(storedMarks));
56
104
  handleGapCursorComposition(view);
57
- const { state } = view;
58
- const $pos = state.selection.$from;
59
- if (compositionMarks) {
60
- setCursorWrapper(widget(state.selection.from, CursorWrapper, {
61
- key: "cursor-wrapper",
62
- marks: compositionMarks
105
+ if (storedMarks) {
106
+ view.dispatch(view.state.tr.setMeta(reactKeysPluginKey, {
107
+ cursorWrapper: widget(view.state.selection.from, CursorWrapper, {
108
+ key: "cursor-wrapper",
109
+ marks: storedMarks,
110
+ side: 0,
111
+ raw: true
112
+ })
63
113
  }));
114
+ // Pin the DOM cursor to PM's canonical position before the IME
115
+ // captures wherever the browser happened to leave it. Without this,
116
+ // a cursor at a mark boundary lands in either the left or right text
117
+ // node depending on the user's last navigation direction, and the
118
+ // IME composes into whichever one it found.
119
+ } else if (view.state.selection.empty) {
120
+ view.domObserver.disconnectSelection();
121
+ try {
122
+ view.docView.setSelection(view.state.selection.anchor, view.state.selection.head, view, true // force — skip the isEquivalentPosition early-return
123
+ );
124
+ } finally{
125
+ view.domObserver.setCurSelection();
126
+ view.domObserver.connectSelection();
127
+ }
64
128
  }
65
- // Snapshot the siblings of the node that contains the
66
- // current cursor. We'll restore this later, so that React
67
- // doesn't panic about unknown DOM nodes.
68
- const { node: parent } = view.domAtPos($pos.pos);
69
- precompositionSnapshot = [];
70
- for (const node of parent.childNodes){
71
- precompositionSnapshot.push(node);
129
+ const freezeFrom = view.state.selection.$from.before();
130
+ view.dispatch(view.state.tr.setMeta(reactKeysPluginKey, {
131
+ freezeFrom
132
+ }));
133
+ const frozenDom = view.nodeDOM(freezeFrom);
134
+ if (!frozenDom) {
135
+ view.dispatch(view.state.tr.setMeta(reactKeysPluginKey, {
136
+ cursorWrapper: null,
137
+ freezeFrom: null
138
+ }));
139
+ return false;
72
140
  }
73
- // @ts-expect-error Internal property - input
141
+ preCompositionSnapshot = view.state.doc.nodeAt(freezeFrom)?.content ?? null;
74
142
  view.input.composing = true;
143
+ observer = new MutationObserver((records)=>{
144
+ if (reactKeysPluginKey.getState(view.state)?.freezeFrom == null) {
145
+ return;
146
+ }
147
+ view.domObserver.queue.push(...records);
148
+ view.domObserver.flush();
149
+ syncCompositionViewDescs(view);
150
+ });
151
+ observer.observe(frozenDom, observeOptions);
75
152
  return true;
76
153
  },
77
154
  compositionupdate () {
78
155
  return true;
79
156
  },
80
157
  compositionend (view, event) {
81
- // @ts-expect-error Internal property - input
82
- view.input.composing = false;
83
- const { state } = view;
84
- const { node: parent } = view.domAtPos(state.selection.from);
85
- if (precompositionSnapshot) {
86
- // Restore the snapshot of the parent node's children
87
- // from before the composition started. This gives us a
88
- // clean slate from which to dispatch our transaction
89
- // and trigger a React update.
90
- precompositionSnapshot.forEach((prevNode, i)=>{
91
- if (parent.childNodes.length <= i) {
92
- parent.appendChild(prevNode);
93
- return;
94
- }
95
- parent.replaceChild(prevNode, parent.childNodes.item(i));
96
- });
97
- if (parent.childNodes.length > precompositionSnapshot.length) {
98
- for(let i = precompositionSnapshot.length; i < parent.childNodes.length; i++){
99
- parent.removeChild(parent.childNodes.item(i));
100
- }
101
- }
102
- }
103
- if (event.data) {
104
- insertText(view, event.data, {
105
- marks: compositionMarks
106
- });
107
- }
108
- compositionMarks = null;
109
- precompositionSnapshot = null;
110
- setCursorWrapper(null);
158
+ if (!(view instanceof ReactEditorView)) return false;
159
+ if (!view.composing) return false;
160
+ teardownComposition(view, event.timeStamp);
161
+ view.dispatch(view.state.tr.setMeta(reactKeysPluginKey, {
162
+ cursorWrapper: null,
163
+ freezeFrom: null
164
+ }));
111
165
  return true;
112
166
  },
113
167
  beforeinput (view, event) {
114
- event.preventDefault();
168
+ if (event.inputType !== "insertFromComposition") {
169
+ event.preventDefault();
170
+ }
115
171
  switch(event.inputType){
116
172
  case "insertParagraph":
117
173
  case "insertLineBreak":
@@ -189,3 +245,59 @@ export function beforeInputPlugin(setCursorWrapper) {
189
245
  }
190
246
  });
191
247
  }
248
+ function syncCompositionViewDescs(view) {
249
+ const compositionNode = view.domObserver.lastChangedTextNode;
250
+ if (!compositionNode) return;
251
+ const freezeFrom = reactKeysPluginKey.getState(view.state)?.freezeFrom;
252
+ if (freezeFrom == null) return;
253
+ const compositionBlock = view.state.doc.nodeAt(freezeFrom);
254
+ if (!compositionBlock) return;
255
+ const compositionBlockDesc = view.docView.descAt(freezeFrom);
256
+ if (!compositionBlockDesc) return;
257
+ const desc = view.docView.nearestDesc(compositionNode);
258
+ compositionBlockDesc.node = compositionBlock;
259
+ if (desc instanceof TextViewDesc) {
260
+ if (compositionNode.nodeValue && desc.node.text !== compositionNode.nodeValue) {
261
+ desc.node = view.state.schema.text(compositionNode.nodeValue, desc.node.marks);
262
+ desc.nodeDOM = compositionNode;
263
+ compositionNode.pmViewDesc = desc;
264
+ }
265
+ return;
266
+ }
267
+ if (desc instanceof CompositionViewDesc) {
268
+ if (compositionNode.nodeValue != null && desc.text !== compositionNode.nodeValue) {
269
+ desc.dom = compositionNode;
270
+ desc.textDOM = compositionNode;
271
+ desc.text = compositionNode.nodeValue;
272
+ compositionNode.pmViewDesc = desc;
273
+ }
274
+ return;
275
+ }
276
+ const parentDesc = desc?.contentDOM ? desc : compositionBlockDesc;
277
+ const children = parentDesc.children;
278
+ // Drop any text or composition desc in this container whose DOM the
279
+ // IME has detached. This covers two cases: a TextViewDesc the IME subsumed
280
+ // into the composition node, and (on Safari, which replaces the whole text
281
+ // node on each composition update) any orphaned composition view
282
+ // desc(s) left over from the previous composition steps.
283
+ for(let i = children.length - 1; i >= 0; i--){
284
+ const c = children[i];
285
+ if (!(c instanceof TextViewDesc) && !(c instanceof CompositionViewDesc)) {
286
+ continue;
287
+ }
288
+ const dom = c.dom;
289
+ if (view.dom.contains(dom)) continue;
290
+ children.splice(i, 1);
291
+ }
292
+ const contentStart = freezeFrom + 1;
293
+ const { from, to } = view.state.selection;
294
+ const textPos = findTextInFragment(compositionBlock.content, compositionNode.nodeValue ?? "", from - contentStart, to - contentStart);
295
+ if (textPos < 0) return;
296
+ const startPos = contentStart + textPos;
297
+ let topDOM = compositionNode;
298
+ while(topDOM.parentNode && topDOM.parentNode !== parentDesc.contentDOM){
299
+ topDOM = topDOM.parentNode;
300
+ }
301
+ const insertIndex = children.findLastIndex((c)=>c.posBefore <= startPos) + 1;
302
+ children.splice(insertIndex, 0, new CompositionViewDesc(parentDesc, ()=>startPos, topDOM, compositionNode, compositionNode.nodeValue ?? ""));
303
+ }