Skip to content

Commit

Permalink
Distinguish between DOM Selection objects and selection ranges
Browse files Browse the repository at this point in the history
FIX: Work around the five-year-old Safari bug where it won't accurately report
the selection inside shadow roots, which would break ProseMirror when put
in shadow DOM.

Issue #142
Issue ProseMirror/prosemirror#476
  • Loading branch information
marijnh committed Sep 30, 2022
1 parent 08c1e27 commit 64a96e5
Show file tree
Hide file tree
Showing 9 changed files with 95 additions and 107 deletions.
17 changes: 9 additions & 8 deletions src/capturekeys.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {Selection, NodeSelection, TextSelection, AllSelection, EditorState} from "prosemirror-state"
import {EditorView} from "./index"
import * as browser from "./browser"
import {domIndex, selectionCollapsed, DOMSelection} from "./dom"
import {domIndex, selectionCollapsed} from "./dom"
import {selectionToDOM} from "./selection"

function moveSelectionBlock(state: EditorState, dir: number) {
Expand Down Expand Up @@ -62,7 +62,7 @@ function isIgnorable(dom: Node) {
// Make sure the cursor isn't directly after one or more ignored
// nodes, which will confuse the browser's cursor motion logic.
function skipIgnoredNodesLeft(view: EditorView) {
let sel = view.domSelection()
let sel = view.domSelectionRange()
let node = sel.focusNode!, offset = sel.focusOffset
if (!node) return
let moveNode, moveOffset: number | undefined, force = false
Expand Down Expand Up @@ -103,14 +103,14 @@ function skipIgnoredNodesLeft(view: EditorView) {
}
}
}
if (force) setSelFocus(view, sel, node, offset)
else if (moveNode) setSelFocus(view, sel, moveNode, moveOffset!)
if (force) setSelFocus(view, node, offset)
else if (moveNode) setSelFocus(view, moveNode, moveOffset!)
}

// Make sure the cursor isn't directly before one or more ignored
// nodes.
function skipIgnoredNodesRight(view: EditorView) {
let sel = view.domSelection()
let sel = view.domSelectionRange()
let node = sel.focusNode!, offset = sel.focusOffset
if (!node) return
let len = nodeLen(node)
Expand Down Expand Up @@ -144,15 +144,16 @@ function skipIgnoredNodesRight(view: EditorView) {
}
}
}
if (moveNode) setSelFocus(view, sel, moveNode, moveOffset!)
if (moveNode) setSelFocus(view, moveNode, moveOffset!)
}

function isBlockNode(dom: Node) {
let desc = dom.pmViewDesc
return desc && desc.node && desc.node.isBlock
}

function setSelFocus(view: EditorView, sel: DOMSelection, node: Node, offset: number) {
function setSelFocus(view: EditorView, node: Node, offset: number) {
let sel = view.domSelection()
if (selectionCollapsed(sel)) {
let range = document.createRange()
range.setEnd(node, offset)
Expand Down Expand Up @@ -222,7 +223,7 @@ function switchEditable(view: EditorView, node: HTMLElement, state: string) {
// after it
function safariDownArrowBug(view: EditorView) {
if (!browser.safari || view.state.selection.$head.parentOffset > 0) return false
let {focusNode, focusOffset} = view.domSelection()
let {focusNode, focusOffset} = view.domSelectionRange()
if (focusNode && focusNode.nodeType == 1 && focusOffset == 0 &&
focusNode.firstChild && (focusNode.firstChild as HTMLElement).contentEditable == "false") {
let child = focusNode.firstChild as HTMLElement
Expand Down
16 changes: 8 additions & 8 deletions src/dom.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import * as browser from "./browser"

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 (var index = 0;; index++) {
Expand All @@ -10,7 +12,7 @@ export const domIndex = function(node: Node) {
}
}

export const parentNode = function(node: Node) {
export const parentNode = function(node: Node): Node | null {
let parent = (node as HTMLSlotElement).assignedSlot || node.parentNode
return parent && parent.nodeType == 11 ? (parent as ShadowRoot).host : parent
}
Expand Down Expand Up @@ -80,11 +82,9 @@ function hasBlockDesc(dom: Node) {

// 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: Selection) {
let collapsed = domSel.isCollapsed
if (collapsed && browser.chrome && domSel.rangeCount && !domSel.getRangeAt(0).collapsed)
collapsed = false
return collapsed
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) {
Expand Down
2 changes: 1 addition & 1 deletion src/domchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {EditorView} from "./index"
function parseBetween(view: EditorView, from_: number, to_: number) {
let {node: parent, fromOffset, toOffset, from, to} = view.docView.parseRange(from_, to_)

let domSel = view.domSelection()
let domSel = view.domSelectionRange()
let find: {node: DOMNode, offset: number, pos?: number}[] | undefined
let anchor = domSel.anchorNode
if (anchor && view.dom.contains(anchor.nodeType == 1 ? anchor : anchor.parentNode)) {
Expand Down
13 changes: 8 additions & 5 deletions src/domcoords.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,15 +452,18 @@ function endOfTextblockHorizontal(view: EditorView, state: EditorState, dir: "le
// one character, and see if that moves the cursor out of the
// textblock (or doesn't move it at all, when at the start/end of
// the document).
let oldRange = sel.getRangeAt(0), oldNode = sel.focusNode, oldOff = sel.focusOffset
let {focusNode: oldNode, focusOffset: oldOff, anchorNode, anchorOffset} = view.domSelectionRange()
let oldBidiLevel = (sel as any).caretBidiLevel // Only for Firefox
;(sel as any).modify("move", dir, "character")
let parentDOM = $head.depth ? view.docView.domAfterPos($head.before()) : view.dom
let result = !parentDOM.contains(sel.focusNode!.nodeType == 1 ? sel.focusNode : sel.focusNode!.parentNode) ||
(oldNode == sel.focusNode && oldOff == sel.focusOffset)
let {focusNode: newNode, focusOffset: newOff} = view.domSelectionRange()
let result = newNode && !parentDOM.contains(newNode.nodeType == 1 ? newNode : newNode.parentNode) ||
(oldNode == newNode && oldOff == newOff)
// Restore the previous selection
sel.removeAllRanges()
sel.addRange(oldRange)
try {
sel.collapse(anchorNode, anchorOffset)
if (oldNode && (oldNode != anchorNode || oldOff != anchorOffset) && sel.extend) sel.extend(oldNode, oldOff)
} catch (_) {}
if (oldBidiLevel != null) (sel as any).caretBidiLevel = oldBidiLevel
return result
})
Expand Down
58 changes: 47 additions & 11 deletions src/domobserver.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {Selection} from "prosemirror-state"
import * as browser from "./browser"
import {domIndex, isEquivalentPosition, selectionCollapsed, DOMSelection} from "./dom"
import {domIndex, isEquivalentPosition, selectionCollapsed, parentNode, DOMSelectionRange, DOMNode} from "./dom"
import {hasFocusAndSelection, selectionToDOM, selectionFromDOM} from "./selection"
import {EditorView} from "./index"

Expand All @@ -21,7 +21,7 @@ class SelectionState {
focusNode: Node | null = null
focusOffset: number = 0

set(sel: DOMSelection) {
set(sel: DOMSelectionRange) {
this.anchorNode = sel.anchorNode; this.anchorOffset = sel.anchorOffset
this.focusNode = sel.focusNode; this.focusOffset = sel.focusOffset
}
Expand All @@ -30,7 +30,7 @@ class SelectionState {
this.anchorNode = this.focusNode = null
}

eq(sel: DOMSelection) {
eq(sel: DOMSelectionRange) {
return sel.anchorNode == this.anchorNode && sel.anchorOffset == this.anchorOffset &&
sel.focusNode == this.focusNode && sel.focusOffset == this.focusOffset
}
Expand Down Expand Up @@ -127,7 +127,7 @@ export class DOMObserver {
// us a selection change event before the DOM changes are
// reported.
if (browser.ie && browser.ie_version <= 11 && !this.view.state.selection.empty) {
let sel = this.view.domSelection()
let sel = this.view.domSelectionRange()
// Selection.isCollapsed isn't reliable on IE
if (sel.focusNode && isEquivalentPosition(sel.focusNode, sel.focusOffset, sel.anchorNode!, sel.anchorOffset))
return this.flushSoon()
Expand All @@ -136,16 +136,21 @@ export class DOMObserver {
}

setCurSelection() {
this.currentSelection.set(this.view.domSelection())
this.currentSelection.set(this.view.domSelectionRange())
}

ignoreSelectionChange(sel: DOMSelection) {
if (sel.rangeCount == 0) return true
let container = sel.getRangeAt(0).commonAncestorContainer
let desc = this.view.docView.nearestDesc(container)
ignoreSelectionChange(sel: DOMSelectionRange) {
if (!sel.focusNode) return true
let ancestors: Set<Node> = new Set, container: DOMNode | undefined
for (let scan: DOMNode | null = sel.focusNode; scan; scan = parentNode(scan)) ancestors.add(scan)
for (let scan = sel.anchorNode; scan; scan = parentNode(scan)) if (ancestors.has(scan)) {
container = scan
break
}
let desc = container && this.view.docView.nearestDesc(container)
if (desc && desc.ignoreMutation({
type: "selection",
target: container.nodeType == 3 ? container.parentNode : container
target: container!.nodeType == 3 ? container!.parentNode : container
} as any)) {
this.setCurSelection()
return true
Expand All @@ -161,7 +166,7 @@ export class DOMObserver {
this.queue.length = 0
}

let sel = view.domSelection()
let sel = view.domSelectionRange()
let newSel = !this.suppressingSelectionUpdates && !this.currentSelection.eq(sel) && hasFocusAndSelection(view) && !this.ignoreSelectionChange(sel)

let from = -1, to = -1, typeOver = false, added: Node[] = []
Expand Down Expand Up @@ -270,3 +275,34 @@ function checkCSS(view: EditorView) {
cssCheckWarned = true
}
}

// Used to work around a Safari Selection/shadow DOM bug
// Based on https://github.com/codemirror/dev/issues/414 fix
export function safariShadowSelectionRange(view: EditorView): DOMSelectionRange {
let found: StaticRange | undefined
function read(event: InputEvent) {
event.preventDefault()
event.stopImmediatePropagation()
found = event.getTargetRanges()[0]
}

// Because Safari (at least in 2018-2022) doesn't provide regular
// access to the selection inside a shadowRoot, we have to perform a
// ridiculous hack to get at it—using `execCommand` to trigger a
// `beforeInput` event so that we can read the target range from the
// event.
view.dom.addEventListener("beforeinput", read, true)
document.execCommand("indent")
view.dom.removeEventListener("beforeinput", read, true)

let anchorNode = found!.startContainer, anchorOffset = found!.startOffset
let focusNode = found!.endContainer, focusOffset = found!.endOffset

let currentAnchor = view.domAtPos(view.state.selection.anchor)
// Since such a range doesn't distinguish between anchor and head,
// use a heuristic that flips it around if its end matches the
// current anchor.
if (isEquivalentPosition(currentAnchor.node, currentAnchor.offset, focusNode, focusOffset))
[anchorNode, anchorOffset, focusNode, focusOffset] = [focusNode, focusOffset, anchorNode, anchorOffset]
return {anchorNode, anchorOffset, focusNode, focusOffset}
}
76 changes: 12 additions & 64 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import {docViewDesc, ViewDesc, NodeView, NodeViewDesc} from "./viewdesc"
import {initInput, destroyInput, dispatchEvent, ensureListeners, clearComposition, InputState} from "./input"
import {selectionToDOM, anchorInRightPlace, syncNodeSelection} from "./selection"
import {Decoration, viewDecorations, DecorationSource} from "./decoration"
import {DOMObserver} from "./domobserver"
import {DOMObserver, safariShadowSelectionRange} from "./domobserver"
import {readDOMChange} from "./domchange"
import {DOMSelection, DOMNode, isEquivalentPosition, deepActiveElement} from "./dom"
import {DOMSelection, DOMNode, DOMSelectionRange, deepActiveElement} from "./dom"
import * as browser from "./browser"

export {Decoration, DecorationSet, DecorationAttrs, DecorationSource} from "./decoration"
Expand Down Expand Up @@ -195,7 +195,7 @@ export class EditorView {
// Chrome sometimes starts misreporting the selection, so this
// tracks that and forces a selection reset when our update
// did write to the node.
let chromeKludge = browser.chrome ? (this.trackWrites = this.domSelection().focusNode) : null
let chromeKludge = browser.chrome ? (this.trackWrites = this.domSelectionRange().focusNode) : null
if (redraw || !this.docView.update(state.doc, outerDeco, innerDeco, this)) {
this.docView.updateOuterDeco([])
this.docView.destroy()
Expand All @@ -208,7 +208,8 @@ export class EditorView {
// can cause a spurious DOM selection update, disrupting mouse
// drag selection.
if (forceSelUpdate ||
!(this.input.mouseDown && this.domObserver.currentSelection.eq(this.domSelection()) && anchorInRightPlace(this))) {
!(this.input.mouseDown && this.domObserver.currentSelection.eq(this.domSelectionRange()) &&
anchorInRightPlace(this))) {
selectionToDOM(this, forceSelUpdate)
} else {
syncNodeSelection(this, state.selection)
Expand All @@ -230,7 +231,7 @@ export class EditorView {

/// @internal
scrollToSelection() {
let startDOM = this.domSelection().focusNode!
let startDOM = this.domSelectionRange().focusNode!
if (this.someProp("handleScrollToSelection", f => f(this))) {
// Handled
} else if (this.state.selection instanceof NodeSelection) {
Expand Down Expand Up @@ -450,63 +451,14 @@ export class EditorView {
}

/// @internal
domSelection(): DOMSelection {
if (browser.safari && this.root.nodeType === 11 && deepActiveElement(this.dom.ownerDocument) == this.dom)
return this.safariDomSelection();

return (this.root as Document).getSelection()!
domSelectionRange(): DOMSelectionRange {
return browser.safari && this.root.nodeType === 11 && deepActiveElement(this.dom.ownerDocument) == this.dom
? safariShadowSelectionRange(this) : this.domSelection()
}

// Used to work around a Safari Selection/shadow DOM bug
// Based on https://github.com/codemirror/dev/issues/414 fix
private safariDomSelection(): DOMSelection {
let found: StaticRange | undefined;

function read(event: InputEvent) {
event.preventDefault()
event.stopImmediatePropagation()
found = event.getTargetRanges()[0]
}

// Because Safari (at least in 2018-2022) doesn't provide regular
// access to the selection inside a shadowRoot, we have to perform a
// ridiculous hack to get at it—using `execCommand` to trigger a
// `beforeInput` event so that we can read the target range from the
// event.
this.dom.addEventListener("beforeinput", read, true)
document.execCommand("indent")
this.dom.removeEventListener("beforeinput", read, true)

let anchorNode = found!.startContainer, anchorOffset = found!.startOffset
let focusNode = found!.endContainer, focusOffset = found!.endOffset

let currentAnchor = this.domAtPos(this.state.selection.anchor)

// Since such a range doesn't distinguish between anchor and head,
// use a heuristic that flips it around if its end matches the
// current anchor.
if (isEquivalentPosition(currentAnchor.node, currentAnchor.offset, focusNode, focusOffset))
[anchorNode, anchorOffset, focusNode, focusOffset] = [focusNode, focusOffset, anchorNode, anchorOffset]

let selection = (this.root as Document).getSelection()! as IndexedDOMSelection

// Copy methods and bind to the original selection as they could be invoked only on Selection instance
let selectionMethods: Record<string, Function> = {};
for (const selectionApiKey in selection) {
if (typeof selection[selectionApiKey] === 'function') {
selectionMethods[selectionApiKey] = selection[selectionApiKey].bind(selection);
}
}

return {
...selection, // get selection props
...selectionMethods, // get selection methods
anchorNode,
anchorOffset,
focusNode,
focusOffset,
isCollapsed: !!found?.collapsed,
}
/// @internal
domSelection(): DOMSelection {
return (this.root as Document).getSelection()!
}
}

Expand Down Expand Up @@ -813,7 +765,3 @@ export interface DirectEditorProps extends EditorProps {
/// the view instance as its `this` binding.
dispatchTransaction?: (tr: Transaction) => void
}

interface IndexedDOMSelection extends DOMSelection {
[key: string]: any;
}
6 changes: 3 additions & 3 deletions src/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,12 +463,12 @@ editHandlers.compositionstart = editHandlers.compositionupdate = view => {
// the inserted text won't inherit the marks. So this moves it
// inside if necessary.
if (browser.gecko && state.selection.empty && $pos.parentOffset && !$pos.textOffset && $pos.nodeBefore!.marks.length) {
let sel = view.domSelection()
let sel = view.domSelectionRange()
for (let node = sel.focusNode, offset = sel.focusOffset; node && node.nodeType == 1 && offset != 0;) {
let before = offset < 0 ? node.lastChild : node.childNodes[offset - 1]
if (!before) break
if (before.nodeType == 3) {
sel.collapse(before, before.nodeValue!.length)
view.domSelection().collapse(before, before.nodeValue!.length)
break
} else {
node = before
Expand Down Expand Up @@ -715,7 +715,7 @@ handlers.focus = view => {
view.domObserver.start()
view.focused = true
setTimeout(() => {
if (view.docView && view.hasFocus() && !view.domObserver.currentSelection.eq(view.domSelection()))
if (view.docView && view.hasFocus() && !view.domObserver.currentSelection.eq(view.domSelectionRange()))
selectionToDOM(view)
}, 20)
}
Expand Down
Loading

0 comments on commit 64a96e5

Please sign in to comment.