Skip to content

Commit dcb0532

Browse files
maximltphilippjfr
authored andcommitted
More complete patch for the TextEditor to support being rendered in the Shadow DOM (#6222)
1 parent ad29b6b commit dcb0532

File tree

2 files changed

+81
-57
lines changed

2 files changed

+81
-57
lines changed

panel/models/quill.ts

Lines changed: 80 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,7 @@
11
import * as p from "@bokehjs/core/properties"
22
import { div } from "@bokehjs/core/dom"
33

4-
import {HTMLBox, HTMLBoxView} from "./layout"
5-
6-
const normalizeNative = (nativeRange: any) => {
7-
8-
// document.getSelection model has properties startContainer and endContainer
9-
// shadow.getSelection model has baseNode and focusNode
10-
// Unify formats to always look like document.getSelection
11-
12-
if (nativeRange) {
13-
14-
const range = nativeRange;
15-
16-
// // HACK: To allow pasting
17-
if (range.baseNode?.classList?.value === 'ql-clipboard') {
18-
return null
19-
}
20-
21-
if (range.baseNode) {
22-
range.startContainer = nativeRange.baseNode;
23-
range.endContainer = nativeRange.focusNode;
24-
range.startOffset = nativeRange.baseOffset;
25-
range.endOffset = nativeRange.focusOffset;
26-
27-
if (range.endOffset < range.startOffset) {
28-
range.startContainer = nativeRange.focusNode;
29-
range.endContainer = nativeRange.baseNode;
30-
range.startOffset = nativeRange.focusOffset;
31-
range.endOffset = nativeRange.baseOffset;
32-
}
33-
}
34-
35-
if (range.startContainer) {
36-
37-
return {
38-
start: { node: range.startContainer, offset: range.startOffset },
39-
end: { node: range.endContainer, offset: range.endOffset },
40-
native: range
41-
};
42-
}
43-
}
44-
45-
return null
46-
};
4+
import { HTMLBox, HTMLBoxView } from "./layout"
475

486
export class QuillInputView extends HTMLBoxView {
497
override model: QuillInput
@@ -59,7 +17,7 @@ export class QuillInputView extends HTMLBoxView {
5917
this.connect(this.model.properties.disabled.change, () => this.quill.enable(!this.model.disabled))
6018
this.connect(this.model.properties.visible.change, () => {
6119
if (this.model.visible)
62-
this.container.style.visibility = 'visible';
20+
this.container.style.visibility = 'visible';
6321
})
6422
this.connect(this.model.properties.text.change, () => {
6523
if (this._editing)
@@ -71,7 +29,7 @@ export class QuillInputView extends HTMLBoxView {
7129
this.quill.enable(!this.model.disabled)
7230
this._editing = false
7331
})
74-
const {mode, toolbar, placeholder} = this.model.properties
32+
const { mode, toolbar, placeholder } = this.model.properties
7533
this.on_change([placeholder], () => {
7634
this.quill.root.setAttribute('data-placeholder', this.model.placeholder)
7735
})
@@ -93,7 +51,7 @@ export class QuillInputView extends HTMLBoxView {
9351

9452
render(): void {
9553
super.render()
96-
this.container = div({style: "visibility: hidden;"})
54+
this.container = div({ style: "visibility: hidden;" })
9755
this.shadow_el.appendChild(this.container)
9856
const theme = (this.model.mode === 'bubble') ? 'bubble' : 'snow'
9957
this.watch_stylesheets()
@@ -106,16 +64,82 @@ export class QuillInputView extends HTMLBoxView {
10664
theme: theme
10765
});
10866

109-
// Apply only with getSelection() is defined (e.g. undefined on Firefox)
110-
if (typeof this.quill.root.getRootNode().getSelection !== 'undefined') {
111-
// Hack Quill and replace document.getSelection with shadow.getSelection
112-
// see https://stackoverflow.com/questions/67914657/quill-editor-inside-shadow-dom/67944380#67944380
113-
this.quill.selection.getNativeRange = () => {
67+
// Apply ShadowDOM patch found at:
68+
// https://github.com/quilljs/quill/issues/2961#issuecomment-1775999845
69+
70+
const hasShadowRootSelection = !!((document.createElement('div').attachShadow({ mode: 'open' }) as any).getSelection);
71+
// Each browser engine has a different implementation for retrieving the Range
72+
const getNativeRange = (rootNode: any) => {
73+
try {
74+
if (hasShadowRootSelection) {
75+
// In Chromium, the shadow root has a getSelection function which returns the range
76+
return rootNode.getSelection().getRangeAt(0);
77+
} else {
78+
const selection = window.getSelection();
79+
if ((selection as any).getComposedRanges) {
80+
// Webkit range retrieval is done with getComposedRanges (see: https://bugs.webkit.org/show_bug.cgi?id=163921)
81+
return (selection as any).getComposedRanges(rootNode)[0];
82+
} else {
83+
// Gecko implements the range API properly in Native Shadow: https://developer.mozilla.org/en-US/docs/Web/API/Selection/getRangeAt
84+
return (selection as any).getRangeAt(0);
85+
}
86+
}
87+
} catch {
88+
return null;
89+
}
90+
}
91+
92+
/**
93+
* Original implementation uses document.active element which does not work in Native Shadow.
94+
* Replace document.activeElement with shadowRoot.activeElement
95+
**/
96+
this.quill.selection.hasFocus = () => {
97+
const rootNode = (this.quill.root.getRootNode() as ShadowRoot);
98+
return rootNode.activeElement === this.quill.root;
99+
}
114100

115-
const selection = (this.shadow_el as any).getSelection();
116-
const range = normalizeNative(selection);
117-
return range;
118-
};
101+
/**
102+
* Original implementation uses document.getSelection which does not work in Native Shadow.
103+
* Replace document.getSelection with shadow dom equivalent (different for each browser)
104+
**/
105+
this.quill.selection.getNativeRange = () => {
106+
const rootNode = (this.quill.root.getRootNode() as ShadowRoot);
107+
const nativeRange = getNativeRange(rootNode);
108+
return !!nativeRange ? this.quill.selection.normalizeNative(nativeRange) : null;
109+
};
110+
111+
/**
112+
* Original implementation relies on Selection.addRange to programmatically set the range, which does not work
113+
* in Webkit with Native Shadow. Selection.addRange works fine in Chromium and Gecko.
114+
**/
115+
this.quill.selection.setNativeRange = (startNode: any, startOffset: any) => {
116+
var endNode = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : startNode;
117+
var endOffset = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : startOffset;
118+
var force = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : false;
119+
if (startNode != null && (this.quill.selection.root.parentNode == null || startNode.parentNode == null || endNode.parentNode == null)) {
120+
return;
121+
}
122+
var selection = document.getSelection();
123+
if (selection == null) return;
124+
if (startNode != null) {
125+
if (!this.quill.selection.hasFocus()) this.quill.selection.root.focus();
126+
var native = (this.quill.selection.getNativeRange() || {}).native;
127+
if (native == null || force || startNode !== native.startContainer || startOffset !== native.startOffset || endNode !== native.endContainer || endOffset !== native.endOffset) {
128+
if (startNode.tagName == "BR") {
129+
startOffset = [].indexOf.call(startNode.parentNode.childNodes, startNode);
130+
startNode = startNode.parentNode;
131+
}
132+
if (endNode.tagName == "BR") {
133+
endOffset = [].indexOf.call(endNode.parentNode.childNodes, endNode);
134+
endNode = endNode.parentNode;
135+
}
136+
selection.setBaseAndExtent(startNode, startOffset, endNode, endOffset);
137+
}
138+
} else {
139+
selection.removeAllRanges();
140+
this.quill.selection.root.blur();
141+
document.body.focus();
142+
}
119143
}
120144

121145
this._editor = (this.shadow_el.querySelector('.ql-editor') as HTMLDivElement)
@@ -167,7 +191,7 @@ export namespace QuillInput {
167191
}
168192
}
169193

170-
export interface QuillInput extends QuillInput.Attrs {}
194+
export interface QuillInput extends QuillInput.Attrs { }
171195

172196
export class QuillInput extends HTMLBox {
173197
properties: QuillInput.Props

panel/tests/ui/widgets/test_texteditor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,4 @@ def test_texteditor_regression_click_toolbar_cursor_stays_in_place(page):
126126
editor.press('Enter')
127127
page.locator('.ql-bold').click()
128128
editor.press('B')
129-
wait_until(lambda: widget.value == '<p>A</p><p>B</p>', page)
129+
wait_until(lambda: widget.value == '<p>A</p><p><strong>B</strong></p>', page)

0 commit comments

Comments
 (0)