-
Notifications
You must be signed in to change notification settings - Fork 17
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Use prosemirror-view's selection syncing
- Loading branch information
1 parent
8a8a479
commit fb545aa
Showing
10 changed files
with
308 additions
and
299 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
Oops, something went wrong.