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 21 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6a11868
Early PoC of CKEditor5 inside a Shadow DOM.
niegowski Aug 26, 2024
b498a03
Merge branch 'refs/heads/master' into ck/shadow-poc
niegowski Aug 27, 2024
c10f714
Add support for drop target in a shadow DOM.
niegowski Aug 27, 2024
a2d80c0
The body collection in a shadow root. Listening for global events on …
niegowski Sep 2, 2024
c612671
CSS root properties should be translated to the host element.
niegowski Sep 3, 2024
97618c8
Marked shadow root places in code. Handling of block toolbar dragging.
niegowski Sep 3, 2024
b6ab662
Component focus handling.
niegowski Sep 3, 2024
3c1d883
Merge branch 'refs/heads/master' into ck/shadow-poc
niegowski Sep 6, 2024
fedf8be
Active element handling updated to work in shadow DOM.
niegowski Sep 6, 2024
d81a4db
Added some shadow helpers.
niegowski Sep 9, 2024
0c71edf
Added notes.
niegowski Sep 10, 2024
d6134b5
Merge branch 'refs/heads/master' into ck/shadow-poc
niegowski Sep 27, 2024
3ca2b7d
Merge branch 'refs/heads/master' into ck/shadow-poc
niegowski Oct 4, 2024
2ae8696
Fixed checking if some element is connected with the DOM document.
niegowski Oct 4, 2024
c744843
Avoid selection fixing on multiple table cells selection.
niegowski Oct 4, 2024
b9573d4
Fixed image resizer in shadow dom.
niegowski Oct 7, 2024
8b5daff
Code cleaning.
niegowski Oct 7, 2024
54a65fe
Updated focus handling.
niegowski Oct 7, 2024
97b974e
Active element fixes.
niegowski Oct 7, 2024
e1a8bdf
Added comment about toolbar focus cycling.
niegowski Oct 7, 2024
5a62869
Manual test clear.
niegowski Oct 8, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
15 changes: 11 additions & 4 deletions packages/ckeditor5-engine/src/view/domconverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ import {
isComment,
isValidAttributeName,
first,
getSelection,
getParentOrHostElement,
getActiveElement,
env
} from '@ckeditor/ckeditor5-utils';

Expand Down Expand Up @@ -1089,8 +1092,10 @@ export default class DomConverter {
*/
public focus( viewEditable: EditableElement ): void {
const domEditable = this.mapViewToDom( viewEditable );
const activeElement = domEditable && getActiveElement( domEditable );

if ( domEditable && domEditable.ownerDocument.activeElement !== domEditable ) {
// TODO ShadowRoot
if ( domEditable && 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 +1140,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 +1208,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 +1854,8 @@ function forEachDomElementAncestor( element: DomElement, callback: ( node: DomEl

while ( node ) {
callback( node );
node = node.parentElement;
// TODO ShadowRoot
node = getParentOrHostElement( node ) as DomElement | null;
}
}

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', () => {
// @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 @@ -993,12 +994,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 @@ -1007,7 +1010,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 @@ -1070,7 +1073,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 @@ -1090,10 +1093,12 @@ export default class Renderer extends /* #__PURE__ */ ObservableMixin() {
* Removes the DOM selection.
*/
private _removeDomSelection(): void {
// TODO ShadowRoot - this currently does not work in Shadow DOM but also looks like it has no effect
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 @@ -1250,6 +1255,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
2 changes: 1 addition & 1 deletion packages/ckeditor5-minimap/src/minimap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export default class Minimap extends Plugin {
this._scrollableRootAncestor = findClosestScrollableAncestor( editingRootElement );

// DOM root element is not yet attached to the document.
if ( !editingRootElement.ownerDocument.body.contains( editingRootElement ) ) {
if ( !editingRootElement.isConnected ) {
editor.ui.once( 'update', this._onUiReady.bind( this ) );

return;
Expand Down
8 changes: 6 additions & 2 deletions packages/ckeditor5-table/src/tableselection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,12 @@ export default class TableSelection extends Plugin {
highlighted.add( viewElement );
}

const lastViewCell = conversionApi.mapper.toViewElement( selectedCells[ selectedCells.length - 1 ] );
viewWriter.setSelection( lastViewCell, 0 );
// TODO ShadowRoot - find nearest selectable position so browser won't try to fix it
const lastModelCell = selectedCells[ selectedCells.length - 1 ];
const modelRange = editor.model.schema.getNearestSelectionRange( editor.model.createPositionAt( lastModelCell, 0 ), 'forward' );
const viewRange = conversionApi.mapper.toViewRange( modelRange );

viewWriter.setSelection( viewRange.start );
}, { priority: 'lowest' } ) );

function clearHighlightedTableCells( viewWriter: DowncastWriter ) {
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
Loading