Skip to content

Commit

Permalink
More complete patch for the TextEditor to support being rendered in t…
Browse files Browse the repository at this point in the history
…he Shadow DOM (#6222)
  • Loading branch information
maximlt authored and philippjfr committed Jan 18, 2024
1 parent ad29b6b commit dcb0532
Show file tree
Hide file tree
Showing 2 changed files with 81 additions and 57 deletions.
136 changes: 80 additions & 56 deletions panel/models/quill.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,7 @@
import * as p from "@bokehjs/core/properties"
import { div } from "@bokehjs/core/dom"

import {HTMLBox, HTMLBoxView} from "./layout"

const normalizeNative = (nativeRange: any) => {

// document.getSelection model has properties startContainer and endContainer
// shadow.getSelection model has baseNode and focusNode
// Unify formats to always look like document.getSelection

if (nativeRange) {

const range = nativeRange;

// // HACK: To allow pasting
if (range.baseNode?.classList?.value === 'ql-clipboard') {
return null
}

if (range.baseNode) {
range.startContainer = nativeRange.baseNode;
range.endContainer = nativeRange.focusNode;
range.startOffset = nativeRange.baseOffset;
range.endOffset = nativeRange.focusOffset;

if (range.endOffset < range.startOffset) {
range.startContainer = nativeRange.focusNode;
range.endContainer = nativeRange.baseNode;
range.startOffset = nativeRange.focusOffset;
range.endOffset = nativeRange.baseOffset;
}
}

if (range.startContainer) {

return {
start: { node: range.startContainer, offset: range.startOffset },
end: { node: range.endContainer, offset: range.endOffset },
native: range
};
}
}

return null
};
import { HTMLBox, HTMLBoxView } from "./layout"

export class QuillInputView extends HTMLBoxView {
override model: QuillInput
Expand All @@ -59,7 +17,7 @@ export class QuillInputView extends HTMLBoxView {
this.connect(this.model.properties.disabled.change, () => this.quill.enable(!this.model.disabled))
this.connect(this.model.properties.visible.change, () => {
if (this.model.visible)
this.container.style.visibility = 'visible';
this.container.style.visibility = 'visible';
})
this.connect(this.model.properties.text.change, () => {
if (this._editing)
Expand All @@ -71,7 +29,7 @@ export class QuillInputView extends HTMLBoxView {
this.quill.enable(!this.model.disabled)
this._editing = false
})
const {mode, toolbar, placeholder} = this.model.properties
const { mode, toolbar, placeholder } = this.model.properties
this.on_change([placeholder], () => {
this.quill.root.setAttribute('data-placeholder', this.model.placeholder)
})
Expand All @@ -93,7 +51,7 @@ export class QuillInputView extends HTMLBoxView {

render(): void {
super.render()
this.container = div({style: "visibility: hidden;"})
this.container = div({ style: "visibility: hidden;" })
this.shadow_el.appendChild(this.container)
const theme = (this.model.mode === 'bubble') ? 'bubble' : 'snow'
this.watch_stylesheets()
Expand All @@ -106,16 +64,82 @@ export class QuillInputView extends HTMLBoxView {
theme: theme
});

// Apply only with getSelection() is defined (e.g. undefined on Firefox)
if (typeof this.quill.root.getRootNode().getSelection !== 'undefined') {
// Hack Quill and replace document.getSelection with shadow.getSelection
// see https://stackoverflow.com/questions/67914657/quill-editor-inside-shadow-dom/67944380#67944380
this.quill.selection.getNativeRange = () => {
// Apply ShadowDOM patch found at:
// https://github.com/quilljs/quill/issues/2961#issuecomment-1775999845

const hasShadowRootSelection = !!((document.createElement('div').attachShadow({ mode: 'open' }) as any).getSelection);
// Each browser engine has a different implementation for retrieving the Range
const getNativeRange = (rootNode: any) => {
try {
if (hasShadowRootSelection) {
// In Chromium, the shadow root has a getSelection function which returns the range
return rootNode.getSelection().getRangeAt(0);
} else {
const selection = window.getSelection();
if ((selection as any).getComposedRanges) {
// Webkit range retrieval is done with getComposedRanges (see: https://bugs.webkit.org/show_bug.cgi?id=163921)
return (selection as any).getComposedRanges(rootNode)[0];
} else {
// Gecko implements the range API properly in Native Shadow: https://developer.mozilla.org/en-US/docs/Web/API/Selection/getRangeAt
return (selection as any).getRangeAt(0);
}
}
} catch {
return null;
}
}

/**
* Original implementation uses document.active element which does not work in Native Shadow.
* Replace document.activeElement with shadowRoot.activeElement
**/
this.quill.selection.hasFocus = () => {
const rootNode = (this.quill.root.getRootNode() as ShadowRoot);
return rootNode.activeElement === this.quill.root;
}

const selection = (this.shadow_el as any).getSelection();
const range = normalizeNative(selection);
return range;
};
/**
* Original implementation uses document.getSelection which does not work in Native Shadow.
* Replace document.getSelection with shadow dom equivalent (different for each browser)
**/
this.quill.selection.getNativeRange = () => {
const rootNode = (this.quill.root.getRootNode() as ShadowRoot);
const nativeRange = getNativeRange(rootNode);
return !!nativeRange ? this.quill.selection.normalizeNative(nativeRange) : null;
};

/**
* Original implementation relies on Selection.addRange to programmatically set the range, which does not work
* in Webkit with Native Shadow. Selection.addRange works fine in Chromium and Gecko.
**/
this.quill.selection.setNativeRange = (startNode: any, startOffset: any) => {
var endNode = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : startNode;
var endOffset = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : startOffset;
var force = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : false;
if (startNode != null && (this.quill.selection.root.parentNode == null || startNode.parentNode == null || endNode.parentNode == null)) {
return;
}
var selection = document.getSelection();
if (selection == null) return;
if (startNode != null) {
if (!this.quill.selection.hasFocus()) this.quill.selection.root.focus();
var native = (this.quill.selection.getNativeRange() || {}).native;
if (native == null || force || startNode !== native.startContainer || startOffset !== native.startOffset || endNode !== native.endContainer || endOffset !== native.endOffset) {
if (startNode.tagName == "BR") {
startOffset = [].indexOf.call(startNode.parentNode.childNodes, startNode);
startNode = startNode.parentNode;
}
if (endNode.tagName == "BR") {
endOffset = [].indexOf.call(endNode.parentNode.childNodes, endNode);
endNode = endNode.parentNode;
}
selection.setBaseAndExtent(startNode, startOffset, endNode, endOffset);
}
} else {
selection.removeAllRanges();
this.quill.selection.root.blur();
document.body.focus();
}
}

this._editor = (this.shadow_el.querySelector('.ql-editor') as HTMLDivElement)
Expand Down Expand Up @@ -167,7 +191,7 @@ export namespace QuillInput {
}
}

export interface QuillInput extends QuillInput.Attrs {}
export interface QuillInput extends QuillInput.Attrs { }

export class QuillInput extends HTMLBox {
properties: QuillInput.Props
Expand Down
2 changes: 1 addition & 1 deletion panel/tests/ui/widgets/test_texteditor.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,4 +126,4 @@ def test_texteditor_regression_click_toolbar_cursor_stays_in_place(page):
editor.press('Enter')
page.locator('.ql-bold').click()
editor.press('B')
wait_until(lambda: widget.value == '<p>A</p><p>B</p>', page)
wait_until(lambda: widget.value == '<p>A</p><p><strong>B</strong></p>', page)

0 comments on commit dcb0532

Please sign in to comment.