Skip to content

Commit

Permalink
Switch back to beforeinput listener from dom observer
Browse files Browse the repository at this point in the history
  • Loading branch information
smoores-dev committed Nov 14, 2023
1 parent b79d41b commit 9a17f30
Show file tree
Hide file tree
Showing 15 changed files with 582 additions and 71 deletions.
53 changes: 53 additions & 0 deletions docs/assets/index-9562f84a.js

Large diffs are not rendered by default.

53 changes: 0 additions & 53 deletions docs/assets/index-bf7cdcd7.js

This file was deleted.

2 changes: 1 addition & 1 deletion docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React-ProseMirror Demo</title>
<script type="module" crossorigin src="/react-prosemirror/assets/index-bf7cdcd7.js"></script>
<script type="module" crossorigin src="/react-prosemirror/assets/index-9562f84a.js"></script>
<link rel="stylesheet" href="/react-prosemirror/assets/index-17fff6c1.css">
</head>
<body>
Expand Down
2 changes: 2 additions & 0 deletions src/components/ProseMirror.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { LayoutGroup } from "../contexts/LayoutGroup.js";
import { NodeViewContext } from "../contexts/NodeViewContext.js";
import { computeDocDeco } from "../decorations/computeDocDeco.js";
import { viewDecorations } from "../decorations/viewDecorations.js";
import { useBeforeInput } from "../hooks/useBeforeInput.js";
import { useComponentEventListeners } from "../hooks/useComponentEventListeners.js";
import { useEditorView } from "../hooks/useEditorView.js";
import { usePluginViews } from "../hooks/usePluginViews.js";
Expand Down Expand Up @@ -137,6 +138,7 @@ export function ProseMirror({

const viewPlugins = useMemo(() => props.plugins ?? [], [props.plugins]);

useBeforeInput(editorView);
useSyncSelection(editorView);
usePluginViews(editorView, editorState, viewPlugins);

Expand Down
2 changes: 1 addition & 1 deletion src/decorations/ReactWidgetType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Decoration } from "prosemirror-view";
import { ForwardRefExoticComponent, RefAttributes } from "react";

import { WidgetViewComponentProps } from "../components/WidgetViewComponentProps.js";
import { DOMNode } from "../viewdesc.js";
import { DOMNode } from "../dom.js";

function compareObjs(
a: { [prop: string]: unknown },
Expand Down
162 changes: 162 additions & 0 deletions src/dom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
export type DOMNode = InstanceType<typeof window.Node>;
export type DOMSelection = InstanceType<typeof window.Selection>;
export type DOMSelectionRange = {
focusNode: DOMNode | null;
focusOffset: number;
anchorNode: DOMNode | null;
anchorOffset: number;
};

export const domIndex = function (node: Node) {
for (let index = 0; ; index++) {
node = node.previousSibling!;
if (!node) return index;
}
};

export const parentNode = function (node: Node): Node | null {
const parent = (node as HTMLSlotElement).assignedSlot || node.parentNode;
return parent && parent.nodeType == 11 ? (parent as ShadowRoot).host : parent;
};

let reusedRange: Range | null = null;

// Note that this will always return the same range, because DOM range
// objects are every expensive, and keep slowing down subsequent DOM
// updates, for some reason.
export const textRange = function (node: Text, from?: number, to?: number) {
const range = reusedRange || (reusedRange = document.createRange());
range.setEnd(node, to == null ? node.nodeValue!.length : to);
range.setStart(node, from || 0);
return range;
};

// Scans forward and backward through DOM positions equivalent to the
// given one to see if the two are in the same place (i.e. after a
// text node vs at the end of that text node)
export const isEquivalentPosition = function (
node: Node,
off: number,
targetNode: Node,
targetOff: number
) {
return (
targetNode &&
(scanFor(node, off, targetNode, targetOff, -1) ||
scanFor(node, off, targetNode, targetOff, 1))
);
};

const atomElements = /^(img|br|input|textarea|hr)$/i;

function scanFor(
node: Node,
off: number,
targetNode: Node,
targetOff: number,
dir: number
) {
for (;;) {
if (node == targetNode && off == targetOff) return true;
if (off == (dir < 0 ? 0 : nodeSize(node))) {
const parent = node.parentNode;
if (
!parent ||
parent.nodeType != 1 ||
hasBlockDesc(node) ||
atomElements.test(node.nodeName) ||
(node as HTMLElement).contentEditable == "false"
)
return false;
off = domIndex(node) + (dir < 0 ? 0 : 1);
node = parent;
} else if (node.nodeType == 1) {
node = node.childNodes[off + (dir < 0 ? -1 : 0)]!;
if ((node as HTMLElement).contentEditable == "false") return false;
off = dir < 0 ? nodeSize(node) : 0;
} else {
return false;
}
}
}

export function nodeSize(node: Node) {
return node.nodeType == 3 ? node.nodeValue!.length : node.childNodes.length;
}

export function isOnEdge(node: Node, offset: number, parent: Node) {
for (
let atStart = offset == 0, atEnd = offset == nodeSize(node);
atStart || atEnd;

) {
if (node == parent) return true;
const index = domIndex(node);
node = node.parentNode!;
if (!node) return false;
atStart = atStart && index == 0;
atEnd = atEnd && index == nodeSize(node);
}
return false;
}

export function hasBlockDesc(dom: Node) {
let desc;
for (let cur: Node | null = dom; cur; cur = cur.parentNode)
if ((desc = cur.pmViewDesc)) break;
return (
desc &&
desc.node &&
desc.node.isBlock &&
(desc.dom == dom || desc.contentDOM == dom)
);
}

// Work around Chrome issue https://bugs.chromium.org/p/chromium/issues/detail?id=447523
// (isCollapsed inappropriately returns true in shadow dom)
export const selectionCollapsed = function (domSel: DOMSelectionRange) {
return (
domSel.focusNode &&
isEquivalentPosition(
domSel.focusNode,
domSel.focusOffset,
domSel.anchorNode!,
domSel.anchorOffset
)
);
};

export function keyEvent(keyCode: number, key: string) {
const event = document.createEvent("Event") as KeyboardEvent;
event.initEvent("keydown", true, true);
(event as any).keyCode = keyCode;
(event as any).key = (event as any).code = key;
return event;
}

export function deepActiveElement(doc: Document) {
let elt = doc.activeElement;
while (elt && elt.shadowRoot) elt = elt.shadowRoot.activeElement;
return elt;
}

export function caretFromPoint(
doc: Document,
x: number,
y: number
): { node: Node; offset: number } | undefined {
if ((doc as any).caretPositionFromPoint) {
try {
// Firefox throws for this call in hard-to-predict circumstances (#994)
const pos = (doc as any).caretPositionFromPoint(x, y);
if (pos) return { node: pos.offsetNode, offset: pos.offset };
} catch (_) {
// pass
}
}
if (doc.caretRangeFromPoint) {
const range = doc.caretRangeFromPoint(x, y);
if (range) return { node: range.startContainer, offset: range.startOffset };
}
return;
}
76 changes: 76 additions & 0 deletions src/hooks/useBeforeInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { EditorView } from "prosemirror-view";
import { useEffect } from "react";

export function useBeforeInput(view: EditorView | null) {
useEffect(() => {
if (!view) return;

function onBeforeInput(event: InputEvent) {
if (!view) return;

switch (event.inputType) {
case "insertText": {
if (event.data === null) return;

if (
view.someProp("handleTextInput")?.(
view,
view.state.selection.from,
view.state.selection.to,
event.data
)
) {
event.preventDefault();
break;
}

const { tr } = view.state;
tr.insertText(event.data);
view.dispatch(tr);
event.preventDefault();
break;
}
case "deleteContentBackward": {
const { tr, doc, selection } = view.state;
const from = selection.empty ? selection.from - 1 : selection.from;
const to = selection.to;
const storedMarks = doc.resolve(from).marksAcross(doc.resolve(to));

tr.delete(from, to).setStoredMarks(storedMarks);

view.dispatch(tr);
event.preventDefault();
break;
}
case "deleteContentForward": {
const { tr, doc, selection } = view.state;
const from = selection.from;
const to = selection.empty ? selection.to + 1 : selection.to;
const storedMarks = doc.resolve(from).marksAcross(doc.resolve(to));

tr.delete(from, to).setStoredMarks(storedMarks);
event.preventDefault();
break;
}
case "deleteContent": {
const { tr, doc, selection } = view.state;
const storedMarks = doc
.resolve(selection.from)
.marksAcross(doc.resolve(selection.to));

tr.delete(selection.from, selection.to).setStoredMarks(storedMarks);
view.dispatch(tr);
event.preventDefault();
break;
}
default: {
event.preventDefault();
break;
}
}
}

view.dom.addEventListener("beforeinput", onBeforeInput);
return () => view.dom.removeEventListener("beforeinput", onBeforeInput);
}, [view]);
}
8 changes: 8 additions & 0 deletions src/hooks/useEditorView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { DirectEditorProps, EditorView } from "prosemirror-view";
import { useLayoutEffect, useState } from "react";
import { unstable_batchedUpdates as batch } from "react-dom";

import { SelectionDOMObserver } from "../selection/SelectionDOMObserver.js";
import { NodeViewDesc } from "../viewdesc.js";

import { useForceUpdate } from "./useForceUpdate.js";
Expand Down Expand Up @@ -32,6 +33,13 @@ class ReactEditorView extends EditorView {
this._props = props;
this.state = props.state;

// @ts-expect-error We're making use of knowledge of internal attributes here
this.domObserver.stop();
// @ts-expect-error We're making use of knowledge of internal attributes here
this.domObserver = new SelectionDOMObserver(this);
// @ts-expect-error We're making use of knowledge of internal attributes here
this.domObserver.start();

// updateCursorWrapper(this);

// Destroy the DOM created by the default
Expand Down
11 changes: 0 additions & 11 deletions src/hooks/useSyncSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,6 @@ import { useEffect } from "react";
import { selectionToDOM } from "../selection/selectionToDOM.js";

export function useSyncSelection(view: EditorView | null) {
useEffect(() => {
if (!view) return;

// We don't have access to view.domObserver types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { domObserver } = view as any;
domObserver.connectSelection();

return () => domObserver.disconnectSelection();
}, [view]);

useEffect(() => {
if (!view?.state) return;

Expand Down
Loading

0 comments on commit 9a17f30

Please sign in to comment.