Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Early PoC of CKEditor5 inside a Shadow DOM. #16975

Draft
wants to merge 11 commits into
base: master
Choose a base branch
from
3 changes: 3 additions & 0 deletions packages/ckeditor5-ckbox/src/ckboxcommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,9 @@ export default class CKBoxCommand extends Command {
return;
}

// TODO ShadowRoot
// - can we append it to the body collection?
// - does CKBox support Shadow DOM?
this._wrapper = createElement( document, 'div', { class: 'ck ckbox-wrapper' } );
document.body.appendChild( this._wrapper );

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ export default class CKBoxImageEditCommand extends Command {
return;
}

// TODO ShadowRoot
// - can we append it to the body collection?
// - does CKBox support Shadow DOM?
const wrapper = createElement( document, 'div', { class: 'ck ckbox-wrapper' } );

this._wrapper = wrapper;
Expand Down
3 changes: 3 additions & 0 deletions packages/ckeditor5-clipboard/src/dragdrop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,9 @@ export default class DragDrop extends Plugin {
style: 'position: fixed; left: -999999px;'
} );

// TODO ShadowRoot
// - can we append it to the body collection?
// - is the preview generated correctly in the Shadow DOM
global.document.body.appendChild( this._previewContainer );
} else if ( this._previewContainer.firstElementChild ) {
this._previewContainer.removeChild( this._previewContainer.firstElementChild );
Expand Down
16 changes: 14 additions & 2 deletions packages/ckeditor5-clipboard/src/dragdropblocktoolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export default class DragDropBlockToolbar extends Plugin {
const element = blockToolbar.buttonView.element!;

this._domEmitter.listenTo( element, 'dragstart', ( evt, data ) => this._handleBlockDragStart( data ) );

// TODO ShadowRoot - those events will propagate across the shadow DOM boundary (bubbles and composed flags set)
this._domEmitter.listenTo( global.document, 'dragover', ( evt, data ) => this._handleBlockDragging( data ) );
this._domEmitter.listenTo( global.document, 'drop', ( evt, data ) => this._handleBlockDragging( data ) );
this._domEmitter.listenTo( global.document, 'dragend', () => this._handleBlockDragEnd(), { useCapture: true } );
Expand Down Expand Up @@ -125,10 +127,20 @@ export default class DragDropBlockToolbar extends Plugin {
return;
}

const view = this.editor.editing.view;

const clientX = domEvent.clientX + ( this.editor.locale.contentLanguageDirection == 'ltr' ? 100 : -100 );
const clientY = domEvent.clientY;
const target = document.elementFromPoint( clientX, clientY );
const view = this.editor.editing.view;

let target = document.elementFromPoint( clientX, clientY );

// TODO ShadowRoot
// - this is a workaround, works this way only in open shadow root
// - we should use map of known shadow roots and not depend on the shadowRoot property (it's there only for open mode)
// - the ShadowRoot#elementFromPoint() is non-standard but available in all browsers.
if ( target && target.shadowRoot && target.shadowRoot.elementFromPoint ) {
target = target.shadowRoot.elementFromPoint( clientX, clientY );
}

if ( !target || !target.closest( '.ck-editor__editable' ) ) {
return;
Expand Down
5 changes: 4 additions & 1 deletion packages/ckeditor5-clipboard/src/dragdroptarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
DomEmitterMixin,
delay,
ResizeObserver,
getParentOrHostElement,
type DomEmitter
} from '@ckeditor/ckeditor5-utils';

Expand Down Expand Up @@ -521,7 +522,9 @@ function findScrollableElement( domNode: HTMLElement ): HTMLElement {
let domElement: HTMLElement = domNode;

do {
domElement = domElement.parentElement!;
// TODO ShadowRoot
// - use helper for easier parent element access
domElement = getParentOrHostElement( domElement ) as HTMLElement;

const overflow = global.window.getComputedStyle( domElement ).overflowY;

Expand Down
13 changes: 8 additions & 5 deletions packages/ckeditor5-engine/src/view/domconverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ import {
isComment,
isValidAttributeName,
first,
env
env,
getSelection
} from '@ckeditor/ckeditor5-utils';

import type ViewNode from './node.js';
Expand Down Expand Up @@ -1090,7 +1091,8 @@ export default class DomConverter {
public focus( viewEditable: EditableElement ): void {
const domEditable = this.mapViewToDom( viewEditable );

if ( domEditable && domEditable.ownerDocument.activeElement !== domEditable ) {
// TODO ShadowRoot
if ( domEditable && domEditable.getRootNode().activeElement !== domEditable ) {
// Save the scrollX and scrollY positions before the focus.
const { scrollX, scrollY } = global.window;
const scrollPositions: Array<[ number, number ]> = [];
Expand Down Expand Up @@ -1135,7 +1137,7 @@ export default class DomConverter {
}

// Check if DOM selection is inside editor editable element.
const domSelection = domEditable.ownerDocument.defaultView!.getSelection()!;
const domSelection = getSelection( domEditable )!;
const newViewSelection = this.domSelectionToView( domSelection );
const selectionInEditable = newViewSelection && newViewSelection.rangeCount > 0;

Expand Down Expand Up @@ -1203,7 +1205,8 @@ export default class DomConverter {
* @param DOM Selection instance to check.
*/
public isDomSelectionBackward( selection: DomSelection ): boolean {
if ( selection.isCollapsed ) {
// TODO ShadowRoot have invalid isCollapsed, check first range and if this issue is not resolved in Chrome.
if ( selection.isCollapsed && ( !selection.rangeCount || selection.getRangeAt( 0 ).collapsed ) ) {
return false;
}

Expand Down Expand Up @@ -1848,7 +1851,7 @@ function forEachDomElementAncestor( element: DomElement, callback: ( node: DomEl

while ( node ) {
callback( node );
node = node.parentElement;
node = node.parentElement; // TODO ShadowRoot
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/ckeditor5-engine/src/view/filler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/

import { keyCodes, isText, type KeystrokeInfo } from '@ckeditor/ckeditor5-utils';
import { keyCodes, isText, getSelection, type KeystrokeInfo } from '@ckeditor/ckeditor5-utils';
import type View from './view.js';
import type DomEventData from './observer/domeventdata.js';
import type { ViewDocumentArrowKeyEvent } from './observer/arrowkeysobserver.js';
Expand Down Expand Up @@ -158,7 +158,7 @@ export function injectQuirksHandling( view: View ): void {
*/
function jumpOverInlineFiller( evt: unknown, data: DomEventData & KeystrokeInfo ) {
if ( data.keyCode == keyCodes.arrowleft ) {
const domSelection = data.domTarget.ownerDocument.defaultView!.getSelection()!;
const domSelection = getSelection( data.domTarget )!;

if ( domSelection.rangeCount == 1 && domSelection.getRangeAt( 0 ).collapsed ) {
const domParent = domSelection.getRangeAt( 0 ).startContainer;
Expand Down
4 changes: 2 additions & 2 deletions packages/ckeditor5-engine/src/view/observer/inputobserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import DomEventObserver from './domeventobserver.js';
import type DomEventData from './domeventdata.js';
import type ViewRange from '../range.js';
import DataTransfer from '../datatransfer.js';
import { env } from '@ckeditor/ckeditor5-utils';
import { env, getSelection } from '@ckeditor/ckeditor5-utils';

// @if CK_DEBUG_TYPING // const { _debouncedLine } = require( '../../dev-utils/utils.js' );

Expand Down Expand Up @@ -105,7 +105,7 @@ export default class InputObserver extends DomEventObserver<'beforeinput'> {
// For Android devices we use a fallback to the current DOM selection, Android modifies it according
// to the expected target ranges of input event.
else if ( env.isAndroid ) {
const domSelection = ( domEvent.target as HTMLElement ).ownerDocument.defaultView!.getSelection()!;
const domSelection = getSelection( domEvent.target as HTMLElement )!;

targetRanges = Array.from( view.domConverter.domSelectionToView( domSelection ).getRanges() );

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export default class MutationObserver extends Observer {
this._domElements.add( domElement );

if ( this.isEnabled ) {
// TODO ShadowRoot - will this work if widget has its own Shadow DOM?
this._mutationObserver.observe( domElement, this._config );
}
}
Expand Down
22 changes: 13 additions & 9 deletions packages/ckeditor5-engine/src/view/observer/selectionobserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import Observer from './observer.js';
import MutationObserver from './mutationobserver.js';
import FocusObserver from './focusobserver.js';
import { env } from '@ckeditor/ckeditor5-utils';
import { env, getSelection } from '@ckeditor/ckeditor5-utils';
import { debounce, type DebouncedFunc } from 'lodash-es';

import type View from '../view.js';
Expand Down Expand Up @@ -115,8 +115,6 @@ export default class SelectionObserver extends Observer {
* @inheritDoc
*/
public override observe( domElement: HTMLElement ): void {
const domDocument = domElement.ownerDocument;

const startDocumentIsSelecting = () => {
this.document.isSelecting = true;

Expand All @@ -131,7 +129,7 @@ export default class SelectionObserver extends Observer {

// Make sure that model selection is up-to-date at the end of selecting process.
// Sometimes `selectionchange` events could arrive after the `mouseup` event and that selection could be already outdated.
this._handleSelectionChange( domDocument );
this._handleSelectionChange( domElement );

this.document.isSelecting = false;

Expand All @@ -147,15 +145,19 @@ export default class SelectionObserver extends Observer {
this.listenTo( domElement, 'keydown', endDocumentIsSelecting, { priority: 'highest', useCapture: true } );
this.listenTo( domElement, 'keyup', endDocumentIsSelecting, { priority: 'highest', useCapture: true } );

const domDocument = domElement.ownerDocument;

// Add document-wide listeners only once. This method could be called for multiple editing roots.
if ( this._documents.has( domDocument ) ) {
return;
}

// This listener is using capture mode to make sure that selection is upcasted before any other
// handler would like to check it and update (for example table multi cell selection).
// TODO ShadowRoot - this event will propagate across the shadow DOM boundary (bubbles and composed flags set)
this.listenTo( domDocument, 'mouseup', endDocumentIsSelecting, { priority: 'highest', useCapture: true } );

// TODO ShadowRoot - this event is always fired from the document, even inside a Shadow DOM.
this.listenTo( domDocument, 'selectionchange', ( evt, domEvent ) => {
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // _debouncedLine();
Expand All @@ -181,7 +183,8 @@ export default class SelectionObserver extends Observer {
return;
}

this._handleSelectionChange( domDocument );
// TODO ShadowRoot - this will not work if separate roots are in separate shadow DOMs
this._handleSelectionChange( domElement );

// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // console.groupEnd();
Expand All @@ -206,7 +209,8 @@ export default class SelectionObserver extends Observer {
// @if CK_DEBUG_TYPING // );
// @if CK_DEBUG_TYPING // }

this._handleSelectionChange( domDocument );
// TODO ShadowRoot - this will not work if separate roots are in separate shadow DOMs
this._handleSelectionChange( domElement );

// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // console.groupEnd();
Expand Down Expand Up @@ -247,14 +251,14 @@ export default class SelectionObserver extends Observer {
* a selection changes and fires {@link module:engine/view/document~Document#event:selectionChange} event on every change
* and {@link module:engine/view/document~Document#event:selectionChangeDone} when a selection stop changing.
*
* @param domDocument DOM document.
* @param domElement DOM element.
*/
private _handleSelectionChange( domDocument: Document ) {
private _handleSelectionChange( domElement: HTMLElement ) {
if ( !this.isEnabled ) {
return;
}

const domSelection = domDocument.defaultView!.getSelection()!;
const domSelection = getSelection( domElement )!;

if ( this.checkShouldIgnoreEventFromTarget( domSelection.anchorNode! ) ) {
return;
Expand Down
16 changes: 11 additions & 5 deletions packages/ckeditor5-engine/src/view/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
isText,
remove,
indexOf,
getSelection,
type DiffResult,
type ObservableChangeEvent
} from '@ckeditor/ckeditor5-utils';
Expand Down Expand Up @@ -990,12 +991,14 @@ export default class Renderer extends /* #__PURE__ */ ObservableMixin() {

container.textContent = this.selection.fakeSelectionLabel || '\u00A0';

const domSelection = domDocument.getSelection()!;
const domSelection = getSelection( domRoot )!;
const domRange = domDocument.createRange();

domSelection.removeAllRanges();
domRange.selectNodeContents( container );
domSelection.addRange( domRange );
domSelection.setBaseAndExtent(
domRange.startContainer, domRange.startOffset,
domRange.endContainer, domRange.endOffset
);
}

/**
Expand All @@ -1004,7 +1007,7 @@ export default class Renderer extends /* #__PURE__ */ ObservableMixin() {
* @param domRoot A valid DOM root where the DOM selection should be rendered.
*/
private _updateDomSelection( domRoot: DomElement ) {
const domSelection = domRoot.ownerDocument.defaultView!.getSelection()!;
const domSelection = getSelection( domRoot )!;

// Let's check whether DOM selection needs updating at all.
if ( !this._domSelectionNeedsUpdate( domSelection ) ) {
Expand Down Expand Up @@ -1067,7 +1070,7 @@ export default class Renderer extends /* #__PURE__ */ ObservableMixin() {
*/
private _fakeSelectionNeedsUpdate( domRoot: DomElement ): boolean {
const container = this._fakeSelectionContainer;
const domSelection = domRoot.ownerDocument.getSelection()!;
const domSelection = getSelection( domRoot )!;

// Fake selection needs to be updated if there's no fake selection container, or the container currently sits
// in a different root.
Expand All @@ -1087,10 +1090,12 @@ export default class Renderer extends /* #__PURE__ */ ObservableMixin() {
* Removes the DOM selection.
*/
private _removeDomSelection(): void {
// TODO ShadowRoot
for ( const doc of this.domDocuments ) {
const domSelection = doc.getSelection()!;

if ( domSelection.rangeCount ) {
// TODO ShadowRoot - the activeElement of the closest ShadowRoot?
const activeDomElement = doc.activeElement!;
const viewElement = this.domConverter.mapDomToView( activeDomElement as DomElement );

Expand Down Expand Up @@ -1247,6 +1252,7 @@ function fixGeckoSelectionAfterBr( focus: ReturnType<DomConverter[ 'viewPosition
// To stay on the safe side, the fix being as specific as possible, it targets only the
// selection which is at the very end of the element and preceded by <br />.
if ( childAtOffset && ( childAtOffset as DomElement ).tagName == 'BR' ) {
// TODO ShadowRoot
domSelection.addRange( domSelection.getRangeAt( 0 ) );
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/ckeditor5-engine/src/view/uielement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import Element, { type ElementAttributes } from './element.js';
import Node from './node.js';
import { CKEditorError, keyCodes } from '@ckeditor/ckeditor5-utils';
import { CKEditorError, keyCodes, getSelection } from '@ckeditor/ckeditor5-utils';

import type View from './view.js';
import type Document from './document.js';
Expand Down Expand Up @@ -173,7 +173,7 @@ function getFillerOffset() {
*/
function jumpOverUiElement( evt: unknown, data: KeyEventData, domConverter: DomConverter ) {
if ( data.keyCode == keyCodes.arrowright ) {
const domSelection = data.domTarget.ownerDocument.defaultView!.getSelection()!;
const domSelection = getSelection( data.domTarget )!;
const domSelectionCollapsed = domSelection.rangeCount == 1 && domSelection.getRangeAt( 0 ).collapsed;

// Jump over UI element if selection is collapsed or shift key is pressed. These are the cases when selection would extend.
Expand Down
1 change: 1 addition & 0 deletions packages/ckeditor5-ui/src/arialiveannouncer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export default class AriaLiveAnnouncer {

if ( !this.view ) {
this.view = new AriaLiveAnnouncerView( editor.locale );
// TODO ShadowRoot - make sure that it can announce if it's inside a shadow root
editor.ui.view.body.add( this.view );
}

Expand Down
3 changes: 3 additions & 0 deletions packages/ckeditor5-ui/src/bindings/clickoutsidehandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export default function clickOutsideHandler(

// Check if `composedPath` is `undefined` in case the browser does not support native shadow DOM.
// Can be removed when all supported browsers support native shadow DOM.
// TODO ShadowRoot
// - This won't work for closed shadow root.
// - We probably should listen to all shadow roots we know of and have access to.
const path = typeof domEvt.composedPath == 'function' ? domEvt.composedPath() : [];

const contextElementsList = typeof contextElements == 'function' ? contextElements() : contextElements;
Expand Down
2 changes: 2 additions & 0 deletions packages/ckeditor5-ui/src/bindings/draggableviewmixin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export default function DraggableViewMixin<Base extends Constructor<View>>( view
* Attaches the listeners for the dragging and drag end.
*/
private _attachDragListeners() {
// TODO ShadowRoot - those events will propagate across the shadow DOM boundary (bubbles and composed flags set)
this.listenTo( global.document, 'mouseup', this._onDragEndBound );
this.listenTo( global.document, 'touchend', this._onDragEndBound );
this.listenTo( global.document, 'mousemove', this._onDragBound );
Expand All @@ -87,6 +88,7 @@ export default function DraggableViewMixin<Base extends Constructor<View>>( view
* Detaches the listeners after the drag end.
*/
private _detachDragListeners() {
// TODO ShadowRoot - those events will propagate across the shadow DOM boundary (bubbles and composed flags set)
this.stopListening( global.document, 'mouseup', this._onDragEndBound );
this.stopListening( global.document, 'touchend', this._onDragEndBound );
this.stopListening( global.document, 'mousemove', this._onDragBound );
Expand Down
1 change: 1 addition & 0 deletions packages/ckeditor5-ui/src/colorpicker/colorpickerview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export default class ColorPickerView extends View {
this.on( 'change:_hexColor', () => {
// Update the selected color in the color picker palette when it's not focused.
// It means the user typed the color in the input.
// TODO ShadowRoot
if ( document.activeElement !== this.picker ) {
this.picker.setAttribute( 'color', this._hexColor );
}
Expand Down
3 changes: 2 additions & 1 deletion packages/ckeditor5-ui/src/dropdown/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,7 +606,8 @@ function focusDropdownButtonOnClose( dropdownView: DropdownView ) {
// If the dropdown was closed, move the focus back to the button (#12125).
// Don't touch the focus, if it moved somewhere else (e.g. moved to the editing root on #execute) (#12178).
// Note: Don't use the state of the DropdownView#focusTracker here. It fires #blur with the timeout.
if ( elements.some( element => element.contains( global.document.activeElement ) ) ) {
// TODO ShadowRoot - the activeElement is valid for the closest ShadowRoot
if ( elements.some( element => element.getRootNode().activeElement && element.contains( element.getRootNode().activeElement ) ) ) {
dropdownView.buttonView.focus();
}
} );
Expand Down
Loading