Huge News!Announcing our $40M Series B led by Abstract Ventures.Learn More
Socket
Sign inDemoInstall
Socket

@ckeditor/ckeditor5-widget

Package Overview
Dependencies
Maintainers
1
Versions
705
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ckeditor/ckeditor5-widget - npm Package Compare versions

Comparing version 19.1.0 to 20.0.0

42

package.json
{
"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

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc