@handlewithcare/react-prosemirror 3.1.0-tiptap.49 → 3.1.0-tiptap.50

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 (32) hide show
  1. package/dist/cjs/ReactEditorView.js +2 -0
  2. package/dist/cjs/components/ChildNodeViews.js +12 -7
  3. package/dist/cjs/components/CursorWrapper.js +8 -9
  4. package/dist/cjs/components/TextNodeView.js +112 -38
  5. package/dist/cjs/hooks/useComponentEventListeners.js +46 -5
  6. package/dist/cjs/hooks/useEditor.js +16 -8
  7. package/dist/cjs/hooks/useNodeViewDescription.js +38 -15
  8. package/dist/cjs/plugins/beforeInputPlugin.js +47 -41
  9. package/dist/cjs/viewdesc.js +10 -3
  10. package/dist/esm/ReactEditorView.js +2 -0
  11. package/dist/esm/components/ChildNodeViews.js +12 -7
  12. package/dist/esm/components/CursorWrapper.js +9 -10
  13. package/dist/esm/components/TextNodeView.js +112 -38
  14. package/dist/esm/hooks/useComponentEventListeners.js +53 -12
  15. package/dist/esm/hooks/useEditor.js +16 -8
  16. package/dist/esm/hooks/useNodeViewDescription.js +39 -16
  17. package/dist/esm/plugins/beforeInputPlugin.js +47 -41
  18. package/dist/esm/viewdesc.js +10 -3
  19. package/dist/tsconfig.tsbuildinfo +1 -1
  20. package/dist/types/ReactEditorView.d.ts +4 -0
  21. package/dist/types/components/TextNodeView.d.ts +14 -4
  22. package/dist/types/constants.d.ts +1 -1
  23. package/dist/types/contexts/EditorContext.d.ts +1 -1
  24. package/dist/types/hooks/useComponentEventListeners.d.ts +11 -10
  25. package/dist/types/hooks/useEditor.d.ts +2 -2
  26. package/dist/types/hooks/useEditorEventListener.d.ts +1 -1
  27. package/dist/types/props.d.ts +26 -26
  28. package/dist/types/viewdesc.d.ts +2 -2
  29. package/package.json +2 -1
  30. package/dist/cjs/tiptap/utils/ssrJSDOMPatch.js +0 -59
  31. package/dist/esm/tiptap/utils/ssrJSDOMPatch.js +0 -56
  32. package/dist/types/tiptap/utils/ssrJSDOMPatch.d.ts +0 -1
@@ -51,13 +51,18 @@ const ChildView = /*#__PURE__*/ memo(function ChildView(param) {
51
51
  key: child.key
52
52
  }, (param)=>{
53
53
  let { siblingsRef, parentRef } = param;
54
- return /*#__PURE__*/ React.createElement(TextNodeView, {
55
- view: view,
56
- node: child.node,
57
- getPos: getPos,
58
- siblingsRef: siblingsRef,
59
- parentRef: parentRef,
60
- decorations: child.outerDeco
54
+ return /*#__PURE__*/ React.createElement(EditorContext.Consumer, null, (param)=>{
55
+ let { registerEventListener, unregisterEventListener } = param;
56
+ return /*#__PURE__*/ React.createElement(TextNodeView, {
57
+ view: view,
58
+ node: child.node,
59
+ getPos: getPos,
60
+ siblingsRef: siblingsRef,
61
+ parentRef: parentRef,
62
+ decorations: child.outerDeco,
63
+ registerEventListener: registerEventListener,
64
+ unregisterEventListener: unregisterEventListener
65
+ });
61
66
  });
62
67
  }) : /*#__PURE__*/ React.createElement(NodeView, {
63
68
  key: child.key,
@@ -1,8 +1,9 @@
1
- import React, { forwardRef, useImperativeHandle, useRef } from "react";
1
+ import React, { forwardRef, useImperativeHandle, useRef, useState } from "react";
2
2
  import { domIndex } from "../dom.js";
3
3
  import { useEditorEffect } from "../hooks/useEditorEffect.js";
4
4
  export const CursorWrapper = /*#__PURE__*/ forwardRef(function CursorWrapper(param, ref) {
5
5
  let { widget, getPos, ...props } = param;
6
+ const [shouldRender, setShouldRender] = useState(true);
6
7
  const innerRef = useRef(null);
7
8
  useImperativeHandle(ref, ()=>{
8
9
  return innerRef.current;
@@ -14,22 +15,20 @@ export const CursorWrapper = /*#__PURE__*/ forwardRef(function CursorWrapper(par
14
15
  // @ts-expect-error Internal property - domSelection
15
16
  const domSel = view.domSelection();
16
17
  const node = innerRef.current;
17
- const img = node.nodeName == "IMG";
18
- if (img) {
19
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
20
- domSel.collapse(node.parentNode, domIndex(node) + 1);
21
- } else {
22
- domSel.collapse(node, 0);
23
- }
18
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
19
+ domSel.collapse(node.parentNode, domIndex(node) + 1);
24
20
  // @ts-expect-error Internal property - domObserver
25
21
  view.domObserver.connectSelection();
22
+ setTimeout(()=>{
23
+ setShouldRender(false);
24
+ });
26
25
  }, []);
27
- return /*#__PURE__*/ React.createElement("img", {
26
+ return shouldRender ? /*#__PURE__*/ React.createElement("img", {
28
27
  ref: innerRef,
29
28
  className: "ProseMirror-separator",
30
29
  // eslint-disable-next-line react/no-unknown-property
31
30
  "mark-placeholder": "true",
32
31
  alt: "",
33
32
  ...props
34
- });
33
+ }) : null;
35
34
  });
@@ -1,5 +1,7 @@
1
+ import { TextSelection } from "prosemirror-state";
1
2
  import { DecorationSet } from "prosemirror-view";
2
3
  import { Component } from "react";
4
+ import { ReactEditorView } from "../ReactEditorView.js";
3
5
  import { findDOMNode } from "../findDOMNode.js";
4
6
  import { CompositionViewDesc, TextViewDesc, sortViewDescs } from "../viewdesc.js";
5
7
  import { wrapInDeco } from "./ChildNodeViews.js";
@@ -28,72 +30,144 @@ function shallowEqual(objA, objB) {
28
30
  export class TextNodeView extends Component {
29
31
  viewDescRef = null;
30
32
  renderRef = null;
31
- updateEffect() {
33
+ wasProtecting = false;
34
+ containsCompositionNodeText = true;
35
+ // This is basically NodeViewDesc.localCompositionInfo
36
+ // from prosemirror-view. It's been slightly adjusted so that
37
+ // it can be used accurately during render, before we've
38
+ // necessarily found (or even let the browser create)
39
+ // view.input.compositionNode
40
+ shouldProtect(props) {
41
+ const { view, getPos, node } = props;
42
+ if (!(view instanceof ReactEditorView)) return false;
43
+ if (!view.composing) {
44
+ return false;
45
+ }
46
+ const pos = getPos();
47
+ const { from, to } = view.state.selection;
48
+ if (!(view.state.selection instanceof TextSelection) || from < pos || // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
49
+ to > pos + node.nodeSize) {
50
+ return false;
51
+ }
52
+ return this.containsCompositionNodeText;
53
+ }
54
+ handleCompositionEnd = ()=>{
55
+ if (!this.wasProtecting) return;
56
+ this.forceUpdate();
57
+ return;
58
+ };
59
+ create() {
32
60
  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
61
  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
- }
62
+ if (!dom && !view.composing) return null;
49
63
  let textNode = dom;
50
- while(textNode.firstChild){
64
+ while(textNode?.firstChild){
51
65
  textNode = textNode.firstChild;
52
66
  }
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;
67
+ if (!(textNode instanceof Text)) {
68
+ textNode = null;
64
69
  }
65
- if (!siblingsRef.current.includes(this.viewDescRef)) {
66
- siblingsRef.current.push(this.viewDescRef);
70
+ let viewDesc;
71
+ if (this.shouldProtect(this.props)) {
72
+ viewDesc = new CompositionViewDesc(parentRef.current, getPos, // If we can't
73
+ // actually find the correct DOM nodes from here (
74
+ // which is the case in a composition in a newly
75
+ // created text node), we let our parent do it.
76
+ // Passing a valid element here just so that the
77
+ // ViewDesc constructor doesn't blow up.
78
+ dom ?? document.createElement("div"), textNode ?? document.createTextNode(node.text ?? ""), node.text ?? "");
79
+ } else {
80
+ if (!dom || !textNode) return null;
81
+ viewDesc = new TextViewDesc(parentRef.current, [], getPos, node, decorations, DecorationSet.empty, dom, textNode);
67
82
  }
83
+ siblingsRef.current.push(viewDesc);
68
84
  siblingsRef.current.sort(sortViewDescs);
85
+ return viewDesc;
86
+ }
87
+ update() {
88
+ const { view, node, decorations } = this.props;
89
+ if (!(view instanceof ReactEditorView)) return false;
90
+ const viewDesc = this.viewDescRef;
91
+ if (!viewDesc) return false;
92
+ if (this.shouldProtect(this.props) !== viewDesc instanceof CompositionViewDesc) {
93
+ return false;
94
+ }
95
+ if (viewDesc instanceof CompositionViewDesc) return false;
96
+ const dom = findDOMNode(this);
97
+ if (!dom || dom !== viewDesc.dom) return false;
98
+ if (!dom.contains(viewDesc.nodeDOM)) return false;
99
+ return viewDesc.matchesNode(node, decorations, DecorationSet.empty) || viewDesc.update(node, decorations, DecorationSet.empty, view);
100
+ }
101
+ destroy() {
102
+ const viewDesc = this.viewDescRef;
103
+ if (!viewDesc) return;
104
+ viewDesc.destroy();
105
+ const siblings = this.props.siblingsRef.current;
106
+ if (siblings.includes(viewDesc)) {
107
+ const index = siblings.indexOf(viewDesc);
108
+ siblings.splice(index, 1);
109
+ }
110
+ }
111
+ updateEffect() {
112
+ if (!this.update()) {
113
+ this.destroy();
114
+ this.viewDescRef = this.create();
115
+ }
116
+ setTimeout(()=>{
117
+ const { view, node } = this.props;
118
+ if (!(view instanceof ReactEditorView)) {
119
+ this.containsCompositionNodeText = true;
120
+ return;
121
+ }
122
+ const textNode = view.input.compositionNode;
123
+ if (!textNode) {
124
+ this.containsCompositionNodeText = true;
125
+ return;
126
+ }
127
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
128
+ const text = textNode.nodeValue;
129
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
130
+ this.containsCompositionNodeText = node.text === text;
131
+ });
69
132
  }
70
133
  shouldComponentUpdate(nextProps) {
134
+ // When leaving the protected state, force a re-render so React's
135
+ // virtual DOM resyncs with whatever the IME wrote into the real DOM
136
+ // while we were returning a stale renderRef.
137
+ if (this.wasProtecting && !this.shouldProtect(nextProps)) {
138
+ return true;
139
+ }
71
140
  return !shallowEqual(this.props, nextProps);
72
141
  }
73
142
  componentDidMount() {
143
+ this.viewDescRef = null;
144
+ // After a composition, force an update so that we re-check whether we need
145
+ // to be protecting our rendered content and allow React to re-sync with the
146
+ // DOM.
147
+ const { registerEventListener } = this.props;
148
+ registerEventListener("compositionend", this.handleCompositionEnd);
74
149
  this.updateEffect();
75
150
  }
76
151
  componentDidUpdate() {
77
152
  this.updateEffect();
78
153
  }
79
154
  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
- }
155
+ const { unregisterEventListener } = this.props;
156
+ unregisterEventListener("compositionend", this.handleCompositionEnd);
157
+ this.destroy();
86
158
  }
87
159
  render() {
88
- const { view, getPos, node, decorations } = this.props;
160
+ const { node, decorations } = this.props;
89
161
  // During a composition, it's crucial that we don't try to
90
162
  // update the DOM that the user is working in. If there's
91
163
  // an active composition and the selection is in this node,
92
164
  // we freeze the DOM of this element so that it doesn't
93
165
  // interrupt the composition
94
- if (view.composing && view.state.selection.from >= getPos() && view.state.selection.from <= getPos() + node.nodeSize) {
166
+ if (this.shouldProtect(this.props)) {
167
+ this.wasProtecting = true;
95
168
  return this.renderRef;
96
169
  }
170
+ this.wasProtecting = false;
97
171
  this.renderRef = decorations.reduce(wrapInDeco, node.text);
98
172
  return this.renderRef;
99
173
  }
@@ -1,5 +1,5 @@
1
- /* Copyright (c) The New York Times Company */ import { useCallback, useMemo, useState } from "react";
2
- import { componentEventListeners } from "../plugins/componentEventListeners.js";
1
+ /* Copyright (c) The New York Times Company */ import { useCallback, useLayoutEffect, useMemo, useState } from "react";
2
+ import { unstable_batchedUpdates as batch } from "react-dom";
3
3
  /**
4
4
  * Produces a plugin that can be used with ProseMirror to handle DOM
5
5
  * events at the EditorView.dom element.
@@ -14,19 +14,27 @@ import { componentEventListeners } from "../plugins/componentEventListeners.js";
14
14
  * @privateRemarks
15
15
  *
16
16
  * This hook uses a combination of mutable and immutable updates to give
17
- * us precise control over when we re-create the ProseMirror plugin.
17
+ * us precise control over when we re-create the event listeners.
18
18
  *
19
- * The plugin has a mutable reference to the set of handlers for each
19
+ * The hook has a mutable reference to the set of handlers for each
20
20
  * event type, but the set of event types is static. This means that we
21
- * need to produce a new ProseMirror plugin whenever a new event type is
22
- * registered. We avoid producing a new ProseMirrer plugin in any other
23
- * scenario to avoid the performance overhead of reconfiguring the plugins
24
- * in the EditorView.
21
+ * need to produce a new handleDOMEVents record whenever a new event type is
22
+ * registered. We avoid producing a new record in any other
23
+ * scenario to avoid the performance overhead of re-registering the event
24
+ * listeners in the EditorView.
25
25
  *
26
26
  * To accomplish this, we shallowly clone the registry whenever a new event
27
27
  * type is registered.
28
- */ export function useComponentEventListeners() {
29
- const [registry, setRegistry] = useState(new Map());
28
+ */ export function useComponentEventListeners(existingHandlers) {
29
+ const [registry, setRegistry] = useState(new Map(Object.entries(existingHandlers ?? {}).map((param)=>{
30
+ let [eventName, handler] = param;
31
+ return [
32
+ eventName,
33
+ handler ? [
34
+ handler
35
+ ] : []
36
+ ];
37
+ })));
30
38
  const registerEventListener = useCallback((eventType, handler)=>{
31
39
  const handlers = registry.get(eventType) ?? [];
32
40
  handlers.unshift(handler);
@@ -43,12 +51,45 @@ import { componentEventListeners } from "../plugins/componentEventListeners.js";
43
51
  }, [
44
52
  registry
45
53
  ]);
46
- const componentEventListenersPlugin = useMemo(()=>componentEventListeners(registry), [
54
+ useLayoutEffect(()=>{
55
+ if (!existingHandlers) return;
56
+ for (const [eventType, handler] of Object.entries(existingHandlers)){
57
+ if (!handler) return;
58
+ registerEventListener(eventType, handler);
59
+ }
60
+ return ()=>{
61
+ for (const [eventType, handler] of Object.entries(existingHandlers)){
62
+ if (!handler) return;
63
+ unregisterEventListener(eventType, handler);
64
+ }
65
+ };
66
+ }, [
67
+ existingHandlers,
68
+ registerEventListener,
69
+ unregisterEventListener
70
+ ]);
71
+ const handleDOMEvents = useMemo(()=>{
72
+ const domEventHandlers = {};
73
+ for (const [eventType, handlers] of registry.entries()){
74
+ function handleEvent(view, event) {
75
+ for (const handler of handlers){
76
+ let handled = false;
77
+ batch(()=>{
78
+ handled = !!handler(view, event);
79
+ });
80
+ if (handled || event.defaultPrevented) return true;
81
+ }
82
+ return false;
83
+ }
84
+ domEventHandlers[eventType] = handleEvent;
85
+ }
86
+ return domEventHandlers;
87
+ }, [
47
88
  registry
48
89
  ]);
49
90
  return {
50
91
  registerEventListener,
51
92
  unregisterEventListener,
52
- componentEventListenersPlugin
93
+ handleDOMEvents
53
94
  };
54
95
  }
@@ -30,7 +30,7 @@ let didWarnValueDefaultValue = false;
30
30
  const defaultState = options.defaultState ?? EMPTY_STATE;
31
31
  const [_state, setState] = useState(defaultState);
32
32
  const state = options.state ?? _state;
33
- const { componentEventListenersPlugin, registerEventListener, unregisterEventListener } = useComponentEventListeners();
33
+ const { handleDOMEvents, registerEventListener, unregisterEventListener } = useComponentEventListeners(options.handleDOMEvents);
34
34
  const setCursorWrapper = useCallback((deco)=>{
35
35
  flushSync(()=>{
36
36
  _setCursorWrapper(deco);
@@ -38,11 +38,9 @@ let didWarnValueDefaultValue = false;
38
38
  }, []);
39
39
  const plugins = useMemo(()=>[
40
40
  ...options.plugins ?? [],
41
- componentEventListenersPlugin,
42
41
  beforeInputPlugin(setCursorWrapper)
43
42
  ], [
44
43
  options.plugins,
45
- componentEventListenersPlugin,
46
44
  setCursorWrapper
47
45
  ]);
48
46
  const dispatchTransaction = useCallback(function dispatchTransaction(tr) {
@@ -71,7 +69,8 @@ let didWarnValueDefaultValue = false;
71
69
  ...options,
72
70
  state,
73
71
  plugins,
74
- dispatchTransaction
72
+ dispatchTransaction,
73
+ handleDOMEvents
75
74
  };
76
75
  const [view, setView] = useState(()=>{
77
76
  return new StaticEditorView(directEditorProps);
@@ -101,11 +100,20 @@ let didWarnValueDefaultValue = false;
101
100
  // running effects. Running effects will reattach selection
102
101
  // change listeners if the EditorView has been destroyed.
103
102
  if (view instanceof ReactEditorView && !view.isDestroyed) {
104
- // Plugins might dispatch transactions from their
105
- // view update lifecycle hooks
106
- flushSyncRef.current = false;
103
+ if (view.deferPendingEffects) {
104
+ setTimeout(()=>{
105
+ // Plugins might dispatch transactions from their
106
+ // view update lifecycle hooks
107
+ flushSyncRef.current = false;
108
+ view.commitPendingEffects();
109
+ flushSyncRef.current = true;
110
+ });
111
+ } else {
112
+ flushSyncRef.current = false;
113
+ view.commitPendingEffects();
114
+ flushSyncRef.current = true;
115
+ }
107
116
  view.commitPendingEffects();
108
- flushSyncRef.current = true;
109
117
  }
110
118
  });
111
119
  view.update(directEditorProps);
@@ -1,8 +1,10 @@
1
1
  import { useCallback, useContext, useMemo, useRef } from "react";
2
2
  import { ReactEditorView } from "../ReactEditorView.js";
3
+ import { CursorWrapper } from "../components/CursorWrapper.js";
3
4
  import { ChildDescriptionsContext } from "../contexts/ChildDescriptionsContext.js";
4
5
  import { EditorContext } from "../contexts/EditorContext.js";
5
- import { CompositionViewDesc, ReactNodeViewDesc, sortViewDescs } from "../viewdesc.js";
6
+ import { ReactWidgetType } from "../decorations/ReactWidgetType.js";
7
+ import { CompositionViewDesc, MarkViewDesc, ReactNodeViewDesc, WidgetViewDesc, sortViewDescs } from "../viewdesc.js";
6
8
  import { useClientLayoutEffect } from "./useClientLayoutEffect.js";
7
9
  import { useEffectEvent } from "./useEffectEvent.js";
8
10
  export function useNodeViewDescription(getDOM, getContentDOM, constructor, props) {
@@ -131,25 +133,46 @@ export function useNodeViewDescription(getDOM, getContentDOM, constructor, props
131
133
  children.sort(sortViewDescs);
132
134
  for (const child of children){
133
135
  child.parent = viewDesc;
136
+ }
137
+ setTimeout(()=>{
134
138
  // Because TextNodeViews can't locate the DOM nodes
135
139
  // 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;
140
+ if (!viewDescRef.current?.contentDOM) return;
141
+ const children = viewDescRef.current?.children;
142
+ const compositionChildIndex = children.findIndex((child)=>child instanceof CompositionViewDesc);
143
+ if (compositionChildIndex === -1) return;
144
+ const compositionViewDesc = children[compositionChildIndex];
145
+ if (!(compositionViewDesc instanceof CompositionViewDesc)) return;
146
+ let compositionTopDOM = null;
147
+ let search = children[compositionChildIndex - 1];
148
+ while(search instanceof MarkViewDesc){
149
+ search = search.children[0];
150
+ }
151
+ if (search instanceof WidgetViewDesc && search.widget.type instanceof ReactWidgetType && search.widget.type.Component === CursorWrapper) {
152
+ compositionTopDOM = search.dom.nextSibling;
153
+ } else {
154
+ for (const childNode of viewDescRef.current.contentDOM.childNodes){
155
+ if (children.every((child)=>child.dom !== childNode)) {
156
+ compositionTopDOM = childNode;
157
+ break;
158
+ }
142
159
  }
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
160
  }
152
- }
161
+ if (!compositionTopDOM) return;
162
+ let textDOM = compositionTopDOM;
163
+ while(textDOM.firstChild){
164
+ textDOM = textDOM.firstChild;
165
+ }
166
+ if (!textDOM || !(textDOM instanceof Text)) {
167
+ console.error(compositionTopDOM, textDOM);
168
+ throw new Error(`Started a composition but couldn't find the text node it belongs to.`);
169
+ }
170
+ compositionViewDesc.dom = compositionTopDOM;
171
+ compositionViewDesc.textDOM = textDOM;
172
+ compositionViewDesc.text = textDOM.data;
173
+ compositionViewDesc.textDOM.pmViewDesc = compositionViewDesc;
174
+ view.input.compositionNodes.push(compositionViewDesc);
175
+ });
153
176
  });
154
177
  const childContextValue = useMemo(()=>({
155
178
  parentRef: viewDescRef,
@@ -1,5 +1,6 @@
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";
5
6
  function insertText(view, eventData) {
@@ -46,31 +47,23 @@ function handleGapCursorComposition(view) {
46
47
  }
47
48
  export function beforeInputPlugin(setCursorWrapper) {
48
49
  let compositionMarks = null;
49
- let precompositionSnapshot = null;
50
50
  return new Plugin({
51
51
  props: {
52
52
  handleDOMEvents: {
53
53
  compositionstart (view) {
54
- compositionMarks = view.state.storedMarks ?? view.state.selection.$from.marks();
54
+ if (!(view instanceof ReactEditorView)) return false;
55
+ compositionMarks = view.state.storedMarks;
55
56
  view.dispatch(view.state.tr.deleteSelection());
56
57
  handleGapCursorComposition(view);
57
58
  const { state } = view;
58
- const $pos = state.selection.$from;
59
- if (compositionMarks) {
59
+ // const $pos = state.selection.$from;
60
+ if (compositionMarks?.length) {
60
61
  setCursorWrapper(widget(state.selection.from, CursorWrapper, {
61
62
  key: "cursor-wrapper",
62
- marks: compositionMarks
63
+ marks: compositionMarks,
64
+ side: 1
63
65
  }));
64
66
  }
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);
72
- }
73
- // @ts-expect-error Internal property - input
74
67
  view.input.composing = true;
75
68
  return true;
76
69
  },
@@ -78,36 +71,17 @@ export function beforeInputPlugin(setCursorWrapper) {
78
71
  return true;
79
72
  },
80
73
  compositionend (view, event) {
81
- // @ts-expect-error Internal property - input
74
+ if (!(view instanceof ReactEditorView)) return false;
75
+ if (!view.composing) return false;
82
76
  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
77
  compositionMarks = null;
109
- precompositionSnapshot = null;
110
78
  setCursorWrapper(null);
79
+ if (view.input.compositionNode && !view.input.compositionNode.pmViewDesc && (view.input.compositionNode instanceof Text || view.input.compositionNode instanceof Element)) {
80
+ view.input.compositionNode.remove();
81
+ }
82
+ view.input.compositionEndedAt = event.timeStamp;
83
+ view.input.compositionNode = null;
84
+ view.input.compositionID++;
111
85
  return true;
112
86
  },
113
87
  beforeinput (view, event) {
@@ -156,6 +130,38 @@ export function beforeInputPlugin(setCursorWrapper) {
156
130
  insertText(view, event.data);
157
131
  break;
158
132
  }
133
+ case "insertCompositionText":
134
+ {
135
+ const { tr } = view.state;
136
+ // There's always a range on insertCompositionText beforeinput events
137
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
138
+ const range = event.getTargetRanges()[0];
139
+ const start = view.posAtDOM(range.startContainer, range.startOffset);
140
+ const end = view.posAtDOM(range.endContainer, range.endOffset, 1);
141
+ if (view.state.doc.textBetween(start, end, "**", "*") === event.data) {
142
+ return;
143
+ }
144
+ if (event.data) {
145
+ if (compositionMarks) tr.ensureMarks(compositionMarks);
146
+ tr.insertText(event.data, start, end);
147
+ } else {
148
+ tr.delete(start, end);
149
+ }
150
+ // When we insert the text that corresponds to an ongoing composition,
151
+ // the relevant TextNodeView will pause re-rendering so that React doesn't
152
+ // clobber the composition in the DOM. This means that we have to wait for
153
+ // the browser to update the DOM itself before attempting to reconcile
154
+ // the selection, so we specifically defer pending effects to the next
155
+ // macro task
156
+ if (view instanceof ReactEditorView) {
157
+ view.deferPendingEffects = true;
158
+ }
159
+ view.dispatch(tr);
160
+ if (view instanceof ReactEditorView) {
161
+ view.deferPendingEffects = false;
162
+ }
163
+ break;
164
+ }
159
165
  case "deleteWordBackward":
160
166
  case "deleteHardLineBackward":
161
167
  case "deleteSoftLineBackward":
@@ -235,7 +235,9 @@ export class ViewDesc {
235
235
  prev = i ? this.children[i - 1] : null;
236
236
  if (!prev || prev.dom.parentNode == this.contentDOM) break;
237
237
  }
238
- if (prev && side && enter && !prev.border && !prev.domAtom) return prev.domFromPos(prev.size, side);
238
+ if (prev && side && enter && !prev.border && !prev.domAtom) {
239
+ return prev.domFromPos(prev.size, side);
240
+ }
239
241
  return {
240
242
  node: this.contentDOM,
241
243
  offset: prev ? domIndex(prev.dom) + 1 : 0
@@ -370,7 +372,9 @@ export class ViewDesc {
370
372
  const after = selRange.focusNode.childNodes[selRange.focusOffset];
371
373
  if (after && after.contentEditable == "false") force = true;
372
374
  }
373
- if (!(force || brKludge && browser.safari) && isEquivalentPosition(anchorDOM.node, anchorDOM.offset, selRange.anchorNode, selRange.anchorOffset) && isEquivalentPosition(headDOM.node, headDOM.offset, selRange.focusNode, selRange.focusOffset)) return;
375
+ if (!(force || brKludge && browser.safari) && isEquivalentPosition(anchorDOM.node, anchorDOM.offset, selRange.anchorNode, selRange.anchorOffset) && isEquivalentPosition(headDOM.node, headDOM.offset, selRange.focusNode, selRange.focusOffset)) {
376
+ return;
377
+ }
374
378
  // Selection.extend can be used to create an 'inverted' selection
375
379
  // (one where the focus is before the anchor), but not all
376
380
  // browsers support it yet.
@@ -645,7 +649,10 @@ export class TextViewDesc extends NodeViewDesc {
645
649
  skip: skip || true
646
650
  };
647
651
  }
648
- update(_node, _outerDeco, _innerDeco, _view) {
652
+ update(node, outerDeco, _innerDeco, _view) {
653
+ if (this.dirty == NODE_DIRTY || this.dirty != NOT_DIRTY && !this.inParent() || !node.sameMarkup(this.node)) return false;
654
+ this.updateOuterDeco(outerDeco);
655
+ this.node = node;
649
656
  this.dirty = NOT_DIRTY;
650
657
  return true;
651
658
  }