@ckeditor/ckeditor5-widget
Version:
Widget API for CKEditor 5.
1,058 lines (1,054 loc) • 140 kB
JavaScript
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import { Plugin } from '@ckeditor/ckeditor5-core/dist/index.js';
import { MouseObserver, PointerObserver, ModelTreeWalker } from '@ckeditor/ckeditor5-engine/dist/index.js';
import { Delete } from '@ckeditor/ckeditor5-typing/dist/index.js';
import { EmitterMixin, Rect, CKEditorError, toArray, isForwardArrowKeyCode, env, keyCodes, getLocalizedArrowKeyCodeDirection, compareArrays, getRangeFromMouseEvent, logWarning, ObservableMixin, global, DomEmitterMixin } from '@ckeditor/ckeditor5-utils/dist/index.js';
import { IconDragHandle, IconReturnArrow } from '@ckeditor/ckeditor5-icons/dist/index.js';
import { IconView, Template, ContextualBalloon, ToolbarView, BalloonPanelView, View } from '@ckeditor/ckeditor5-ui/dist/index.js';
import { Enter } from '@ckeditor/ckeditor5-enter/dist/index.js';
import { throttle } from 'es-toolkit/compat';
/**
* Class used to handle the correct order of highlights on elements.
*
* When different highlights are applied to same element the correct order should be preserved:
*
* * highlight with highest priority should be applied,
* * if two highlights have same priority - sort by CSS class provided in
* {@link module:engine/conversion/downcasthelpers~DowncastHighlightDescriptor}.
*
* This way, highlight will be applied with the same rules it is applied on texts.
*/ class WidgetHighlightStack extends /* #__PURE__ */ EmitterMixin() {
_stack = [];
/**
* Adds highlight descriptor to the stack.
*
* @fires change:top
*/ add(descriptor, writer) {
const stack = this._stack;
// Save top descriptor and insert new one. If top is changed - fire event.
const oldTop = stack[0];
this._insertDescriptor(descriptor);
const newTop = stack[0];
// When new object is at the top and stores different information.
if (oldTop !== newTop && !compareDescriptors(oldTop, newTop)) {
this.fire('change:top', {
oldDescriptor: oldTop,
newDescriptor: newTop,
writer
});
}
}
/**
* Removes highlight descriptor from the stack.
*
* @fires change:top
* @param id Id of the descriptor to remove.
*/ remove(id, writer) {
const stack = this._stack;
const oldTop = stack[0];
this._removeDescriptor(id);
const newTop = stack[0];
// When new object is at the top and stores different information.
if (oldTop !== newTop && !compareDescriptors(oldTop, newTop)) {
this.fire('change:top', {
oldDescriptor: oldTop,
newDescriptor: newTop,
writer
});
}
}
/**
* Inserts a given descriptor in correct place in the stack. It also takes care about updating information
* when descriptor with same id is already present.
*/ _insertDescriptor(descriptor) {
const stack = this._stack;
const index = stack.findIndex((item)=>item.id === descriptor.id);
// Inserting exact same descriptor - do nothing.
if (compareDescriptors(descriptor, stack[index])) {
return;
}
// If descriptor with same id but with different information is on the stack - remove it.
if (index > -1) {
stack.splice(index, 1);
}
// Find correct place to insert descriptor in the stack.
// It has different information (for example priority) so it must be re-inserted in correct place.
let i = 0;
while(stack[i] && shouldABeBeforeB(stack[i], descriptor)){
i++;
}
stack.splice(i, 0, descriptor);
}
/**
* Removes descriptor with given id from the stack.
*
* @param id Descriptor's id.
*/ _removeDescriptor(id) {
const stack = this._stack;
const index = stack.findIndex((item)=>item.id === id);
// If descriptor with same id is on the list - remove it.
if (index > -1) {
stack.splice(index, 1);
}
}
}
/**
* Compares two descriptors by checking their priority and class list.
*
* @returns Returns true if both descriptors are defined and have same priority and classes.
*/ function compareDescriptors(a, b) {
return a && b && a.priority == b.priority && classesToString(a.classes) == classesToString(b.classes);
}
/**
* Checks whenever first descriptor should be placed in the stack before second one.
*/ function shouldABeBeforeB(a, b) {
if (a.priority > b.priority) {
return true;
} else if (a.priority < b.priority) {
return false;
}
// When priorities are equal and names are different - use classes to compare.
return classesToString(a.classes) > classesToString(b.classes);
}
/**
* Converts CSS classes passed with {@link module:engine/conversion/downcasthelpers~DowncastHighlightDescriptor} to
* sorted string.
*/ function classesToString(classes) {
return Array.isArray(classes) ? classes.sort().join(',') : classes;
}
/**
* CSS class added to each widget element.
*/ const WIDGET_CLASS_NAME = 'ck-widget';
/**
* CSS class added to currently selected widget element.
*/ const WIDGET_SELECTED_CLASS_NAME = 'ck-widget_selected';
/**
* Returns `true` if given {@link module:engine/view/node~ViewNode} is an {@link module:engine/view/element~ViewElement} and a widget.
*/ function isWidget(node) {
if (!node.is('element')) {
return false;
}
return !!node.getCustomProperty('widget');
}
/**
* Converts the given {@link module:engine/view/element~ViewElement} to a widget in the following way:
*
* * sets the `contenteditable` attribute to `"false"`,
* * adds the `ck-widget` CSS class,
* * adds a custom {@link module:engine/view/element~ViewElement#getFillerOffset `getFillerOffset()`} method returning `null`,
* * adds a custom property allowing to recognize widget elements by using {@link ~isWidget `isWidget()`},
* * implements the {@link ~setHighlightHandling view highlight on widgets}.
*
* This function needs to be used in conjunction with
* {@link module:engine/conversion/downcasthelpers~DowncastHelpers downcast conversion helpers}
* like {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement `elementToElement()`}.
* Moreover, typically you will want to use `toWidget()` only for `editingDowncast`, while keeping the `dataDowncast` clean.
*
* For example, in order to convert a `<widget>` model element to `<div class="widget">` in the view, you can define
* such converters:
*
* ```ts
* editor.conversion.for( 'editingDowncast' )
* .elementToElement( {
* model: 'widget',
* view: ( modelItem, { writer } ) => {
* const div = writer.createContainerElement( 'div', { class: 'widget' } );
*
* return toWidget( div, writer, { label: 'some widget' } );
* }
* } );
*
* editor.conversion.for( 'dataDowncast' )
* .elementToElement( {
* model: 'widget',
* view: ( modelItem, { writer } ) => {
* return writer.createContainerElement( 'div', { class: 'widget' } );
* }
* } );
* ```
*
* See the full source code of the widget (with a nested editable) schema definition and converters in
* [this sample](https://github.com/ckeditor/ckeditor5-widget/blob/master/tests/manual/widget-with-nestededitable.js).
*
* @param options Additional options.
* @param options.label Element's label provided to the {@link ~setLabel} function. It can be passed as
* a plain string or a function returning a string. It represents the widget for assistive technologies (like screen readers).
* @param options.hasSelectionHandle If `true`, the widget will have a selection handle added.
* @returns Returns the same element.
*/ function toWidget(element, writer, options = {}) {
if (!element.is('containerElement')) {
/**
* The element passed to `toWidget()` must be a {@link module:engine/view/containerelement~ViewContainerElement}
* instance.
*
* @error widget-to-widget-wrong-element-type
* @param {any} element The view element passed to `toWidget()`.
*/ throw new CKEditorError('widget-to-widget-wrong-element-type', null, {
element
});
}
writer.setAttribute('contenteditable', 'false', element);
writer.addClass(WIDGET_CLASS_NAME, element);
writer.setCustomProperty('widget', true, element);
element.getFillerOffset = getFillerOffset;
writer.setCustomProperty('widgetLabel', [], element);
if (options.label) {
setLabel(element, options.label);
}
if (options.hasSelectionHandle) {
addSelectionHandle(element, writer);
}
setHighlightHandling(element, writer);
return element;
}
/**
* Default handler for adding a highlight on a widget.
* It adds CSS class and attributes basing on the given highlight descriptor.
*/ function addHighlight(element, descriptor, writer) {
if (descriptor.classes) {
writer.addClass(toArray(descriptor.classes), element);
}
if (descriptor.attributes) {
for(const key in descriptor.attributes){
writer.setAttribute(key, descriptor.attributes[key], element);
}
}
}
/**
* Default handler for removing a highlight from a widget.
* It removes CSS class and attributes basing on the given highlight descriptor.
*/ function removeHighlight(element, descriptor, writer) {
if (descriptor.classes) {
writer.removeClass(toArray(descriptor.classes), element);
}
if (descriptor.attributes) {
for(const key in descriptor.attributes){
writer.removeAttribute(key, element);
}
}
}
/**
* Sets highlight handling methods. Uses {@link module:widget/highlightstack~WidgetHighlightStack} to
* properly determine which highlight descriptor should be used at given time.
*/ function setHighlightHandling(element, writer, add = addHighlight, remove = removeHighlight) {
const stack = new WidgetHighlightStack();
stack.on('change:top', (evt, data)=>{
if (data.oldDescriptor) {
remove(element, data.oldDescriptor, data.writer);
}
if (data.newDescriptor) {
add(element, data.newDescriptor, data.writer);
}
});
const addHighlightCallback = (element, descriptor, writer)=>stack.add(descriptor, writer);
const removeHighlightCallback = (element, id, writer)=>stack.remove(id, writer);
writer.setCustomProperty('addHighlight', addHighlightCallback, element);
writer.setCustomProperty('removeHighlight', removeHighlightCallback, element);
}
/**
* Sets label for given element.
* It can be passed as a plain string or a function returning a string. Function will be called each time label is retrieved by
* {@link ~getLabel `getLabel()`}.
*/ function setLabel(element, labelOrCreator) {
const widgetLabel = element.getCustomProperty('widgetLabel');
widgetLabel.push(labelOrCreator);
}
/**
* Returns the label of the provided element.
*/ function getLabel(element) {
const widgetLabel = element.getCustomProperty('widgetLabel');
return widgetLabel.reduce((prev, current)=>{
if (typeof current === 'function') {
return prev ? prev + '. ' + current() : current();
} else {
return prev ? prev + '. ' + current : current;
}
}, '');
}
/**
* Adds functionality to the provided {@link module:engine/view/editableelement~ViewEditableElement} to act as a widget's editable:
*
* * sets the `contenteditable` attribute to `true` when
* {@link module:engine/view/editableelement~ViewEditableElement#isReadOnly} is `false`,
* otherwise sets it to `false`,
* * adds the `ck-editor__editable` and `ck-editor__nested-editable` CSS classes,
* * adds the `ck-editor__nested-editable_focused` CSS class when the editable is focused and removes it when it is blurred.
* * implements the {@link ~setHighlightHandling view highlight on widget's editable}.
* * sets the `role` attribute to `textbox` for accessibility purposes.
*
* Similarly to {@link ~toWidget `toWidget()`} this function should be used in `editingDowncast` only and it is usually
* used together with {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement `elementToElement()`}.
*
* For example, in order to convert a `<nested>` model element to `<div class="nested">` in the view, you can define
* such converters:
*
* ```ts
* editor.conversion.for( 'editingDowncast' )
* .elementToElement( {
* model: 'nested',
* view: ( modelItem, { writer } ) => {
* const div = writer.createEditableElement( 'div', { class: 'nested' } );
*
* return toWidgetEditable( nested, writer, { label: 'label for editable' } );
* }
* } );
*
* editor.conversion.for( 'dataDowncast' )
* .elementToElement( {
* model: 'nested',
* view: ( modelItem, { writer } ) => {
* return writer.createContainerElement( 'div', { class: 'nested' } );
* }
* } );
* ```
*
* See the full source code of the widget (with nested editable) schema definition and converters in
* [this sample](https://github.com/ckeditor/ckeditor5-widget/blob/master/tests/manual/widget-with-nestededitable.js).
*
* @param options Additional options.
* @param options.label Editable's label used by assistive technologies (e.g. screen readers).
* @param options.withAriaRole Whether to add the role="textbox" attribute on the editable. Defaults to `true`.
* @returns Returns the same element that was provided in the `editable` parameter
*/ function toWidgetEditable(editable, writer, options = {}) {
writer.addClass([
'ck-editor__editable',
'ck-editor__nested-editable'
], editable);
// Set role="textbox" only if explicitly requested (defaults to true for backward compatibility).
if (options.withAriaRole !== false) {
writer.setAttribute('role', 'textbox', editable);
}
// Setting tabindex=-1 on contenteditable=false makes it focusable. It propagates focus to the editable
// element and makes it possible to highlight nested editables as focused. It's not what we want
// for read-only editables though.
// See more: https://github.com/ckeditor/ckeditor5/issues/18965
if (!editable.isReadOnly) {
writer.setAttribute('tabindex', '-1', editable);
}
if (options.label) {
writer.setAttribute('aria-label', options.label, editable);
}
// Set initial contenteditable value.
writer.setAttribute('contenteditable', editable.isReadOnly ? 'false' : 'true', editable);
// Bind the contenteditable property to element#isReadOnly.
editable.on('change:isReadOnly', (evt, property, isReadonly)=>{
writer.setAttribute('contenteditable', isReadonly ? 'false' : 'true', editable);
if (isReadonly) {
writer.removeAttribute('tabindex', editable);
} else {
writer.setAttribute('tabindex', '-1', editable);
}
});
editable.on('change:isFocused', (evt, property, is)=>{
if (is) {
writer.addClass('ck-editor__nested-editable_focused', editable);
} else {
writer.removeClass('ck-editor__nested-editable_focused', editable);
}
});
setHighlightHandling(editable, writer);
return editable;
}
/**
* Returns a model range which is optimal (in terms of UX) for inserting a widget block.
*
* For instance, if a selection is in the middle of a paragraph, the collapsed range before this paragraph
* will be returned so that it is not split. If the selection is at the end of a paragraph,
* the collapsed range after this paragraph will be returned.
*
* Note: If the selection is placed in an empty block, the range in that block will be returned. If that range
* is then passed to {@link module:engine/model/model~Model#insertContent}, the block will be fully replaced
* by the inserted widget block.
*
* @param selection The selection based on which the insertion position should be calculated.
* @param model Model instance.
* @returns The optimal range.
*/ function findOptimalInsertionRange(selection, model) {
const selectedElement = selection.getSelectedElement();
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.createRange(model.createPositionAt(selectedElement, typeAroundFakeCaretPosition));
}
}
return model.schema.findOptimalInsertionRange(selection);
}
/**
* A util to be used in order to map view positions to correct model positions when implementing a widget
* which renders non-empty view element for an empty model element.
*
* For example:
*
* ```
* // Model:
* <placeholder type="name"></placeholder>
*
* // View:
* <span class="placeholder">name</span>
* ```
*
* In such case, view positions inside `<span>` cannot be correctly mapped to the model (because the model element is empty).
* To handle mapping positions inside `<span class="placeholder">` to the model use this util as follows:
*
* ```ts
* editor.editing.mapper.on(
* 'viewToModelPosition',
* viewToModelPositionOutsideModelElement( model, viewElement => viewElement.hasClass( 'placeholder' ) )
* );
* ```
*
* The callback will try to map the view offset of selection to an expected model position.
*
* 1. When the position is at the end (or in the middle) of the inline widget:
*
* ```
* // View:
* <p>foo <span class="placeholder">name|</span> bar</p>
*
* // Model:
* <paragraph>foo <placeholder type="name"></placeholder>| bar</paragraph>
* ```
*
* 2. When the position is at the beginning of the inline widget:
*
* ```
* // View:
* <p>foo <span class="placeholder">|name</span> bar</p>
*
* // Model:
* <paragraph>foo |<placeholder type="name"></placeholder> bar</paragraph>
* ```
*
* @param model Model instance on which the callback operates.
* @param viewElementMatcher Function that is passed a view element and should return `true` if the custom mapping
* should be applied to the given view element.
*/ function viewToModelPositionOutsideModelElement(model, viewElementMatcher) {
return (evt, data)=>{
const { mapper, viewPosition } = data;
const viewParent = mapper.findMappedViewAncestor(viewPosition);
if (!viewElementMatcher(viewParent)) {
return;
}
const modelParent = mapper.toModelElement(viewParent);
data.modelPosition = model.createPositionAt(modelParent, viewPosition.isAtStart ? 'before' : 'after');
};
}
/**
* Default filler offset function applied to all widget elements.
*/ function getFillerOffset() {
return null;
}
/**
* Adds a drag handle to the widget.
*/ function addSelectionHandle(widgetElement, writer) {
const selectionHandle = writer.createUIElement('div', {
class: 'ck ck-widget__selection-handle'
}, function(domDocument) {
const domElement = this.toDomElement(domDocument);
// Use the IconView from the ui library.
const icon = new IconView();
icon.set('content', IconDragHandle);
// Render the icon view right away to append its #element to the selectionHandle DOM element.
icon.render();
domElement.appendChild(icon.element);
return domElement;
});
// Append the selection handle into the widget wrapper.
writer.insert(writer.createPositionAt(widgetElement, 0), selectionHandle);
writer.addClass([
'ck-widget_with-selection-handle'
], widgetElement);
}
/**
* Starting from a DOM resize host element (an element that receives dimensions as a result of resizing),
* this helper returns the width of the found ancestor element.
*
* * It searches up to 5 levels of ancestors only.
*
* @param domResizeHost Resize host DOM element that receives dimensions as a result of resizing.
* @returns Width of ancestor element in pixels or 0 if no ancestor with a computed width has been found.
*/ function calculateResizeHostAncestorWidth(domResizeHost) {
const getElementComputedWidth = (element)=>{
const { width, paddingLeft, paddingRight } = element.ownerDocument.defaultView.getComputedStyle(element);
return parseFloat(width) - (parseFloat(paddingLeft) || 0) - (parseFloat(paddingRight) || 0);
};
const domResizeHostParent = domResizeHost.parentElement;
if (!domResizeHostParent) {
return 0;
}
// Need to use computed style as it properly excludes parent's paddings from the returned value.
let parentWidth = getElementComputedWidth(domResizeHostParent);
// Sometimes parent width cannot be accessed. If that happens we should go up in the elements tree
// and try to get width from next ancestor.
// https://github.com/ckeditor/ckeditor5/issues/10776
const ancestorLevelLimit = 5;
let currentLevel = 0;
let checkedElement = domResizeHostParent;
while(isNaN(parentWidth)){
checkedElement = checkedElement.parentElement;
if (++currentLevel > ancestorLevelLimit) {
return 0;
}
parentWidth = getElementComputedWidth(checkedElement);
}
return parentWidth;
}
/**
* Calculates a relative width of a `domResizeHost` compared to its ancestor in percents.
*
* @param domResizeHost Resize host DOM element.
* @returns Percentage value between 0 and 100.
*/ function calculateResizeHostPercentageWidth(domResizeHost, resizeHostRect = new Rect(domResizeHost)) {
const parentWidth = calculateResizeHostAncestorWidth(domResizeHost);
if (!parentWidth) {
return 0;
}
return resizeHostRect.width / parentWidth * 100;
}
/**
* The name of the type around model selection attribute responsible for
* displaying a fake caret next to a selected widget.
*
* @internal
*/ const TYPE_AROUND_SELECTION_ATTRIBUTE = 'widget-type-around';
/**
* Checks if an element is a widget that qualifies to get the widget type around UI.
*/ function isTypeAroundWidget(viewElement, modelElement, schema) {
return !!viewElement && isWidget(viewElement) && !schema.isInline(modelElement);
}
/**
* For the passed HTML element, this helper finds the closest widget type around button ancestor.
*
* @internal
*/ function getClosestTypeAroundDomButton(domElement) {
return domElement.closest('.ck-widget__type-around__button');
}
/**
* For the passed widget type around button element, this helper determines at which position
* the paragraph would be inserted into the content if, for instance, the button was
* clicked by the user.
*
* @internal
* @returns The position of the button.
*/ function getTypeAroundButtonPosition(domElement) {
return domElement.classList.contains('ck-widget__type-around__button_before') ? 'before' : 'after';
}
/**
* For the passed HTML element, this helper returns the closest view widget ancestor.
*
* @internal
*/ function getClosestWidgetViewElement(domElement, domConverter) {
const widgetDomElement = domElement.closest('.ck-widget');
return domConverter.mapDomToView(widgetDomElement);
}
/**
* For the passed selection instance, it returns the position of the fake caret displayed next to a widget.
*
* **Note**: If the fake caret is not currently displayed, `null` is returned.
*
* @internal
* @returns The position of the fake caret or `null` when none is present.
*/ function getTypeAroundFakeCaretPosition(selection) {
return selection.getAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE);
}
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.
*/ 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;
// Selection expanding/shrinking is handled without the fake caret by the widget plugin.
if (domEventData.shiftKey) {
return;
}
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);
} else if (modelSelection.isCollapsed) {
shouldStopAndPreventDefault = this._handleArrowKeyPressWhenSelectionNextToAWidget(isForward);
} 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;
}
} 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;
} 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._listenToIfEnab