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

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 (30) hide show
  1. package/dist/cjs/ReactEditorView.js +0 -2
  2. package/dist/cjs/components/ChildNodeViews.js +7 -12
  3. package/dist/cjs/components/CursorWrapper.js +9 -8
  4. package/dist/cjs/components/TextNodeView.js +38 -112
  5. package/dist/cjs/hooks/useEditor.js +5 -13
  6. package/dist/cjs/hooks/useNodeViewDescription.js +15 -38
  7. package/dist/cjs/plugins/beforeInputPlugin.js +41 -47
  8. package/dist/cjs/tiptap/hooks/useEditor.js +349 -0
  9. package/dist/cjs/tiptap/hooks/useTiptapEditor.js +2 -2
  10. package/dist/cjs/tiptap/utils/ssrJSDOMPatch.js +59 -0
  11. package/dist/cjs/viewdesc.js +3 -10
  12. package/dist/esm/ReactEditorView.js +0 -2
  13. package/dist/esm/components/ChildNodeViews.js +7 -12
  14. package/dist/esm/components/CursorWrapper.js +10 -9
  15. package/dist/esm/components/TextNodeView.js +38 -112
  16. package/dist/esm/hooks/useEditor.js +5 -13
  17. package/dist/esm/hooks/useNodeViewDescription.js +16 -39
  18. package/dist/esm/plugins/beforeInputPlugin.js +41 -47
  19. package/dist/esm/tiptap/hooks/useEditor.js +339 -0
  20. package/dist/esm/tiptap/hooks/useTiptapEditor.js +1 -1
  21. package/dist/esm/tiptap/utils/ssrJSDOMPatch.js +56 -0
  22. package/dist/esm/viewdesc.js +3 -10
  23. package/dist/tsconfig.tsbuildinfo +1 -1
  24. package/dist/types/ReactEditorView.d.ts +0 -4
  25. package/dist/types/components/TextNodeView.d.ts +4 -14
  26. package/dist/types/tiptap/hooks/useEditor.d.ts +38 -0
  27. package/dist/types/tiptap/hooks/useTiptapEditor.d.ts +1 -1
  28. package/dist/types/tiptap/utils/ssrJSDOMPatch.d.ts +1 -0
  29. package/dist/types/viewdesc.d.ts +1 -1
  30. package/package.json +1 -2
@@ -1,7 +1,5 @@
1
- import { TextSelection } from "prosemirror-state";
2
1
  import { DecorationSet } from "prosemirror-view";
3
2
  import { Component } from "react";
4
- import { ReactEditorView } from "../ReactEditorView.js";
5
3
  import { findDOMNode } from "../findDOMNode.js";
6
4
  import { CompositionViewDesc, TextViewDesc, sortViewDescs } from "../viewdesc.js";
7
5
  import { wrapInDeco } from "./ChildNodeViews.js";
@@ -30,144 +28,72 @@ function shallowEqual(objA, objB) {
30
28
  export class TextNodeView extends Component {
31
29
  viewDescRef = null;
32
30
  renderRef = null;
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() {
31
+ updateEffect() {
60
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
61
35
  const dom = findDOMNode(this);
62
- if (!dom && !view.composing) return null;
63
- let textNode = dom;
64
- while(textNode?.firstChild){
65
- textNode = textNode.firstChild;
66
- }
67
- if (!(textNode instanceof Text)) {
68
- textNode = null;
69
- }
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.
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.
76
44
  // Passing a valid element here just so that the
77
45
  // 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);
46
+ document.createElement("div"), document.createTextNode(node.text ?? ""), node.text ?? "");
47
+ return;
82
48
  }
83
- siblingsRef.current.push(viewDesc);
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;
49
+ let textNode = dom;
50
+ while(textNode.firstChild){
51
+ textNode = textNode.firstChild;
94
52
  }
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);
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;
109
64
  }
110
- }
111
- updateEffect() {
112
- if (!this.update()) {
113
- this.destroy();
114
- this.viewDescRef = this.create();
65
+ if (!siblingsRef.current.includes(this.viewDescRef)) {
66
+ siblingsRef.current.push(this.viewDescRef);
115
67
  }
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
- });
68
+ siblingsRef.current.sort(sortViewDescs);
132
69
  }
133
70
  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
- }
140
71
  return !shallowEqual(this.props, nextProps);
141
72
  }
142
73
  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);
149
74
  this.updateEffect();
150
75
  }
151
76
  componentDidUpdate() {
152
77
  this.updateEffect();
153
78
  }
154
79
  componentWillUnmount() {
155
- const { unregisterEventListener } = this.props;
156
- unregisterEventListener("compositionend", this.handleCompositionEnd);
157
- this.destroy();
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
+ }
158
86
  }
159
87
  render() {
160
- const { node, decorations } = this.props;
88
+ const { view, getPos, node, decorations } = this.props;
161
89
  // During a composition, it's crucial that we don't try to
162
90
  // update the DOM that the user is working in. If there's
163
91
  // an active composition and the selection is in this node,
164
92
  // we freeze the DOM of this element so that it doesn't
165
93
  // interrupt the composition
166
- if (this.shouldProtect(this.props)) {
167
- this.wasProtecting = true;
94
+ if (view.composing && view.state.selection.from >= getPos() && view.state.selection.from <= getPos() + node.nodeSize) {
168
95
  return this.renderRef;
169
96
  }
170
- this.wasProtecting = false;
171
97
  this.renderRef = decorations.reduce(wrapInDeco, node.text);
172
98
  return this.renderRef;
173
99
  }
@@ -101,19 +101,11 @@ let didWarnValueDefaultValue = false;
101
101
  // running effects. Running effects will reattach selection
102
102
  // change listeners if the EditorView has been destroyed.
103
103
  if (view instanceof ReactEditorView && !view.isDestroyed) {
104
- if (view.deferPendingEffects) {
105
- setTimeout(()=>{
106
- flushSyncRef.current = false;
107
- view.commitPendingEffects();
108
- flushSyncRef.current = true;
109
- });
110
- } else {
111
- // Plugins might dispatch transactions from their
112
- // view update lifecycle hooks
113
- flushSyncRef.current = false;
114
- view.commitPendingEffects();
115
- flushSyncRef.current = true;
116
- }
104
+ // Plugins might dispatch transactions from their
105
+ // view update lifecycle hooks
106
+ flushSyncRef.current = false;
107
+ view.commitPendingEffects();
108
+ flushSyncRef.current = true;
117
109
  }
118
110
  });
119
111
  view.update(directEditorProps);
@@ -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 { CompositionViewDesc, ReactNodeViewDesc, 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) {
@@ -133,46 +131,25 @@ export function useNodeViewDescription(getDOM, getContentDOM, constructor, props
133
131
  children.sort(sortViewDescs);
134
132
  for (const child of children){
135
133
  child.parent = viewDesc;
136
- }
137
- setTimeout(()=>{
138
134
  // Because TextNodeViews can't locate the DOM nodes
139
135
  // for compositions, we need to override them here
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
- }
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;
159
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);
160
151
  }
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
- });
152
+ }
176
153
  });
177
154
  const childContextValue = useMemo(()=>({
178
155
  parentRef: viewDescRef,
@@ -1,6 +1,5 @@
1
1
  import { Fragment, Slice } from "prosemirror-model";
2
2
  import { Plugin, TextSelection } from "prosemirror-state";
3
- import { ReactEditorView } from "../ReactEditorView.js";
4
3
  import { CursorWrapper } from "../components/CursorWrapper.js";
5
4
  import { widget } from "../decorations/ReactWidgetType.js";
6
5
  function insertText(view, eventData) {
@@ -47,23 +46,31 @@ function handleGapCursorComposition(view) {
47
46
  }
48
47
  export function beforeInputPlugin(setCursorWrapper) {
49
48
  let compositionMarks = null;
49
+ let precompositionSnapshot = null;
50
50
  return new Plugin({
51
51
  props: {
52
52
  handleDOMEvents: {
53
53
  compositionstart (view) {
54
- if (!(view instanceof ReactEditorView)) return false;
55
- compositionMarks = view.state.storedMarks;
54
+ compositionMarks = view.state.storedMarks ?? view.state.selection.$from.marks();
56
55
  view.dispatch(view.state.tr.deleteSelection());
57
56
  handleGapCursorComposition(view);
58
57
  const { state } = view;
59
- // const $pos = state.selection.$from;
60
- if (compositionMarks?.length) {
58
+ const $pos = state.selection.$from;
59
+ if (compositionMarks) {
61
60
  setCursorWrapper(widget(state.selection.from, CursorWrapper, {
62
61
  key: "cursor-wrapper",
63
- marks: compositionMarks,
64
- side: 1
62
+ marks: compositionMarks
65
63
  }));
66
64
  }
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
67
74
  view.input.composing = true;
68
75
  return true;
69
76
  },
@@ -71,17 +78,36 @@ export function beforeInputPlugin(setCursorWrapper) {
71
78
  return true;
72
79
  },
73
80
  compositionend (view, event) {
74
- if (!(view instanceof ReactEditorView)) return false;
75
- if (!view.composing) return false;
81
+ // @ts-expect-error Internal property - input
76
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
+ }
77
108
  compositionMarks = null;
109
+ precompositionSnapshot = null;
78
110
  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++;
85
111
  return true;
86
112
  },
87
113
  beforeinput (view, event) {
@@ -130,38 +156,6 @@ export function beforeInputPlugin(setCursorWrapper) {
130
156
  insertText(view, event.data);
131
157
  break;
132
158
  }
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
- }
165
159
  case "deleteWordBackward":
166
160
  case "deleteHardLineBackward":
167
161
  case "deleteSoftLineBackward":