@ckeditor/ckeditor5-widget
Version:
Widget API for CKEditor 5.
775 lines (774 loc) • 40.3 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module widget/widgettypearound/widgettypearound
*/
import { Plugin } from '@ckeditor/ckeditor5-core';
import { IconReturnArrow } from '@ckeditor/ckeditor5-icons';
import { Template } from '@ckeditor/ckeditor5-ui';
import { Enter } from '@ckeditor/ckeditor5-enter';
import { Delete } from '@ckeditor/ckeditor5-typing';
import { env, isForwardArrowKeyCode } from '@ckeditor/ckeditor5-utils';
import { isTypeAroundWidget, getClosestTypeAroundDomButton, getTypeAroundButtonPosition, getClosestWidgetViewElement, getTypeAroundFakeCaretPosition, TYPE_AROUND_SELECTION_ATTRIBUTE } from './utils.js';
import { isWidget } from '../utils.js';
// @if CK_DEBUG_TYPING // const { _buildLogMessage } = require( '@ckeditor/ckeditor5-engine/src/dev-utils/utils.js' );
import '../../theme/widgettypearound.css';
const POSSIBLE_INSERTION_POSITIONS = ['before', 'after'];
// Do the SVG parsing once and then clone the result <svg> DOM element for each new button.
const RETURN_ARROW_ICON_ELEMENT = new DOMParser().parseFromString(IconReturnArrow, 'image/svg+xml').firstChild;
const PLUGIN_DISABLED_EDITING_ROOT_CLASS = 'ck-widget__type-around_disabled';
/**
* A plugin that allows users to type around widgets where normally it is impossible to place the caret due
* to limitations of web browsers. These "tight spots" occur, for instance, before (or after) a widget being
* the first (or last) child of its parent or between two block widgets.
*
* This plugin extends the {@link module:widget/widget~Widget `Widget`} plugin and injects the user interface
* with two buttons into each widget instance in the editor. Each of the buttons can be clicked by the
* user if the widget is next to the "tight spot". Once clicked, a paragraph is created with the selection anchored
* in it so that users can type (or insert content, paste, etc.) straight away.
*/
export class WidgetTypeAround extends Plugin {
/**
* 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.
*/
_currentFakeCaretModelElement = null;
/**
* @inheritDoc
*/
static get pluginName() {
return 'WidgetTypeAround';
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
static get requires() {
return [Enter, Delete];
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const editingView = editor.editing.view;
// Set a CSS class on the view editing root when the plugin is disabled so all the buttons
// and lines visually disappear. All the interactions are disabled in individual plugin methods.
this.on('change:isEnabled', (evt, data, isEnabled) => {
editingView.change(writer => {
for (const root of editingView.document.roots) {
if (isEnabled) {
writer.removeClass(PLUGIN_DISABLED_EDITING_ROOT_CLASS, root);
}
else {
writer.addClass(PLUGIN_DISABLED_EDITING_ROOT_CLASS, root);
}
}
});
if (!isEnabled) {
editor.model.change(writer => {
writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
});
}
});
this._enableTypeAroundUIInjection();
this._enableInsertingParagraphsOnButtonClick();
this._enableInsertingParagraphsOnEnterKeypress();
this._enableInsertingParagraphsOnTypingKeystroke();
this._enableTypeAroundFakeCaretActivationUsingKeyboardArrows();
this._enableDeleteIntegration();
this._enableInsertContentIntegration();
this._enableInsertObjectIntegration();
this._enableDeleteContentIntegration();
}
/**
* @inheritDoc
*/
destroy() {
super.destroy();
this._currentFakeCaretModelElement = null;
}
/**
* Inserts a new paragraph next to a widget element with the selection anchored in it.
*
* **Note**: This method is heavily user-oriented and will both focus the editing view and scroll
* the viewport to the selection in the inserted paragraph.
*
* @param widgetModelElement The model widget element next to which a paragraph is inserted.
* @param position The position where the paragraph is inserted. Either `'before'` or `'after'` the widget.
*/
_insertParagraph(widgetModelElement, position) {
const editor = this.editor;
const editingView = editor.editing.view;
const attributesToCopy = editor.model.schema.getAttributesWithProperty(widgetModelElement, 'copyOnReplace', true);
editor.execute('insertParagraph', {
position: editor.model.createPositionAt(widgetModelElement, position),
attributes: attributesToCopy
});
editingView.focus();
editingView.scrollToTheSelection();
}
/**
* A wrapper for the {@link module:utils/emittermixin~Emitter#listenTo} method that executes the callbacks only
* when the plugin {@link #isEnabled is enabled}.
*
* @param emitter The object that fires the event.
* @param event The name of the event.
* @param callback The function to be called on event.
* @param options Additional options.
*/
_listenToIfEnabled(emitter, event, callback, options) {
this.listenTo(emitter, event, (...args) => {
// Do not respond if the plugin is disabled.
if (this.isEnabled) {
callback(...args);
}
}, options);
}
/**
* Similar to {@link #_insertParagraph}, this method inserts a paragraph except that it
* does not expect a position. Instead, 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's fake caret is active (e.g. activated
* using the keyboard).
*
* @returns 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;
}
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // console.info( ..._buildLogMessage( this, 'WidgetTypeAround',
// @if CK_DEBUG_TYPING // 'Fake caret -> insert paragraph',
// @if CK_DEBUG_TYPING // ) );
// @if CK_DEBUG_TYPING // }
const selectedModelElement = modelSelection.getSelectedElement();
this._insertParagraph(selectedModelElement, typeAroundFakeCaretPosition);
return true;
}
/**
* Creates a listener in the editing conversion pipeline that injects the widget type around
* UI into every single widget instance created in the editor.
*
* The UI is delivered as a {@link module:engine/view/uielement~ViewUIElement}
* wrapper which renders DOM buttons that users can use to insert paragraphs.
*/
_enableTypeAroundUIInjection() {
const editor = this.editor;
const schema = editor.model.schema;
const t = editor.locale.t;
const buttonTitles = {
before: t('Insert paragraph before block'),
after: t('Insert paragraph after block')
};
editor.editing.downcastDispatcher.on('insert', (evt, data, conversionApi) => {
const viewElement = conversionApi.mapper.toViewElement(data.item);
if (!viewElement) {
return;
}
// Filter out non-widgets and inline widgets.
if (isTypeAroundWidget(viewElement, data.item, schema)) {
injectUIIntoWidget(conversionApi.writer, buttonTitles, viewElement);
const widgetLabel = viewElement.getCustomProperty('widgetLabel');
widgetLabel.push(() => {
return this.isEnabled ? t('Press Enter to type after or press Shift + Enter to type before the widget') : '';
});
}
}, { priority: 'low' });
}
/**
* Brings support for the fake caret that appears when either:
*
* * the selection moves to a widget from a position next to it 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
* <kbd>Enter</kbd> 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).
*/
_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").
this._listenToIfEnabled(editingView.document, 'arrowKey', (evt, domEventData) => {
this._handleArrowKeyPress(evt, domEventData);
}, { context: [isWidget, '$text'], priority: 'high' });
// 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.
this._listenToIfEnabled(modelSelection, '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).
this._listenToIfEnabled(model.document, '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.
this._listenToIfEnabled(editor.editing.downcastDispatcher, '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._listenToIfEnabled(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_type-around_show-fake-caret_${position}`;
}
}
/**
* A listener executed on each "keydown" in the view document, a part of
* {@link #_enableTypeAroundFakeCaretActivationUsingKeyboardArrows}.
*
* It decides whether the arrow keypress 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 and 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 widget type around plugin).
*/
_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;
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;
// 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);
}
// Handle collapsing a non-collapsed selection that is wider than on a single widget.
else if (!domEventData.shiftKey) {
shouldStopAndPreventDefault = this._handleArrowKeyPressWhenNonCollapsedSelection(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.
*
* @param isForward `true` when the pressed arrow key was responsible for the forward model selection movement
* as in {@link module:utils/keyboard~isForwardArrowKeyCode}.
* @returns Returns `true` when the keypress was handled and no other keydown listener of the editor should
* process the event any further. Returns `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;
});
}
/**
* 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 keypress
* (AKA entering/selecting the widget).
*
* **Note**: This code mirrors the implementation from the widget plugin but also adds the selection attribute.
* Unfortunately, there is no safe way to let the widget plugin do the selection part first and then just set the
* selection attribute here in the widget type around plugin. This is why this code must duplicate some from the widget plugin.
*
* @param isForward `true` when the pressed arrow key was responsible for the forward model selection movement
* as in {@link module:utils/keyboard~isForwardArrowKeyCode}.
* @returns Returns `true` when the keypress was handled and no other keydown listener of the editor should
* process the event any further. Returns `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;
}
/**
* Handles the keyboard navigation on "keydown" when a widget is currently selected (together with some other content)
* and the widget is the first or last element in the selection. It activates or deactivates the fake caret for that widget.
*
* @param isForward `true` when the pressed arrow key was responsible for the forward model selection movement
* as in {@link module:utils/keyboard~isForwardArrowKeyCode}.
* @returns Returns `true` when the keypress was handled and no other keydown listener of the editor should
* process the event any further. Returns `false` otherwise.
*/
_handleArrowKeyPressWhenNonCollapsedSelection(isForward) {
const editor = this.editor;
const model = editor.model;
const schema = model.schema;
const mapper = editor.editing.mapper;
const modelSelection = model.document.selection;
const selectedModelNode = isForward ?
modelSelection.getLastPosition().nodeBefore :
modelSelection.getFirstPosition().nodeAfter;
const selectedViewNode = mapper.toViewElement(selectedModelNode);
// There is a widget at the collapse position so collapse the selection to the fake caret on it.
if (isTypeAroundWidget(selectedViewNode, selectedModelNode, schema)) {
model.change(writer => {
writer.setSelection(selectedModelNode, 'on');
writer.setSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE, isForward ? 'after' : 'before');
});
return true;
}
return false;
}
/**
* Registers a `mousedown` listener for the view document which intercepts events
* coming from the widget type around UI, which happens when a user clicks one of the buttons
* that insert a paragraph next to a widget.
*/
_enableInsertingParagraphsOnButtonClick() {
const editor = this.editor;
const editingView = editor.editing.view;
this._listenToIfEnabled(editingView.document, 'mousedown', (evt, domEventData) => {
const button = getClosestTypeAroundDomButton(domEventData.domTarget);
if (!button) {
return;
}
const buttonPosition = getTypeAroundButtonPosition(button);
const widgetViewElement = getClosestWidgetViewElement(button, editingView.domConverter);
const widgetModelElement = editor.editing.mapper.toModelElement(widgetViewElement);
this._insertParagraph(widgetModelElement, buttonPosition);
domEventData.preventDefault();
evt.stop();
});
}
/**
* Creates the <kbd>Enter</kbd> 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}).
*
* In the second case, the new paragraph is inserted based on whether a soft (<kbd>Shift</kbd>+<kbd>Enter</kbd>) keystroke
* was pressed or not.
*/
_enableInsertingParagraphsOnEnterKeypress() {
const editor = this.editor;
const selection = editor.model.document.selection;
const editingView = editor.editing.view;
this._listenToIfEnabled(editingView.document, 'enter', (evt, domEventData) => {
// This event could be triggered from inside the widget but we are interested
// only when the widget is selected itself.
if (evt.eventPhase != 'atTarget') {
return;
}
const selectedModelElement = selection.getSelectedElement();
const selectedViewElement = editor.editing.mapper.toViewElement(selectedModelElement);
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();
}
}, { context: isWidget });
}
/**
* 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 instead of <kbd>Enter</kbd>.
*
* 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**: At the moment this listener creates 2 undo steps: one for the `insertParagraph` command
* and another one for actual typing. It is not a disaster but this may need to be fixed
* sooner or later.
*/
_enableInsertingParagraphsOnTypingKeystroke() {
const editor = this.editor;
const viewDocument = editor.editing.view.document;
// Note: The priority must precede the default Input plugin insertText handler.
this._listenToIfEnabled(viewDocument, 'insertText', (evt, data) => {
if (this._insertParagraphAccordingToFakeCaretPosition()) {
// The view selection in the event data contains the widget. If the new paragraph
// was inserted, modify the view selection passed along with the insertText event
// so the default event handler in the Input plugin starts typing inside the paragraph.
// Otherwise, the typing would be over the widget.
data.selection = viewDocument.selection;
}
}, { priority: 'high' });
if (env.isAndroid) {
// On Android with English keyboard, the composition starts just by putting caret
// at the word end or by selecting a table column. This is not a real composition started.
// Trigger delete content on first composition key pressed.
this._listenToIfEnabled(viewDocument, 'keydown', (evt, data) => {
if (data.keyCode == 229) {
this._insertParagraphAccordingToFakeCaretPosition();
}
});
}
else {
// Note: The priority must precede the default Input plugin compositionstart handler (to call it before delete content).
this._listenToIfEnabled(viewDocument, 'compositionstart', () => {
this._insertParagraphAccordingToFakeCaretPosition();
}, { priority: 'highest' });
}
}
/**
* It creates a "delete" event listener on the view document to handle cases when the <kbd>Delete</kbd> or <kbd>Backspace</kbd>
* 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 or after
* a widget, pressing <kbd>Delete</kbd> or <kbd>Backspace</kbd> should remove a widget or delete the content
* before or after a widget (depending on the content surrounding the widget).
*/
_enableDeleteIntegration() {
const editor = this.editor;
const editingView = editor.editing.view;
const model = editor.model;
const schema = model.schema;
this._listenToIfEnabled(editingView.document, 'delete', (evt, domEventData) => {
// This event could be triggered from inside the widget but we are interested
// only when the widget is selected itself.
if (evt.eventPhase != 'atTarget') {
return;
}
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 isDeleteForward = direction == 'forward';
const shouldDeleteEntireWidget = isFakeCaretBefore === isDeleteForward;
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(isDeleteForward ? 'deleteForward' : '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(isDeleteForward ? 'deleteForward' : '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 "deleteForward" 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();
}, { context: isWidget });
}
/**
* 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 is first activated using the arrow keys.
*
* The content is inserted according to the `widget-type-around` selection attribute (see {@link #_handleArrowKeyPress}).
*/
_enableInsertContentIntegration() {
const editor = this.editor;
const model = this.editor.model;
const documentSelection = model.document.selection;
this._listenToIfEnabled(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' });
}
/**
* Attaches the {@link module:engine/model/model~Model#event:insertObject} event listener that modifies the
* `options.findOptimalPosition`parameter to position of fake caret in relation to selected element
* to reflect user's intent of desired insertion position.
*
* The object is inserted according to the `widget-type-around` selection attribute (see {@link #_handleArrowKeyPress}).
*/
_enableInsertObjectIntegration() {
const editor = this.editor;
const model = this.editor.model;
const documentSelection = model.document.selection;
this._listenToIfEnabled(editor.model, 'insertObject', (evt, args) => {
const [, selectable, options = {}] = args;
if (selectable && !selectable.is('documentSelection')) {
return;
}
const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(documentSelection);
if (!typeAroundFakeCaretPosition) {
return;
}
options.findOptimalPosition = typeAroundFakeCaretPosition;
args[3] = options;
}, { priority: 'high' });
}
/**
* Attaches the {@link module:engine/model/model~Model#event:deleteContent} event listener to block the event when the fake
* caret is active.
*
* This is required for cases that trigger {@link module:engine/model/model~Model#deleteContent `model.deleteContent()`}
* before calling {@link module:engine/model/model~Model#insertContent `model.insertContent()`} like, for instance,
* plain text pasting.
*/
_enableDeleteContentIntegration() {
const editor = this.editor;
const model = this.editor.model;
const documentSelection = model.document.selection;
this._listenToIfEnabled(editor.model, 'deleteContent', (evt, [selection]) => {
if (selection && !selection.is('documentSelection')) {
return;
}
const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(documentSelection);
// Disable removing the selection content while pasting plain text.
if (typeAroundFakeCaretPosition) {
evt.stop();
}
}, { priority: 'high' });
}
}
/**
* Injects the type around UI into a view widget instance.
*/
function injectUIIntoWidget(viewWriter, buttonTitles, widgetViewElement) {
const typeAroundWrapper = viewWriter.createUIElement('div', {
class: 'ck ck-reset_all ck-widget__type-around'
}, function (domDocument) {
const wrapperDomElement = this.toDomElement(domDocument);
injectButtons(wrapperDomElement, buttonTitles);
injectFakeCaret(wrapperDomElement);
return wrapperDomElement;
});
// Inject the type around wrapper into the widget's wrapper.
viewWriter.insert(viewWriter.createPositionAt(widgetViewElement, 'end'), typeAroundWrapper);
}
/**
* FYI: Not using the IconView class because each instance would need to be destroyed to avoid memory leaks
* and it's pretty hard to figure out when a view (widget) is gone for good so it's cheaper to use raw
* <svg> here.
*/
function injectButtons(wrapperDomElement, buttonTitles) {
for (const position of POSSIBLE_INSERTION_POSITIONS) {
const buttonTemplate = new Template({
tag: 'div',
attributes: {
class: [
'ck',
'ck-widget__type-around__button',
`ck-widget__type-around__button_${position}`
],
title: buttonTitles[position],
'aria-hidden': 'true'
},
children: [
wrapperDomElement.ownerDocument.importNode(RETURN_ARROW_ICON_ELEMENT, true)
]
});
wrapperDomElement.appendChild(buttonTemplate.render());
}
}
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>`.
*/
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;
}