diff --git a/src/docview.ts b/src/docview.ts index 6de6c624..dc602157 100644 --- a/src/docview.ts +++ b/src/docview.ts @@ -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) { @@ -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) } }) } @@ -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 { @@ -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) diff --git a/src/dom.ts b/src/dom.ts index 0f9c38f4..f8da22b2 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -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 @@ -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 } diff --git a/src/domchange.ts b/src/domchange.ts index e181f458..c631107f 100644 --- a/src/domchange.ts +++ b/src/domchange.ts @@ -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" @@ -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) @@ -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) @@ -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) } @@ -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) diff --git a/src/domobserver.ts b/src/domobserver.ts index 49b31d25..61215c53 100644 --- a/src/domobserver.ts +++ b/src/domobserver.ts @@ -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, @@ -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 @@ -65,6 +68,7 @@ export class DOMObserver { this.flushSoon() } + this.updateSelectionRange() this.onSelectionChange = this.onSelectionChange.bind(this) this.start() @@ -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) @@ -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 @@ -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 @@ -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 @@ -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 + } +}