@ckeditor/ckeditor5-widget
Advanced tools
Comparing version 19.1.0 to 20.0.0
{ | ||
"name": "@ckeditor/ckeditor5-widget", | ||
"version": "19.1.0", | ||
"version": "20.0.0", | ||
"description": "Widget API for CKEditor 5.", | ||
@@ -12,26 +12,28 @@ "keywords": [ | ||
"dependencies": { | ||
"@ckeditor/ckeditor5-core": "^19.0.1", | ||
"@ckeditor/ckeditor5-engine": "^19.0.1", | ||
"@ckeditor/ckeditor5-ui": "^19.0.1", | ||
"@ckeditor/ckeditor5-utils": "^19.0.1", | ||
"lodash-es": "^4.17.10" | ||
"@ckeditor/ckeditor5-core": "^20.0.0", | ||
"@ckeditor/ckeditor5-engine": "^20.0.0", | ||
"@ckeditor/ckeditor5-typing": "^20.0.0", | ||
"@ckeditor/ckeditor5-ui": "^20.0.0", | ||
"@ckeditor/ckeditor5-utils": "^20.0.0", | ||
"lodash-es": "^4.17.15" | ||
}, | ||
"devDependencies": { | ||
"@ckeditor/ckeditor5-basic-styles": "^19.0.1", | ||
"@ckeditor/ckeditor5-block-quote": "^19.0.1", | ||
"@ckeditor/ckeditor5-clipboard": "^19.0.1", | ||
"@ckeditor/ckeditor5-editor-balloon": "^19.0.1", | ||
"@ckeditor/ckeditor5-editor-classic": "^19.0.1", | ||
"@ckeditor/ckeditor5-enter": "^19.0.1", | ||
"@ckeditor/ckeditor5-essentials": "^19.0.1", | ||
"@ckeditor/ckeditor5-heading": "^19.0.1", | ||
"@ckeditor/ckeditor5-horizontal-line": "^19.0.1", | ||
"@ckeditor/ckeditor5-media-embed": "^19.1.0", | ||
"@ckeditor/ckeditor5-paragraph": "^19.1.0", | ||
"@ckeditor/ckeditor5-table": "^19.1.0", | ||
"@ckeditor/ckeditor5-basic-styles": "^20.0.0", | ||
"@ckeditor/ckeditor5-block-quote": "^20.0.0", | ||
"@ckeditor/ckeditor5-clipboard": "^20.0.0", | ||
"@ckeditor/ckeditor5-editor-balloon": "^20.0.0", | ||
"@ckeditor/ckeditor5-editor-classic": "^20.0.0", | ||
"@ckeditor/ckeditor5-enter": "^20.0.0", | ||
"@ckeditor/ckeditor5-essentials": "^20.0.0", | ||
"@ckeditor/ckeditor5-heading": "^20.0.0", | ||
"@ckeditor/ckeditor5-horizontal-line": "^20.0.0", | ||
"@ckeditor/ckeditor5-image": "^20.0.0", | ||
"@ckeditor/ckeditor5-media-embed": "^20.0.0", | ||
"@ckeditor/ckeditor5-paragraph": "^20.0.0", | ||
"@ckeditor/ckeditor5-table": "^20.0.0", | ||
"@ckeditor/ckeditor5-typing": "^19.0.1", | ||
"@ckeditor/ckeditor5-undo": "^19.0.1" | ||
"@ckeditor/ckeditor5-undo": "^20.0.0" | ||
}, | ||
"engines": { | ||
"node": ">=8.0.0", | ||
"node": ">=12.0.0", | ||
"npm": ">=5.7.1" | ||
@@ -38,0 +40,0 @@ }, |
@@ -17,2 +17,3 @@ /** | ||
import dragHandleIcon from '../theme/icons/drag-handle.svg'; | ||
import { getTypeAroundFakeCaretPosition } from './widgettypearound/utils'; | ||
@@ -260,4 +261,14 @@ /** | ||
if ( selectedElement && model.schema.isBlock( selectedElement ) ) { | ||
return model.createPositionAfter( selectedElement ); | ||
if ( selectedElement ) { | ||
const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition( selection ); | ||
// If the WidgetTypeAround "fake caret" is displayed, use its position for the insertion | ||
// to provide the most predictable UX (https://github.com/ckeditor/ckeditor5/issues/7438). | ||
if ( typeAroundFakeCaretPosition ) { | ||
return model.createPositionAt( selectedElement, typeAroundFakeCaretPosition ); | ||
} | ||
if ( model.schema.isBlock( selectedElement ) ) { | ||
return model.createPositionAfter( selectedElement ); | ||
} | ||
} | ||
@@ -264,0 +275,0 @@ |
@@ -14,6 +14,10 @@ /** | ||
import { getLabel, isWidget, WIDGET_SELECTED_CLASS_NAME } from './utils'; | ||
import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; | ||
import { | ||
isArrowKeyCode, | ||
isForwardArrowKeyCode | ||
} from '@ckeditor/ckeditor5-utils/src/keyboard'; | ||
import env from '@ckeditor/ckeditor5-utils/src/env'; | ||
import '../theme/widget.css'; | ||
import priorities from '@ckeditor/ckeditor5-utils/src/priorities'; | ||
@@ -100,5 +104,21 @@ /** | ||
// Handle custom keydown behaviour. | ||
this.listenTo( viewDocument, 'keydown', ( ...args ) => this._onKeydown( ...args ), { priority: 'high' } ); | ||
// There are two keydown listeners working on different priorities. This allows other | ||
// features such as WidgetTypeAround or TableKeyboard to attach their listeners in between | ||
// and customize the behavior even further in different content/selection scenarios. | ||
// | ||
// * The first listener handles changing the selection on arrow key press | ||
// if the widget is selected or if the selection is next to a widget and the widget | ||
// should become selected upon the arrow key press. | ||
// | ||
// * The second (late) listener makes sure the default browser action on arrow key press is | ||
// prevented when a widget is selected. This prevents the selection from being moved | ||
// from a fake selection container. | ||
this.listenTo( viewDocument, 'keydown', ( ...args ) => { | ||
this._handleSelectionChangeOnArrowKeyPress( ...args ); | ||
}, { priority: 'high' } ); | ||
this.listenTo( viewDocument, 'keydown', ( ...args ) => { | ||
this._preventDefaultOnArrowKeyPress( ...args ); | ||
}, { priority: priorities.get( 'high' ) - 20 } ); | ||
// Handle custom delete behaviour. | ||
@@ -167,4 +187,10 @@ this.listenTo( viewDocument, 'delete', ( evt, data ) => { | ||
/** | ||
* Handles {@link module:engine/view/document~Document#event:keydown keydown} events. | ||
* Handles {@link module:engine/view/document~Document#event:keydown keydown} events and changes | ||
* the model selection when: | ||
* | ||
* * arrow key is pressed when the widget is selected, | ||
* * the selection is next to a widget and the widget should become selected upon the arrow key press. | ||
* | ||
* See {@link #_preventDefaultOnArrowKeyPress}. | ||
* | ||
* @private | ||
@@ -174,17 +200,45 @@ * @param {module:utils/eventinfo~EventInfo} eventInfo | ||
*/ | ||
_onKeydown( eventInfo, domEventData ) { | ||
_handleSelectionChangeOnArrowKeyPress( eventInfo, domEventData ) { | ||
const keyCode = domEventData.keyCode; | ||
const isLtrContent = this.editor.locale.contentLanguageDirection === 'ltr'; | ||
const isForward = keyCode == keyCodes.arrowdown || keyCode == keyCodes[ isLtrContent ? 'arrowright' : 'arrowleft' ]; | ||
let wasHandled = false; | ||
// Checks if the keys were handled and then prevents the default event behaviour and stops | ||
// the propagation. | ||
if ( isArrowKeyCode( keyCode ) ) { | ||
wasHandled = this._handleArrowKeys( isForward ); | ||
} else if ( keyCode === keyCodes.enter ) { | ||
wasHandled = this._handleEnterKey( domEventData.shiftKey ); | ||
if ( !isArrowKeyCode( keyCode ) ) { | ||
return; | ||
} | ||
if ( wasHandled ) { | ||
const model = this.editor.model; | ||
const schema = model.schema; | ||
const modelSelection = model.document.selection; | ||
const objectElement = modelSelection.getSelectedElement(); | ||
const isForward = isForwardArrowKeyCode( keyCode, this.editor.locale.contentLanguageDirection ); | ||
// If object element is selected. | ||
if ( objectElement && schema.isObject( objectElement ) ) { | ||
const position = isForward ? modelSelection.getLastPosition() : modelSelection.getFirstPosition(); | ||
const newRange = schema.getNearestSelectionRange( position, isForward ? 'forward' : 'backward' ); | ||
if ( newRange ) { | ||
model.change( writer => { | ||
writer.setSelection( newRange ); | ||
} ); | ||
domEventData.preventDefault(); | ||
eventInfo.stop(); | ||
} | ||
return; | ||
} | ||
// If selection is next to object element. | ||
// Return if not collapsed. | ||
if ( !modelSelection.isCollapsed ) { | ||
return; | ||
} | ||
const objectElementNextToSelection = this._getObjectElementNextToSelection( isForward ); | ||
if ( objectElementNextToSelection && schema.isObject( objectElementNextToSelection ) ) { | ||
this._setSelectionOverElement( objectElementNextToSelection ); | ||
domEventData.preventDefault(); | ||
@@ -196,2 +250,33 @@ eventInfo.stop(); | ||
/** | ||
* Handles {@link module:engine/view/document~Document#event:keydown keydown} events and prevents | ||
* the default browser behavior to make sure the fake selection is not being moved from a fake selection | ||
* container. | ||
* | ||
* See {@link #_handleSelectionChangeOnArrowKeyPress}. | ||
* | ||
* @private | ||
* @param {module:utils/eventinfo~EventInfo} eventInfo | ||
* @param {module:engine/view/observer/domeventdata~DomEventData} domEventData | ||
*/ | ||
_preventDefaultOnArrowKeyPress( eventInfo, domEventData ) { | ||
const keyCode = domEventData.keyCode; | ||
// Checks if the keys were handled and then prevents the default event behaviour and stops | ||
// the propagation. | ||
if ( !isArrowKeyCode( keyCode ) ) { | ||
return; | ||
} | ||
const model = this.editor.model; | ||
const schema = model.schema; | ||
const objectElement = model.document.selection.getSelectedElement(); | ||
// If object element is selected. | ||
if ( objectElement && schema.isObject( objectElement ) ) { | ||
domEventData.preventDefault(); | ||
eventInfo.stop(); | ||
} | ||
} | ||
/** | ||
* Handles delete keys: backspace and delete. | ||
@@ -239,85 +324,5 @@ * | ||
/** | ||
* Handles arrow keys. | ||
* | ||
* @private | ||
* @param {Boolean} isForward Set to true if arrow key should be handled in forward direction. | ||
* @returns {Boolean|undefined} Returns `true` if keys were handled correctly. | ||
*/ | ||
_handleArrowKeys( isForward ) { | ||
const model = this.editor.model; | ||
const schema = model.schema; | ||
const modelDocument = model.document; | ||
const modelSelection = modelDocument.selection; | ||
const objectElement = modelSelection.getSelectedElement(); | ||
// If object element is selected. | ||
if ( objectElement && schema.isObject( objectElement ) ) { | ||
const position = isForward ? modelSelection.getLastPosition() : modelSelection.getFirstPosition(); | ||
const newRange = schema.getNearestSelectionRange( position, isForward ? 'forward' : 'backward' ); | ||
if ( newRange ) { | ||
model.change( writer => { | ||
writer.setSelection( newRange ); | ||
} ); | ||
} | ||
return true; | ||
} | ||
// If selection is next to object element. | ||
// Return if not collapsed. | ||
if ( !modelSelection.isCollapsed ) { | ||
return; | ||
} | ||
const objectElement2 = this._getObjectElementNextToSelection( isForward ); | ||
if ( !!objectElement2 && schema.isObject( objectElement2 ) ) { | ||
this._setSelectionOverElement( objectElement2 ); | ||
return true; | ||
} | ||
} | ||
/** | ||
* Handles the enter key, giving users and access to positions in the editable directly before | ||
* (<kbd>Shift</kbd>+<kbd>Enter</kbd>) or after (<kbd>Enter</kbd>) the selected widget. | ||
* It improves the UX, mainly when the widget is the first or last child of the root editable | ||
* and there's no other way to type after or before it. | ||
* | ||
* @private | ||
* @param {Boolean} isBackwards Set to true if the new paragraph is to be inserted before | ||
* the selected widget (<kbd>Shift</kbd>+<kbd>Enter</kbd>). | ||
* @returns {Boolean|undefined} Returns `true` if keys were handled correctly. | ||
*/ | ||
_handleEnterKey( isBackwards ) { | ||
const model = this.editor.model; | ||
const modelSelection = model.document.selection; | ||
const selectedElement = modelSelection.getSelectedElement(); | ||
if ( shouldInsertParagraph( selectedElement, model.schema ) ) { | ||
model.change( writer => { | ||
let position = writer.createPositionAt( selectedElement, isBackwards ? 'before' : 'after' ); | ||
const paragraph = writer.createElement( 'paragraph' ); | ||
// Split the parent when inside a block element. | ||
// https://github.com/ckeditor/ckeditor5/issues/1529 | ||
if ( model.schema.isBlock( selectedElement.parent ) ) { | ||
const paragraphLimit = model.schema.findAllowedParent( position, paragraph ); | ||
position = writer.split( position, paragraphLimit ).position; | ||
} | ||
writer.insert( paragraph, position ); | ||
writer.setSelection( paragraph, 'in' ); | ||
} ); | ||
return true; | ||
} | ||
} | ||
/** | ||
* Sets {@link module:engine/model/selection~Selection document's selection} over given element. | ||
* | ||
* @private | ||
* @protected | ||
* @param {module:engine/model/element~Element} element | ||
@@ -336,3 +341,3 @@ */ | ||
* | ||
* @private | ||
* @protected | ||
* @param {Boolean} forward Direction of checking. | ||
@@ -374,13 +379,2 @@ * @returns {module:engine/model/element~Element|null} | ||
// Returns 'true' if provided key code represents one of the arrow keys. | ||
// | ||
// @param {Number} keyCode | ||
// @returns {Boolean} | ||
function isArrowKeyCode( keyCode ) { | ||
return keyCode == keyCodes.arrowright || | ||
keyCode == keyCodes.arrowleft || | ||
keyCode == keyCodes.arrowup || | ||
keyCode == keyCodes.arrowdown; | ||
} | ||
// Returns `true` when element is a nested editable or is placed inside one. | ||
@@ -419,9 +413,1 @@ // | ||
} | ||
// Checks if enter key should insert paragraph. This should be done only on elements of type object (excluding inline objects). | ||
// | ||
// @param {module:engine/model/element~Element} element And element to check. | ||
// @param {module:engine/model/schema~Schema} schema | ||
function shouldInsertParagraph( element, schema ) { | ||
return element && schema.isObject( element ) && !schema.isInline( element ); | ||
} |
@@ -10,2 +10,4 @@ /** | ||
/* global console */ | ||
import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; | ||
@@ -19,3 +21,3 @@ import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon'; | ||
} from './utils'; | ||
import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; | ||
import CKEditorError, { attachLinkToDocumentation } from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; | ||
@@ -129,2 +131,17 @@ /** | ||
register( toolbarId, { ariaLabel, items, getRelatedElement, balloonClassName = 'ck-toolbar-container' } ) { | ||
// Trying to register a toolbar without any item. | ||
if ( !items.length ) { | ||
/** | ||
* When {@link #register} a new toolbar, you need to provide a non-empty array with | ||
* the items that will be inserted into the toolbar. | ||
* | ||
* @error widget-toolbar-no-items | ||
*/ | ||
console.warn( | ||
attachLinkToDocumentation( 'widget-toolbar-no-items: Trying to register a toolbar without items.' ), { toolbarId } | ||
); | ||
return; | ||
} | ||
const editor = this.editor; | ||
@@ -131,0 +148,0 @@ const t = editor.t; |
@@ -13,2 +13,8 @@ /** | ||
/** | ||
* The name of the type around model selection attribute responsible for | ||
* displaying a "fake caret" next to a selected widget. | ||
*/ | ||
export const TYPE_AROUND_SELECTION_ATTRIBUTE = 'widget-type-around'; | ||
/** | ||
* Checks if an element is a widget that qualifies to get the type around UI. | ||
@@ -41,3 +47,3 @@ * | ||
* @param {HTMLElement} domElement | ||
* @returns {String} Either `'before'` or `'after'`. | ||
* @returns {'before'|'after'} Position of the button. | ||
*/ | ||
@@ -62,37 +68,11 @@ export function getTypeAroundButtonPosition( domElement ) { | ||
/** | ||
* For the passed widget view element, this helper returns an array of positions which | ||
* correspond to the "tight spots" around the widget which cannot be accessed due to | ||
* limitations of selection rendering in web browsers. | ||
* For the passed selection instance, it returns the position of the "fake caret" displayed next to a widget. | ||
* | ||
* @param {module:engine/view/element~Element} widgetViewElement | ||
* @returns {Array.<String>} | ||
* **Note**: If the "fake caret" is not currently displayed, `null` is returned. | ||
* | ||
* @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection | ||
* @returns {'before'|'after'|null} Position of the fake caret or `null` when none is preset. | ||
*/ | ||
export function getWidgetTypeAroundPositions( widgetViewElement ) { | ||
const positions = []; | ||
if ( isFirstChild( widgetViewElement ) || hasPreviousWidgetSibling( widgetViewElement ) ) { | ||
positions.push( 'before' ); | ||
} | ||
if ( isLastChild( widgetViewElement ) || hasNextWidgetSibling( widgetViewElement ) ) { | ||
positions.push( 'after' ); | ||
} | ||
return positions; | ||
export function getTypeAroundFakeCaretPosition( selection ) { | ||
return selection.getAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ); | ||
} | ||
function isFirstChild( widget ) { | ||
return !widget.previousSibling; | ||
} | ||
function isLastChild( widget ) { | ||
return !widget.nextSibling; | ||
} | ||
function hasPreviousWidgetSibling( widget ) { | ||
return widget.previousSibling && isWidget( widget.previousSibling ); | ||
} | ||
function hasNextWidgetSibling( widget ) { | ||
return widget.nextSibling && isWidget( widget.nextSibling ); | ||
} |
@@ -13,13 +13,23 @@ /** | ||
import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; | ||
import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; | ||
import Template from '@ckeditor/ckeditor5-ui/src/template'; | ||
import { | ||
isArrowKeyCode, | ||
isForwardArrowKeyCode, | ||
keyCodes | ||
} from '@ckeditor/ckeditor5-utils/src/keyboard'; | ||
import priorities from '@ckeditor/ckeditor5-utils/src/priorities'; | ||
import { | ||
isTypeAroundWidget, | ||
getWidgetTypeAroundPositions, | ||
getClosestTypeAroundDomButton, | ||
getTypeAroundButtonPosition, | ||
getClosestWidgetViewElement | ||
getClosestWidgetViewElement, | ||
getTypeAroundFakeCaretPosition, | ||
TYPE_AROUND_SELECTION_ATTRIBUTE | ||
} from './utils'; | ||
import { | ||
isNonTypingKeystroke | ||
} from '@ckeditor/ckeditor5-typing/src/utils/injectunsafekeystrokeshandling'; | ||
import returnIcon from '../../theme/icons/return-arrow.svg'; | ||
@@ -50,9 +60,2 @@ import '../../theme/widgettypearound.css'; | ||
*/ | ||
static get requires() { | ||
return [ Paragraph ]; | ||
} | ||
/** | ||
* @inheritDoc | ||
*/ | ||
static get pluginName() { | ||
@@ -69,12 +72,10 @@ return 'WidgetTypeAround'; | ||
/** | ||
* A set containing all widgets in all editor roots that have the type around UI injected in | ||
* {@link #_enableTypeAroundUIInjection}. | ||
* A reference to the model widget element that has the "fake caret" active | ||
* on either side of it. It is later used to remove CSS classes associated with the "fake caret" | ||
* when the widget no longer needs it. | ||
* | ||
* Keeping track of them saves time, for instance, when updating their CSS classes. | ||
* | ||
* @private | ||
* @readonly | ||
* @member {Set} #_widgetsWithTypeAroundUI | ||
* @member {module:engine/model/element~Element|null} | ||
*/ | ||
this._widgetsWithTypeAroundUI = new Set(); | ||
this._currentFakeCaretModelElement = null; | ||
} | ||
@@ -85,4 +86,10 @@ | ||
*/ | ||
destroy() { | ||
this._widgetsWithTypeAroundUI.clear(); | ||
init() { | ||
this._enableTypeAroundUIInjection(); | ||
this._enableInsertingParagraphsOnButtonClick(); | ||
this._enableInsertingParagraphsOnEnterKeypress(); | ||
this._enableInsertingParagraphsOnTypingKeystroke(); | ||
this._enableTypeAroundFakeCaretActivationUsingKeyboardArrows(); | ||
this._enableDeleteIntegration(); | ||
this._enableInsertContentIntegration(); | ||
} | ||
@@ -93,6 +100,4 @@ | ||
*/ | ||
init() { | ||
this._enableTypeAroundUIInjection(); | ||
this._enableDetectionOfTypeAroundWidgets(); | ||
this._enableInsertingParagraphsOnButtonClick(); | ||
destroy() { | ||
this._currentFakeCaretModelElement = null; | ||
} | ||
@@ -107,19 +112,11 @@ | ||
* @protected | ||
* @param {module:engine/view/element~Element} widgetViewElement The view widget element next to which a paragraph is inserted. | ||
* @param {module:engine/model/element~Element} widgetModelElement The model widget element next to which a paragraph is inserted. | ||
* @param {'before'|'after'} position The position where the paragraph is inserted. Either `'before'` or `'after'` the widget. | ||
*/ | ||
_insertParagraph( widgetViewElement, position ) { | ||
_insertParagraph( widgetModelElement, position ) { | ||
const editor = this.editor; | ||
const editingView = editor.editing.view; | ||
const widgetModelElement = editor.editing.mapper.toModelElement( widgetViewElement ); | ||
let modelPosition; | ||
if ( position === 'before' ) { | ||
modelPosition = editor.model.createPositionBefore( widgetModelElement ); | ||
} else { | ||
modelPosition = editor.model.createPositionAfter( widgetModelElement ); | ||
} | ||
editor.execute( 'insertParagraph', { | ||
position: modelPosition | ||
position: editor.model.createPositionAt( widgetModelElement, position ) | ||
} ); | ||
@@ -132,2 +129,31 @@ | ||
/** | ||
* Similar to {@link #_insertParagraph}, this method inserts a paragraph except that it | ||
* does not expect a position but it performs the insertion next to a selected widget | ||
* according to the "widget-type-around" model selection attribute value ("fake caret" position). | ||
* | ||
* Because this method requires the "widget-type-around" attribute to be set, | ||
* the insertion can only happen when the widget "fake caret" is active (e.g. activated | ||
* using the keyboard). | ||
* | ||
* @private | ||
* @returns {Boolean} Returns `true` when the paragraph was inserted (the attribute was present) and `false` otherwise. | ||
*/ | ||
_insertParagraphAccordingToFakeCaretPosition() { | ||
const editor = this.editor; | ||
const model = editor.model; | ||
const modelSelection = model.document.selection; | ||
const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition( modelSelection ); | ||
if ( !typeAroundFakeCaretPosition ) { | ||
return false; | ||
} | ||
const selectedModelElement = modelSelection.getSelectedElement(); | ||
this._insertParagraph( selectedModelElement, typeAroundFakeCaretPosition ); | ||
return true; | ||
} | ||
/** | ||
* Creates a listener in the editing conversion pipeline that injects the type around | ||
@@ -156,7 +182,2 @@ * UI into every single widget instance created in the editor. | ||
injectUIIntoWidget( conversionApi.writer, buttonTitles, viewElement ); | ||
// Keep track of widgets that have the type around UI injected. | ||
// In the #_enableDetectionOfTypeAroundWidgets() we will iterate only over these | ||
// widgets instead of all children of the root. This should improve the performance. | ||
this._widgetsWithTypeAroundUI.add( viewElement ); | ||
} | ||
@@ -167,34 +188,222 @@ }, { priority: 'low' } ); | ||
/** | ||
* Registers an editing view post-fixer which checks all block widgets in the content | ||
* and adds CSS classes to these which should have the typing around (UI) enabled | ||
* and visible for the users. | ||
* Brings support for the "fake caret" that appears when either: | ||
* | ||
* * the selection moves from a position next to a widget (to a widget) using arrow keys, | ||
* * the arrow key is pressed when the widget is already selected. | ||
* | ||
* The "fake caret" lets the user know that they can start typing or just press | ||
* enter to insert a paragraph at the position next to a widget as suggested by the "fake caret". | ||
* | ||
* The "fake caret" disappears when the user changes the selection or the editor | ||
* gets blurred. | ||
* | ||
* The whole idea is as follows: | ||
* | ||
* 1. A user does one of the 2 scenarios described at the beginning. | ||
* 2. The "keydown" listener is executed and the decision is made whether to show or hide the "fake caret". | ||
* 3. If it should show up, the "widget-type-around" model selection attribute is set indicating | ||
* on which side of the widget it should appear. | ||
* 4. The selection dispatcher reacts to the selection attribute and sets CSS classes responsible for the | ||
* "fake caret" on the view widget. | ||
* 5. If the "fake caret" should disappear, the selection attribute is removed and the dispatcher | ||
* does the CSS class clean-up in the view. | ||
* 6. Additionally, "change:range" and FocusTracker#isFocused listeners also remove the selection | ||
* attribute (the former also removes widget CSS classes). | ||
* | ||
* @private | ||
*/ | ||
_enableDetectionOfTypeAroundWidgets() { | ||
_enableTypeAroundFakeCaretActivationUsingKeyboardArrows() { | ||
const editor = this.editor; | ||
const model = editor.model; | ||
const modelSelection = model.document.selection; | ||
const schema = model.schema; | ||
const editingView = editor.editing.view; | ||
// This is the main listener responsible for the "fake caret". | ||
// Note: The priority must precede the default Widget class keydown handler ("high") and the | ||
// TableKeyboard keydown handler ("high-10"). | ||
editingView.document.on( 'keydown', ( evt, domEventData ) => { | ||
if ( isArrowKeyCode( domEventData.keyCode ) ) { | ||
this._handleArrowKeyPress( evt, domEventData ); | ||
} | ||
}, { priority: priorities.get( 'high' ) + 10 } ); | ||
// This listener makes sure the widget type around selection attribute will be gone from the model | ||
// selection as soon as the model range changes. This attribute only makes sense when a widget is selected | ||
// (and the "fake horizontal caret" is visible) so whenever the range changes (e.g. selection moved somewhere else), | ||
// let's get rid of the attribute so that the selection downcast dispatcher isn't even bothered. | ||
modelSelection.on( 'change:range', ( evt, data ) => { | ||
// Do not reset the selection attribute when the change was indirect. | ||
if ( !data.directChange ) { | ||
return; | ||
} | ||
// Get rid of the widget type around attribute of the selection on every change:range. | ||
// If the range changes, it means for sure, the user is no longer in the active ("fake horizontal caret") mode. | ||
editor.model.change( writer => { | ||
writer.removeSelectionAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ); | ||
} ); | ||
} ); | ||
// Get rid of the widget type around attribute of the selection on every document change | ||
// that makes widget not selected any more (i.e. widget was removed). | ||
model.document.on( 'change:data', () => { | ||
const selectedModelElement = modelSelection.getSelectedElement(); | ||
if ( selectedModelElement ) { | ||
const selectedViewElement = editor.editing.mapper.toViewElement( selectedModelElement ); | ||
if ( isTypeAroundWidget( selectedViewElement, selectedModelElement, schema ) ) { | ||
return; | ||
} | ||
} | ||
editor.model.change( writer => { | ||
writer.removeSelectionAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ); | ||
} ); | ||
} ); | ||
// React to changes of the model selection attribute made by the arrow keys listener. | ||
// If the block widget is selected and the attribute changes, downcast the attribute to special | ||
// CSS classes associated with the active ("fake horizontal caret") mode of the widget. | ||
editor.editing.downcastDispatcher.on( 'selection', ( evt, data, conversionApi ) => { | ||
const writer = conversionApi.writer; | ||
if ( this._currentFakeCaretModelElement ) { | ||
const selectedViewElement = conversionApi.mapper.toViewElement( this._currentFakeCaretModelElement ); | ||
if ( selectedViewElement ) { | ||
// Get rid of CSS classes associated with the active ("fake horizontal caret") mode from the view widget. | ||
writer.removeClass( POSSIBLE_INSERTION_POSITIONS.map( positionToWidgetCssClass ), selectedViewElement ); | ||
this._currentFakeCaretModelElement = null; | ||
} | ||
} | ||
const selectedModelElement = data.selection.getSelectedElement(); | ||
if ( !selectedModelElement ) { | ||
return; | ||
} | ||
const selectedViewElement = conversionApi.mapper.toViewElement( selectedModelElement ); | ||
if ( !isTypeAroundWidget( selectedViewElement, selectedModelElement, schema ) ) { | ||
return; | ||
} | ||
const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition( data.selection ); | ||
if ( !typeAroundFakeCaretPosition ) { | ||
return; | ||
} | ||
writer.addClass( positionToWidgetCssClass( typeAroundFakeCaretPosition ), selectedViewElement ); | ||
// Remember the view widget that got the "fake-caret" CSS class. This class should be removed ASAP when the | ||
// selection changes | ||
this._currentFakeCaretModelElement = selectedModelElement; | ||
} ); | ||
this.listenTo( editor.ui.focusTracker, 'change:isFocused', ( evt, name, isFocused ) => { | ||
if ( !isFocused ) { | ||
editor.model.change( writer => { | ||
writer.removeSelectionAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ); | ||
} ); | ||
} | ||
} ); | ||
function positionToWidgetCssClass( position ) { | ||
return `ck-widget_can-type-around_${ position }`; | ||
return `ck-widget_type-around_show-fake-caret_${ position }`; | ||
} | ||
} | ||
editingView.document.registerPostFixer( writer => { | ||
for ( const widgetViewElement of this._widgetsWithTypeAroundUI ) { | ||
// If the widget is no longer attached to the root (for instance, because it was removed), | ||
// there is no need to update its classes and we can safely forget about it. | ||
if ( !widgetViewElement.isAttached() ) { | ||
this._widgetsWithTypeAroundUI.delete( widgetViewElement ); | ||
} else { | ||
// Update widgets' classes depending on possible positions for paragraph insertion. | ||
const positions = getWidgetTypeAroundPositions( widgetViewElement ); | ||
/** | ||
* A listener executed on each "keydown" in the view document, a part of | ||
* {@link #_enableTypeAroundFakeCaretActivationUsingKeyboardArrows}. | ||
* | ||
* It decides whether the arrow key press should activate the "fake caret" or not (also whether it should | ||
* be deactivated). | ||
* | ||
* The "fake caret" activation is done by setting the "widget-type-around" model selection attribute | ||
* in this listener and stopping&preventing the event that would normally be handled by the Widget | ||
* plugin that is responsible for the regular keyboard navigation near/across all widgets (that | ||
* includes inline widgets, which are ignored by the WidgetTypeAround plugin). | ||
* | ||
* @private | ||
*/ | ||
_handleArrowKeyPress( evt, domEventData ) { | ||
const editor = this.editor; | ||
const model = editor.model; | ||
const modelSelection = model.document.selection; | ||
const schema = model.schema; | ||
const editingView = editor.editing.view; | ||
// Remove all classes. In theory we could remove only these that will not be added a few lines later, | ||
// but since there are only two... KISS. | ||
writer.removeClass( POSSIBLE_INSERTION_POSITIONS.map( positionToWidgetCssClass ), widgetViewElement ); | ||
const keyCode = domEventData.keyCode; | ||
const isForward = isForwardArrowKeyCode( keyCode, editor.locale.contentLanguageDirection ); | ||
const selectedViewElement = editingView.document.selection.getSelectedElement(); | ||
const selectedModelElement = editor.editing.mapper.toModelElement( selectedViewElement ); | ||
let shouldStopAndPreventDefault; | ||
// Set CSS classes related to possible positions. They are used so the UI knows which buttons to display. | ||
writer.addClass( positions.map( positionToWidgetCssClass ), widgetViewElement ); | ||
// Handle keyboard navigation when a type-around-compatible widget is currently selected. | ||
if ( isTypeAroundWidget( selectedViewElement, selectedModelElement, schema ) ) { | ||
shouldStopAndPreventDefault = this._handleArrowKeyPressOnSelectedWidget( isForward ); | ||
} | ||
// Handle keyboard arrow navigation when the selection is next to a type-around-compatible widget | ||
// and the widget is about to be selected. | ||
else if ( modelSelection.isCollapsed ) { | ||
shouldStopAndPreventDefault = this._handleArrowKeyPressWhenSelectionNextToAWidget( isForward ); | ||
} | ||
if ( shouldStopAndPreventDefault ) { | ||
domEventData.preventDefault(); | ||
evt.stop(); | ||
} | ||
} | ||
/** | ||
* Handles the keyboard navigation on "keydown" when a widget is currently selected and activates or deactivates | ||
* the "fake caret" for that widget, depending on the current value of the "widget-type-around" model | ||
* selection attribute and the direction of the pressed arrow key. | ||
* | ||
* @private | ||
* @param {Boolean} isForward `true` when the pressed arrow key was responsible for the forward model selection movement | ||
* as in {@link module:utils/keyboard~isForwardArrowKeyCode}. | ||
* @returns {Boolean} `true` when the key press was handled and no other keydown listener of the editor should | ||
* process the event any further. `false` otherwise. | ||
*/ | ||
_handleArrowKeyPressOnSelectedWidget( isForward ) { | ||
const editor = this.editor; | ||
const model = editor.model; | ||
const modelSelection = model.document.selection; | ||
const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition( modelSelection ); | ||
return model.change( writer => { | ||
// If the fake caret is displayed... | ||
if ( typeAroundFakeCaretPosition ) { | ||
const isLeavingWidget = typeAroundFakeCaretPosition === ( isForward ? 'after' : 'before' ); | ||
// If the keyboard arrow works against the value of the selection attribute... | ||
// then remove the selection attribute but prevent default DOM actions | ||
// and do not let the Widget plugin listener move the selection. This brings | ||
// the widget back to the state, for instance, like if was selected using the mouse. | ||
// | ||
// **Note**: If leaving the widget when the "fake caret" is active, then the default | ||
// Widget handler will change the selection and, in turn, this will automatically discard | ||
// the selection attribute. | ||
if ( !isLeavingWidget ) { | ||
writer.removeSelectionAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ); | ||
return true; | ||
} | ||
} | ||
// If the fake caret wasn't displayed, let's set it now according to the direction of the arrow | ||
// key press. This also means we cannot let the Widget plugin listener move the selection. | ||
else { | ||
writer.setSelectionAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE, isForward ? 'after' : 'before' ); | ||
return true; | ||
} | ||
return false; | ||
} ); | ||
@@ -204,2 +413,41 @@ } | ||
/** | ||
* Handles the keyboard navigation on "keydown" when **no** widget is selected but the selection is **directly** next | ||
* to one and upon the "fake caret" should become active for this widget upon arrow key press | ||
* (AKA entering/selecting the widget). | ||
* | ||
* **Note**: This code mirrors the implementation from the Widget plugin but also adds the selection attribute. | ||
* Unfortunately, there's no safe way to let the Widget plugin do the selection part first and then just set the | ||
* selection attribute here in the WidgetTypeAround plugin. This is why this code must duplicate some from the Widget plugin. | ||
* | ||
* @private | ||
* @param {Boolean} isForward `true` when the pressed arrow key was responsible for the forward model selection movement | ||
* as in {@link module:utils/keyboard~isForwardArrowKeyCode}. | ||
* @returns {Boolean} `true` when the key press was handled and no other keydown listener of the editor should | ||
* process the event any further. `false` otherwise. | ||
*/ | ||
_handleArrowKeyPressWhenSelectionNextToAWidget( isForward ) { | ||
const editor = this.editor; | ||
const model = editor.model; | ||
const schema = model.schema; | ||
const widgetPlugin = editor.plugins.get( 'Widget' ); | ||
// This is the widget the selection is about to be set on. | ||
const modelElementNextToSelection = widgetPlugin._getObjectElementNextToSelection( isForward ); | ||
const viewElementNextToSelection = editor.editing.mapper.toViewElement( modelElementNextToSelection ); | ||
if ( isTypeAroundWidget( viewElementNextToSelection, modelElementNextToSelection, schema ) ) { | ||
model.change( writer => { | ||
widgetPlugin._setSelectionOverElement( modelElementNextToSelection ); | ||
writer.setSelectionAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE, isForward ? 'before' : 'after' ); | ||
} ); | ||
// The change() block above does the same job as the Widget plugin. The event can | ||
// be safely canceled. | ||
return true; | ||
} | ||
return false; | ||
} | ||
/** | ||
* Registers a `mousedown` listener for the view document which intercepts events | ||
@@ -224,4 +472,5 @@ * coming from the type around UI, which happens when a user clicks one of the buttons | ||
const widgetViewElement = getClosestWidgetViewElement( button, editingView.domConverter ); | ||
const widgetModelElement = editor.editing.mapper.toModelElement( widgetViewElement ); | ||
this._insertParagraph( widgetViewElement, buttonPosition ); | ||
this._insertParagraph( widgetModelElement, buttonPosition ); | ||
@@ -232,2 +481,208 @@ domEventData.preventDefault(); | ||
} | ||
/** | ||
* Creates the "enter" key listener on the view document that allows the user to insert a paragraph | ||
* near the widget when either: | ||
* | ||
* * The "fake caret" was first activated using the arrow keys, | ||
* * The entire widget is selected in the model. | ||
* | ||
* In the first case, the new paragraph is inserted according to the "widget-type-around" selection | ||
* attribute (see {@link #_handleArrowKeyPress}). | ||
* | ||
* It the second case, the new paragraph is inserted based on whether a soft (Shift+Enter) keystroke | ||
* was pressed or not. | ||
* | ||
* @private | ||
*/ | ||
_enableInsertingParagraphsOnEnterKeypress() { | ||
const editor = this.editor; | ||
const editingView = editor.editing.view; | ||
this.listenTo( editingView.document, 'enter', ( evt, domEventData ) => { | ||
const selectedViewElement = editingView.document.selection.getSelectedElement(); | ||
const selectedModelElement = editor.editing.mapper.toModelElement( selectedViewElement ); | ||
const schema = editor.model.schema; | ||
let wasHandled; | ||
// First check if the widget is selected and there's a type around selection attribute associated | ||
// with the "fake caret" that would tell where to insert a new paragraph. | ||
if ( this._insertParagraphAccordingToFakeCaretPosition() ) { | ||
wasHandled = true; | ||
} | ||
// Then, if there is no selection attribute associated with the "fake caret", check if the widget | ||
// simply is selected and create a new paragraph according to the keystroke (Shift+)Enter. | ||
else if ( isTypeAroundWidget( selectedViewElement, selectedModelElement, schema ) ) { | ||
this._insertParagraph( selectedModelElement, domEventData.isSoft ? 'before' : 'after' ); | ||
wasHandled = true; | ||
} | ||
if ( wasHandled ) { | ||
domEventData.preventDefault(); | ||
evt.stop(); | ||
} | ||
} ); | ||
} | ||
/** | ||
* Similar to the {@link #_enableInsertingParagraphsOnEnterKeypress}, it allows the user | ||
* to insert a paragraph next to a widget when the "fake caret" was activated using arrow | ||
* keys but it responds to "typing keystrokes" instead of "enter". | ||
* | ||
* "Typing keystrokes" are keystrokes that insert new content into the document | ||
* like, for instance, letters ("a") or numbers ("4"). The "keydown" listener enabled by this method | ||
* will insert a new paragraph according to the "widget-type-around" model selection attribute | ||
* as the user simply starts typing, which creates the impression that the "fake caret" | ||
* behaves like a "real one" rendered by the browser (AKA your text appears where the caret was). | ||
* | ||
* **Note**: ATM this listener creates 2 undo steps: one for the "insertParagraph" command | ||
* and the second for the actual typing. It's not a disaster but this may need to be fixed | ||
* sooner or later. | ||
* | ||
* Learn more in {@link module:typing/utils/injectunsafekeystrokeshandling}. | ||
* | ||
* @private | ||
*/ | ||
_enableInsertingParagraphsOnTypingKeystroke() { | ||
const editor = this.editor; | ||
const editingView = editor.editing.view; | ||
const keyCodesHandledSomewhereElse = [ | ||
keyCodes.enter, | ||
keyCodes.delete, | ||
keyCodes.backspace | ||
]; | ||
// Note: The priority must precede the default Widget class keydown handler ("high") and the | ||
// TableKeyboard keydown handler ("high + 1"). | ||
editingView.document.on( 'keydown', ( evt, domEventData ) => { | ||
// Don't handle enter/backspace/delete here. They are handled in dedicated listeners. | ||
if ( !keyCodesHandledSomewhereElse.includes( domEventData.keyCode ) && !isNonTypingKeystroke( domEventData ) ) { | ||
this._insertParagraphAccordingToFakeCaretPosition(); | ||
} | ||
}, { priority: priorities.get( 'high' ) + 1 } ); | ||
} | ||
/** | ||
* It creates a "delete" event listener on the view document to handle cases when delete/backspace | ||
* is pressed and the "fake caret" is currently active. | ||
* | ||
* The "fake caret" should create an illusion of a "real browser caret" so that when it appears | ||
* before/after a widget, pressing delete/backspace should remove a widget or delete a content | ||
* before/after a widget (depending on the content surrounding the widget). | ||
* | ||
* @private | ||
*/ | ||
_enableDeleteIntegration() { | ||
const editor = this.editor; | ||
const editingView = editor.editing.view; | ||
const model = editor.model; | ||
const schema = model.schema; | ||
// Note: The priority must precede the default Widget class delete handler. | ||
this.listenTo( editingView.document, 'delete', ( evt, domEventData ) => { | ||
const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition( model.document.selection ); | ||
// This listener handles only these cases when the "fake caret" is active. | ||
if ( !typeAroundFakeCaretPosition ) { | ||
return; | ||
} | ||
const direction = domEventData.direction; | ||
const selectedModelWidget = model.document.selection.getSelectedElement(); | ||
const isFakeCaretBefore = typeAroundFakeCaretPosition === 'before'; | ||
const isForwardDelete = direction == 'forward'; | ||
const shouldDeleteEntireWidget = isFakeCaretBefore === isForwardDelete; | ||
if ( shouldDeleteEntireWidget ) { | ||
editor.execute( 'delete', { | ||
selection: model.createSelection( selectedModelWidget, 'on' ) | ||
} ); | ||
} else { | ||
const range = schema.getNearestSelectionRange( | ||
model.createPositionAt( selectedModelWidget, typeAroundFakeCaretPosition ), | ||
direction | ||
); | ||
// If there is somewhere to move selection to, then there will be something to delete. | ||
if ( range ) { | ||
// If the range is NOT collapsed, then we know that the range contains an object (see getNearestSelectionRange() docs). | ||
if ( !range.isCollapsed ) { | ||
model.change( writer => { | ||
writer.setSelection( range ); | ||
editor.execute( isForwardDelete ? 'forwardDelete' : 'delete' ); | ||
} ); | ||
} else { | ||
const probe = model.createSelection( range.start ); | ||
model.modifySelection( probe, { direction } ); | ||
// If the range is collapsed, let's see if a non-collapsed range exists that can could be deleted. | ||
// If such range exists, use the editor command because it it safe for collaboration (it merges where it can). | ||
if ( !probe.focus.isEqual( range.start ) ) { | ||
model.change( writer => { | ||
writer.setSelection( range ); | ||
editor.execute( isForwardDelete ? 'forwardDelete' : 'delete' ); | ||
} ); | ||
} | ||
// If there is no non-collapsed range to be deleted then we are sure that there is an empty element | ||
// next to a widget that should be removed. "delete" and "forwardDelete" commands cannot get rid of it | ||
// so calling Model#deleteContent here manually. | ||
else { | ||
const deepestEmptyRangeAncestor = getDeepestEmptyElementAncestor( schema, range.start.parent ); | ||
model.deleteContent( model.createSelection( deepestEmptyRangeAncestor, 'on' ), { | ||
doNotAutoparagraph: true | ||
} ); | ||
} | ||
} | ||
} | ||
} | ||
// If some content was deleted, don't let the handler from the Widget plugin kick in. | ||
// If nothing was deleted, then the default handler will have nothing to do anyway. | ||
domEventData.preventDefault(); | ||
evt.stop(); | ||
}, { priority: priorities.get( 'high' ) + 1 } ); | ||
} | ||
/** | ||
* Attaches the {@link module:engine/model/model~Model#event:insertContent} event listener that, for instance, allows the user to paste | ||
* content near a widget when the "fake caret" was first activated using the arrow keys. | ||
* | ||
* The content is inserted according to the "widget-type-around" selection attribute (see {@link #_handleArrowKeyPress}). | ||
* | ||
* @private | ||
*/ | ||
_enableInsertContentIntegration() { | ||
const editor = this.editor; | ||
const model = this.editor.model; | ||
const documentSelection = model.document.selection; | ||
this.listenTo( editor.model, 'insertContent', ( evt, [ content, selectable ] ) => { | ||
if ( selectable && !selectable.is( 'documentSelection' ) ) { | ||
return; | ||
} | ||
const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition( documentSelection ); | ||
if ( !typeAroundFakeCaretPosition ) { | ||
return; | ||
} | ||
evt.stop(); | ||
return model.change( writer => { | ||
const selectedElement = documentSelection.getSelectedElement(); | ||
const position = model.createPositionAt( selectedElement, typeAroundFakeCaretPosition ); | ||
const selection = writer.createSelection( position ); | ||
const result = model.insertContent( content, selection ); | ||
writer.setSelection( selection ); | ||
return result; | ||
} ); | ||
}, { priority: 'high' } ); | ||
} | ||
} | ||
@@ -247,2 +702,3 @@ | ||
injectButtons( wrapperDomElement, buttonTitles ); | ||
injectFakeCaret( wrapperDomElement ); | ||
@@ -282,1 +738,40 @@ return wrapperDomElement; | ||
} | ||
// @param {HTMLElement} wrapperDomElement | ||
function injectFakeCaret( wrapperDomElement ) { | ||
const caretTemplate = new Template( { | ||
tag: 'div', | ||
attributes: { | ||
class: [ | ||
'ck', | ||
'ck-widget__type-around__fake-caret' | ||
] | ||
} | ||
} ); | ||
wrapperDomElement.appendChild( caretTemplate.render() ); | ||
} | ||
// Returns the ancestor of an element closest to the root which is empty. For instance, | ||
// for `<baz>`: | ||
// | ||
// <foo>abc<bar><baz></baz></bar></foo> | ||
// | ||
// it returns `<bar>`. | ||
// | ||
// @param {module:engine/model/schema~Schema} schema | ||
// @param {module:engine/model/element~Element} element | ||
// @returns {module:engine/model/element~Element|null} | ||
function getDeepestEmptyElementAncestor( schema, element ) { | ||
let deepestEmptyAncestor = element; | ||
for ( const ancestor of element.getAncestors( { parentFirst: true } ) ) { | ||
if ( ancestor.childCount > 1 || schema.isLimit( ancestor ) ) { | ||
break; | ||
} | ||
deepestEmptyAncestor = ancestor; | ||
} | ||
return deepestEmptyAncestor; | ||
} |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
178226
2954
6
15
+ Added@ckeditor/ckeditor5-core@20.0.0(transitive)
+ Added@ckeditor/ckeditor5-engine@20.0.0(transitive)
+ Added@ckeditor/ckeditor5-typing@20.0.0(transitive)
+ Added@ckeditor/ckeditor5-ui@20.0.0(transitive)
+ Added@ckeditor/ckeditor5-utils@20.0.0(transitive)
- Removed@ckeditor/ckeditor5-core@19.0.1(transitive)
- Removed@ckeditor/ckeditor5-engine@19.0.1(transitive)
- Removed@ckeditor/ckeditor5-ui@19.0.1(transitive)
- Removed@ckeditor/ckeditor5-utils@19.0.2(transitive)
Updatedlodash-es@^4.17.15