Skip to content

Commit

Permalink
Work around Safari's broken selection in shadow roots
Browse files Browse the repository at this point in the history
FIX: Add a workaround for Safari's broken selection reporting when the
editor is in a shadow DOM tree.

Issue codemirror/dev#414
  • Loading branch information
marijnh committed Apr 23, 2021
1 parent bd387df commit d81786d
Show file tree
Hide file tree
Showing 4 changed files with 82 additions and 32 deletions.
20 changes: 11 additions & 9 deletions src/docview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,13 +207,14 @@ export class DocView extends ContentView {
force = true
}

let domSel = getSelection(this.root)
let domSel = this.view.observer.selectionRange
// If the selection is already here, or in an equivalent position, don't touch it
if (force || !domSel.focusNode ||
(browser.gecko && main.empty && nextToUneditable(domSel.focusNode, domSel.focusOffset)) ||
!isEquivalentPosition(anchor.node, anchor.offset, domSel.anchorNode, domSel.anchorOffset) ||
!isEquivalentPosition(head.node, head.offset, domSel.focusNode, domSel.focusOffset)) {
this.view.observer.ignore(() => {
let rawSel = getSelection(this.root)
if (main.empty) {
// Work around https://bugzilla.mozilla.org/show_bug.cgi?id=1612076
if (browser.gecko) {
Expand All @@ -223,23 +224,23 @@ export class DocView extends ContentView {
if (text) anchor = new DOMPos(text, nextTo == NextTo.Before ? 0 : text.nodeValue!.length)
}
}
domSel.collapse(anchor.node, anchor.offset)
rawSel.collapse(anchor.node, anchor.offset)
if (main.bidiLevel != null && (domSel as any).cursorBidiLevel != null)
(domSel as any).cursorBidiLevel = main.bidiLevel
} else if (domSel.extend) {
} else if (rawSel.extend) {
// Selection.extend can be used to create an 'inverted' selection
// (one where the focus is before the anchor), but not all
// browsers support it yet.
domSel.collapse(anchor.node, anchor.offset)
domSel.extend(head.node, head.offset)
rawSel.collapse(anchor.node, anchor.offset)
rawSel.extend(head.node, head.offset)
} else {
// Primitive (IE) way
let range = document.createRange()
if (main.anchor > main.head) [anchor, head] = [head, anchor]
range.setEnd(head.node, head.offset)
range.setStart(anchor.node, anchor.offset)
domSel.removeAllRanges()
domSel.addRange(range)
rawSel.removeAllRanges()
rawSel.addRange(range)
}
})
}
Expand All @@ -264,7 +265,8 @@ export class DocView extends ContentView {
}

mayControlSelection() {
return this.view.state.facet(editable) ? this.root.activeElement == this.dom : hasSelection(this.dom, getSelection(this.root))
return this.view.state.facet(editable) ? this.root.activeElement == this.dom
: hasSelection(this.dom, this.view.observer.selectionRange)
}

nearest(dom: Node): ContentView | null {
Expand Down Expand Up @@ -420,7 +422,7 @@ class BlockGapWidget extends WidgetType {
}

export function computeCompositionDeco(view: EditorView, changes: ChangeSet): DecorationSet {
let sel = getSelection(view.root)
let sel = view.observer.selectionRange
let textNode = sel.focusNode && nearbyTextNode(sel.focusNode, sel.focusOffset, 0)
if (!textNode) return Decoration.none
let cView = view.docView.nearest(textNode)
Expand Down
24 changes: 12 additions & 12 deletions src/dom.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
import browser from "./browser"

export function getSelection(root: DocumentOrShadowRoot): Selection {
return (root.getSelection ? root.getSelection() : document.getSelection())!
}

// Work around Chrome issue https://bugs.chromium.org/p/chromium/issues/detail?id=447523
// (isCollapsed inappropriately returns true in shadow dom)
export function selectionCollapsed(domSel: Selection) {
let collapsed = domSel.isCollapsed
if (collapsed && browser.chrome && domSel.rangeCount && !domSel.getRangeAt(0).collapsed)
collapsed = false
return collapsed
export type SelectionRange = {
focusNode: Node | null, focusOffset: number,
anchorNode: Node | null, anchorOffset: number
}

export function contains(dom: HTMLElement, node: Node | null) {
return node ? dom.contains(node.nodeType != 1 ? node.parentNode : node) : false
}

export function hasSelection(dom: HTMLElement, selection: Selection): boolean {
export function deepActiveElement() {
let elt = document.activeElement
while (elt && elt.shadowRoot) elt = elt.shadowRoot.activeElement
return elt
}

export function hasSelection(dom: HTMLElement, selection: SelectionRange): boolean {
if (!selection.anchorNode) return false
try {
// Firefox will raise 'permission denied' errors when accessing
Expand Down Expand Up @@ -159,12 +159,12 @@ export class DOMSelection {
focusNode: Node | null = null
focusOffset: number = 0

eq(domSel: Selection): boolean {
eq(domSel: SelectionRange): boolean {
return this.anchorNode == domSel.anchorNode && this.anchorOffset == domSel.anchorOffset &&
this.focusNode == domSel.focusNode && this.focusOffset == domSel.focusOffset
}

set(domSel: Selection) {
set(domSel: SelectionRange) {
this.anchorNode = domSel.anchorNode; this.anchorOffset = domSel.anchorOffset
this.focusNode = domSel.focusNode; this.focusOffset = domSel.focusOffset
}
Expand Down
14 changes: 7 additions & 7 deletions src/domchange.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {EditorView} from "./editorview"
import {ContentView} from "./contentview"
import {inputHandler, editable} from "./extension"
import {selectionCollapsed, getSelection, contains} from "./dom"
import {contains} from "./dom"
import browser from "./browser"
import {EditorSelection, Transaction, Annotation, Text} from "@codemirror/state"

Expand All @@ -10,7 +10,7 @@ export function applyDOMChange(view: EditorView, start: number, end: number, typ
let sel = view.state.selection.main, bounds
if (start > -1 && (bounds = view.docView.domBoundsAround(start, end, 0))) {
let {from, to} = bounds
let selPoints = view.docView.impreciseHead || view.docView.impreciseAnchor ? [] : selectionPoints(view.contentDOM, view.root)
let selPoints = view.docView.impreciseHead || view.docView.impreciseAnchor ? [] : selectionPoints(view)
let reader = new DOMReader(selPoints, view)
reader.readRange(bounds.startDOM, bounds.endDOM)
newSel = selectionFromPoints(selPoints, from)
Expand All @@ -28,7 +28,7 @@ export function applyDOMChange(view: EditorView, start: number, end: number, typ
if (diff) change = {from: from + diff.from, to: from + diff.toA,
insert: view.state.toText(reader.text.slice(diff.from, diff.toB))}
} else if (view.hasFocus || !view.state.facet(editable)) {
let domSel = getSelection(view.root)
let domSel = view.observer.selectionRange
let {impreciseHead: iHead, impreciseAnchor: iAnchor} = view.docView
let head = iHead && iHead.node == domSel.focusNode && iHead.offset == domSel.focusOffset ||
!contains(view.contentDOM, domSel.focusNode)
Expand All @@ -37,7 +37,7 @@ export function applyDOMChange(view: EditorView, start: number, end: number, typ
let anchor = iAnchor && iAnchor.node == domSel.anchorNode && iAnchor.offset == domSel.anchorOffset ||
!contains(view.contentDOM, domSel.anchorNode)
? view.state.selection.main.anchor
: selectionCollapsed(domSel) ? head : view.docView.posFromDOM(domSel.anchorNode!, domSel.anchorOffset)
: view.docView.posFromDOM(domSel.anchorNode!, domSel.anchorOffset)
if (head != sel.head || anchor != sel.anchor)
newSel = EditorSelection.single(anchor, head)
}
Expand Down Expand Up @@ -204,10 +204,10 @@ class DOMPoint {
constructor(readonly node: Node, readonly offset: number) {}
}

function selectionPoints(dom: HTMLElement, root: DocumentOrShadowRoot): DOMPoint[] {
function selectionPoints(view: EditorView) {
let result: DOMPoint[] = []
if (root.activeElement != dom) return result
let {anchorNode, anchorOffset, focusNode, focusOffset} = getSelection(root)
if (view.root.activeElement != view.contentDOM) return result
let {anchorNode, anchorOffset, focusNode, focusOffset} = view.observer.selectionRange
if (anchorNode) {
result.push(new DOMPoint(anchorNode, anchorOffset))
if (focusNode != anchorNode || focusOffset != anchorOffset)
Expand Down
56 changes: 52 additions & 4 deletions src/domobserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import browser from "./browser"
import {ContentView, Dirty} from "./contentview"
import {EditorView} from "./editorview"
import {editable} from "./extension"
import {hasSelection, getSelection, DOMSelection, isEquivalentPosition} from "./dom"
import {hasSelection, getSelection, DOMSelection, isEquivalentPosition, SelectionRange, deepActiveElement} from "./dom"

const observeOptions = {
childList: true,
Expand Down Expand Up @@ -31,6 +31,9 @@ export class DOMObserver {
intersection: IntersectionObserver | null = null
intersecting: boolean = false

// Used to work around a Safari Selection/shadow DOM bug (#414)
selectionRange!: SelectionRange

// Timeout for scheduling check of the parents that need scroll handlers
parentCheck = -1

Expand Down Expand Up @@ -65,6 +68,7 @@ export class DOMObserver {
this.flushSoon()
}

this.updateSelectionRange()
this.onSelectionChange = this.onSelectionChange.bind(this)
this.start()

Expand Down Expand Up @@ -92,7 +96,8 @@ export class DOMObserver {
}

onSelectionChange(event: Event) {
let {view} = this, sel = getSelection(view.root)
this.updateSelectionRange()
let {view} = this, sel = this.selectionRange
if (view.state.facet(editable) ? view.root.activeElement != this.dom : !hasSelection(view.dom, sel))
return
let context = sel.anchorNode && view.docView.nearest(sel.anchorNode)
Expand All @@ -109,6 +114,15 @@ export class DOMObserver {
this.flush()
}

updateSelectionRange() {
let {root} = this.view, sel: SelectionRange = getSelection(root)
// The Selection object is broken in shadow roots in Safari. See
// https://github.com/codemirror/codemirror.next/issues/414
if (browser.safari && (root as any).nodeType == 11 && deepActiveElement() == this.view.contentDOM)
sel = safariSelectionRangeHack(this.view) || sel
this.selectionRange = sel
}

listenForScroll() {
this.parentCheck = -1
let i = 0, changed: HTMLElement[] | null = null
Expand Down Expand Up @@ -161,7 +175,7 @@ export class DOMObserver {
}

clearSelection() {
this.ignoreSelection.set(getSelection(this.view.root))
this.ignoreSelection.set(this.selectionRange)
}

// Throw away any pending changes
Expand Down Expand Up @@ -192,7 +206,7 @@ export class DOMObserver {
for (let mut of this.observer.takeRecords()) records.push(mut)
if (records.length) this.queue = []

let selection = getSelection(this.view.root)
let selection = this.selectionRange
let newSel = !this.ignoreSelection.eq(selection) && hasSelection(this.dom, selection)
if (records.length == 0 && !newSel) return

Expand Down Expand Up @@ -254,3 +268,37 @@ function findChild(cView: ContentView, dom: Node | null, dir: number): ContentVi
}
return null
}

function safariSelectionRangeHack(view: EditorView) {
let found: null | StaticRange = null
// Because Safari (at least in 2018-2021) 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.
function read(event: InputEvent) {
event.preventDefault()
event.stopImmediatePropagation()
found = (event as any).getTargetRanges()[0]
}
view.contentDOM.addEventListener("beforeinput", read, true)
document.execCommand("indent")
view.contentDOM.removeEventListener("beforeinput", read, true)
if (!found) return null
let curAnchor = view.docView.domAtPos(view.state.selection.main.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.
return isEquivalentPosition(curAnchor.node, curAnchor.offset, found!.endContainer, found!.endOffset)
? {
anchorNode: found!.endContainer,
anchorOffset: found!.endOffset,
focusNode: found!.startContainer,
focusOffset: found!.startOffset
} : {
anchorNode: found!.startContainer,
anchorOffset: found!.startOffset,
focusNode: found!.endContainer,
focusOffset: found!.endOffset
}
}

0 comments on commit d81786d

Please sign in to comment.