Skip to content

Commit

Permalink
Use prosemirror-view's selection syncing
Browse files Browse the repository at this point in the history
  • Loading branch information
smoores-dev committed Aug 12, 2023
1 parent 8a8a479 commit fb545aa
Show file tree
Hide file tree
Showing 10 changed files with 308 additions and 299 deletions.
66 changes: 39 additions & 27 deletions src/components/EditorView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import React, {
HTMLAttributes,
MutableRefObject,
RefAttributes,
useEffect,
useMemo,
useRef,
useState,
Expand All @@ -35,6 +36,7 @@ import {
endOfTextblock,
posAtCoords,
} from "../prosemirror-internal/domcoords.js";
import { DOMObserver } from "../prosemirror-internal/domobserver.js";
import { InputState } from "../prosemirror-internal/input.js";

import { DocNodeView } from "./DocNodeView.js";
Expand Down Expand Up @@ -116,15 +118,17 @@ export function EditorView(props: Props) {

// This is only safe to use in effects/layout effects or
// event handlers!
const editorViewAPI: EditorViewInternal = useMemo<EditorViewInternal>(
const editorViewAPI: EditorViewInternal = useMemo<EditorViewInternal>(() => {
// @ts-expect-error - EditorView API not fully implemented yet
() => ({
const api: EditorViewInternal = {
/* Start TODO */
dragging: null,
composing: false,
focus() {
/* */
},
// I do not know what this is or what it's for yet
cursorWrapper: editorViewRefInternal.current?.cursorWrapper ?? null,
/* End TODO */
_props: {
handleDOMEvents,
Expand All @@ -144,6 +148,10 @@ export function EditorView(props: Props) {
focused: editorViewRefInternal.current?.focused ?? false,
markCursor: editorViewRefInternal.current?.markCursor ?? null,
input: editorViewRefInternal.current?.input ?? new InputState(),
domObserver:
editorViewRefInternal.current?.domObserver ??
new DOMObserver(editorViewRefInternal),
lastSelectedViewDesc: editorViewRefInternal.current?.lastSelectedViewDesc,
get dom() {
if (!mountRef.current) {
throw new Error(
Expand Down Expand Up @@ -263,37 +271,41 @@ export function EditorView(props: Props) {
): boolean {
return endOfTextblock(this, state || this.state, dir);
},
}),
[
handleDOMEvents,
handleClick,
handleClickOn,
handleDoubleClick,
handleDoubleClickOn,
handleDrop,
handleKeyDown,
handleKeyPress,
handlePaste,
handleScrollToSelection,
handleTextInput,
handleTripleClick,
handleTripleClickOn,
editable,
state,
editableProp,
stateProp,
defaultState,
dispatchProp,
plugins,
]
);
};
api.dispatch = api.dispatch.bind(api);
return api;
}, [
handleDOMEvents,
handleClick,
handleClickOn,
handleDoubleClick,
handleDoubleClickOn,
handleDrop,
handleKeyDown,
handleKeyPress,
handlePaste,
handleScrollToSelection,
handleTextInput,
handleTripleClick,
handleTripleClickOn,
editable,
state,
editableProp,
stateProp,
defaultState,
dispatchProp,
plugins,
]);

editorViewRefInternal.current = editorViewAPI;

const editorViewRef =
editorViewRefInternal as MutableRefObject<EditorViewInternal>;

useSyncSelection(state, editorViewAPI.dispatch, posToDesc, domToDesc);
useEffect(() => {
editorViewRef.current.domObserver.connectSelection();
}, [editorViewRef]);
useSyncSelection(editorViewRef);
useContentEditable(editorViewRef);
usePluginViews(editorViewRef, plugins);

Expand Down
2 changes: 1 addition & 1 deletion src/contexts/EditorViewContext.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MutableRefObject, createContext } from "react";
import { createContext } from "react";

import { EditorViewInternal } from "../prosemirror-internal/EditorViewInternal.js";

Expand Down
111 changes: 11 additions & 100 deletions src/hooks/useSyncSelection.ts
Original file line number Diff line number Diff line change
@@ -1,108 +1,19 @@
import { EditorState, TextSelection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { MutableRefObject, useEffect } from "react";

import { ViewDesc } from "../descriptors/ViewDesc.js";
import { DOMNode } from "../prosemirror-internal/dom.js";
import { EditorViewInternal } from "../prosemirror-internal/EditorViewInternal.js";
import { selectionToDOM } from "../prosemirror-internal/selection.js";

export function useSyncSelection(
state: EditorState,
dispatchTransaction: EditorView["dispatch"],
posToDesc: MutableRefObject<Map<number, ViewDesc>>,
domToDesc: MutableRefObject<Map<DOMNode, ViewDesc>>
) {
export function useSyncSelection(view: MutableRefObject<EditorViewInternal>) {
useEffect(() => {
function onSelectionChange() {
const { doc, tr } = state;
const { domObserver } = view.current;
domObserver.connectSelection();

const domSelection = document.getSelection();
if (!domSelection) return;

const { anchorNode: initialAnchorNode, anchorOffset } = domSelection;
if (!initialAnchorNode) return;

let anchorNode = initialAnchorNode;
const nodes = new Set(domToDesc.current.keys());
while (!nodes.has(anchorNode)) {
const parentNode = anchorNode.parentNode;
if (!parentNode) return;
anchorNode = parentNode;
}

const anchorDesc = domToDesc.current.get(anchorNode);
if (!anchorDesc) return;

const $anchor = doc.resolve(anchorDesc.posAtStart + anchorOffset);

const { focusNode: initialHeadNode, focusOffset } = domSelection;
if (!initialHeadNode) return;

let headNode = initialHeadNode;
while (!nodes.has(headNode)) {
const parentNode = headNode.parentNode;
if (!parentNode) return;
headNode = parentNode;
}

const headDesc = domToDesc.current.get(headNode);
if (!headDesc) return;

const $head = doc.resolve(headDesc.posAtStart + focusOffset);

const selection = TextSelection.between($anchor, $head);
if (!state.selection.eq(selection)) {
tr.setSelection(selection);
dispatchTransaction(tr);
}
}

document.addEventListener("selectionchange", onSelectionChange);

return () => {
document.removeEventListener("selectionchange", onSelectionChange);
};
}, [dispatchTransaction, domToDesc, state]);
return () => domObserver.disconnectSelection();
}, [view]);

useEffect(() => {
const positions = Array.from(posToDesc.current.keys()).sort(
(a, b) => a - b
);

let anchorNodePos = 0;
for (const pos of positions) {
if (pos > state.selection.anchor) break;

anchorNodePos = pos;
}
let headNodePos = 0;
for (const pos of positions) {
if (pos > state.selection.head) break;

headNodePos = pos;
}

const anchorDesc = posToDesc.current.get(anchorNodePos);
const headDesc = posToDesc.current.get(headNodePos);
if (!anchorDesc || !headDesc) return;

// You can't actually put a selection inside of a <br> tag,
// but we use them to handle contenteditable issues with
// empty textblocks
const anchorNode =
anchorDesc.dom.nodeName === "BR"
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
anchorDesc.dom.parentNode!
: anchorDesc.dom;
const headNode =
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
headDesc.dom.nodeName === "BR" ? headDesc.dom.parentNode! : headDesc.dom;

const domSelection = document.getSelection();
domSelection?.setBaseAndExtent(
anchorNode,
state.selection.anchor - anchorNodePos,
headNode,
state.selection.head - headNodePos
);
}, [posToDesc, state]);
selectionToDOM(view.current);
// This is safe; we only update view.current when we re-render
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view.current]);
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"use client";

export { ProseMirror } from "./components/ProseMirror.js";
export { EditorView } from "./components/EditorView.js";
export { EditorProvider } from "./contexts/EditorContext.js";
export { LayoutGroup, useLayoutGroupEffect } from "./contexts/LayoutGroup.js";
export { useEditorEffect } from "./hooks/useEditorEffect.js";
export { useEditorEventCallback } from "./hooks/useEditorEventCallback.js";
export { useEditorEventListener } from "./hooks/useEditorEventListener.js";
export { useEditorState } from "./hooks/useEditorState.js";
export { useEditorView } from "./hooks/useEditorView.js";
export { useView } from "./hooks/useView.js";
export { useNodeViews } from "./hooks/useNodeViews.js";

export type {
Expand Down
4 changes: 2 additions & 2 deletions src/prosemirror-internal/DecorationInternal.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Node } from "prosemirror-model";
import { Decoration, DecorationSource } from "prosemirror-view";
import { DecorationType } from "../decorations/DecorationType";
import { ReactWidgetType } from "../decorations/ReactWidgetType";
import { DecorationType } from "../decorations/DecorationType.js";
import { ReactWidgetType } from "../decorations/ReactWidgetType.js";

export interface NonWidgetType extends DecorationType {
attrs: {
Expand Down
11 changes: 8 additions & 3 deletions src/prosemirror-internal/EditorViewInternal.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { Mark } from "prosemirror-model";
import { EditorView as EditorViewT } from "prosemirror-view";

import { NodeViewDesc } from "../descriptors/ViewDesc.js";
import { DOMSelection, DOMSelectionRange } from "./dom.js";
import { NodeViewDesc, ViewDesc } from "../descriptors/ViewDesc.js";
import { DecorationInternal } from "./DecorationInternal.js";
import { DOMNode, DOMSelection, DOMSelectionRange } from "./dom.js";
import { DOMObserver } from "./domobserver.js";
import { InputState } from "./input.js";

export interface EditorViewInternal extends EditorViewT {
Expand All @@ -11,5 +13,8 @@ export interface EditorViewInternal extends EditorViewT {
focused: boolean;
input: InputState;
markCursor: readonly Mark[] | null;
domSelectionRange: () => DOMSelectionRange
domSelectionRange: () => DOMSelectionRange;
domObserver: DOMObserver;
cursorWrapper: {dom: DOMNode, deco: DecorationInternal} | null;
lastSelectedViewDesc: ViewDesc | undefined
};
2 changes: 1 addition & 1 deletion src/prosemirror-internal/domcoords.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import {EditorState} from "prosemirror-state"
import {nodeSize, textRange, parentNode, caretFromPoint} from "./dom"
import {nodeSize, textRange, parentNode, caretFromPoint} from "./dom.js"
import * as browser from "./browser.js"
import {EditorView} from "prosemirror-view"

Expand Down
89 changes: 89 additions & 0 deletions src/prosemirror-internal/domobserver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import * as browser from "./browser.js"
import {isEquivalentPosition, DOMSelectionRange} from "./dom.js"
import {hasFocusAndSelection, selectionFromDOM} from "./selection.js"
import {EditorViewInternal as EditorView} from "./EditorViewInternal.js"
import { MutableRefObject } from "react"

class SelectionState {
anchorNode: Node | null = null
anchorOffset: number = 0
focusNode: Node | null = null
focusOffset: number = 0

set(sel: DOMSelectionRange) {
this.anchorNode = sel.anchorNode; this.anchorOffset = sel.anchorOffset
this.focusNode = sel.focusNode; this.focusOffset = sel.focusOffset
}

clear() {
this.anchorNode = this.focusNode = null
}

eq(sel: DOMSelectionRange) {
return sel.anchorNode == this.anchorNode && sel.anchorOffset == this.anchorOffset &&
sel.focusNode == this.focusNode && sel.focusOffset == this.focusOffset
}
}

export class DOMObserver {
queue: MutationRecord[] = []
flushingSoon = -1
observer: MutationObserver | null = null
currentSelection = new SelectionState
onCharData: ((e: Event) => void) | null = null
suppressingSelectionUpdates = false

constructor(
readonly view: MutableRefObject<EditorView | null>,
) {
this.onSelectionChange = this.onSelectionChange.bind(this)
}

flushSoon() {
if (this.flushingSoon < 0)
this.flushingSoon = window.setTimeout(() => { this.flushingSoon = -1; this.flush() }, 20)
}

connectSelection() {
this.view.current!.dom.ownerDocument.addEventListener("selectionchange", this.onSelectionChange)
}

disconnectSelection() {
this.view.current!.dom.ownerDocument.removeEventListener("selectionchange", this.onSelectionChange)
}

onSelectionChange() {
if (!hasFocusAndSelection(this.view.current!)) return
// Deletions on IE11 fire their events in the wrong order, giving
// us a selection change event before the DOM changes are
// reported.
if (browser.ie && browser.ie_version <= 11 && !this.view.current!.state.selection.empty) {
let sel = this.view.current!.domSelectionRange()
// Selection.isCollapsed isn't reliable on IE
if (sel.focusNode && isEquivalentPosition(sel.focusNode, sel.focusOffset, sel.anchorNode!, sel.anchorOffset))
return this.flushSoon()
}
this.flush()
}

setCurSelection() {
this.currentSelection.set(this.view.current!.domSelectionRange())
}

pendingRecords() {
if (this.observer) for (let mut of this.observer.takeRecords()) this.queue.push(mut)
return this.queue
}

flush() {
let {view} = this
if (!view.current!.docView || this.flushingSoon > -1) return
let mutations = this.pendingRecords()
if (mutations.length) this.queue = []

const newSel = selectionFromDOM(view.current!)
if (newSel) view.current!.dispatch(view.current!.state.tr.setSelection(newSel))
let sel = view.current!.domSelectionRange()
this.currentSelection.set(sel)
}
}
Loading

0 comments on commit fb545aa

Please sign in to comment.