@ckeditor/ckeditor5-ui
Version:
The UI framework and standard UI library of CKEditor 5.
1,523 lines (1,520 loc) • 676 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
*/
import { Collection, CKEditorError, EmitterMixin, isNode, toArray, DomEmitterMixin, ObservableMixin, isIterable, uid, env, delay, getEnvKeystrokeText, isVisible, global, KeystrokeHandler, FocusTracker, toUnit, Rect, createElement, ResizeObserver, getBorderWidths, logWarning, getOptimalPosition, isText, isRange, priorities, first, parseBase64EncodedObject, getVisualViewportOffset, getAncestors } from '@ckeditor/ckeditor5-utils/dist/index.js';
import { cloneDeepWith, isObject, isElement, debounce, throttle, cloneDeep, extend, escapeRegExp, escape } from 'es-toolkit/compat';
import { Plugin, ContextPlugin } from '@ckeditor/ckeditor5-core/dist/index.js';
import { IconCancel, IconCheck, IconAccessibility, IconDropdownArrow, IconColorTileCheck, IconDragIndicator, IconPilcrow, IconThreeVerticalDots, IconText, IconPlus, IconParagraph, IconImportExport, IconBold, IconAlignLeft, IconColorPalette, IconEraser, IconProjectLogo, IconPreviousArrow, IconNextArrow, IconLoupe } from '@ckeditor/ckeditor5-icons/dist/index.js';
import parse from 'color-parse';
import * as convert from 'color-convert';
import { HexBase } from 'vanilla-colorful/lib/entrypoints/hex';
import { Observer } from '@ckeditor/ckeditor5-engine/dist/index.js';
/**
* Collects {@link module:ui/view~View} instances.
*
* ```ts
* const parentView = new ParentView( locale );
* const collection = new ViewCollection( locale );
*
* collection.setParent( parentView.element );
*
* const viewA = new ChildView( locale );
* const viewB = new ChildView( locale );
* ```
*
* View collection renders and manages view {@link module:ui/view~View#element elements}:
*
* ```ts
* collection.add( viewA );
* collection.add( viewB );
*
* console.log( parentView.element.firsChild ); // -> viewA.element
* console.log( parentView.element.lastChild ); // -> viewB.element
* ```
*
* It {@link module:ui/viewcollection~ViewCollection#delegate propagates} DOM events too:
*
* ```ts
* // Delegate #click and #keydown events from viewA and viewB to the parentView.
* collection.delegate( 'click' ).to( parentView );
*
* parentView.on( 'click', ( evt ) => {
* console.log( `${ evt.source } has been clicked.` );
* } );
*
* // This event will be delegated to the parentView.
* viewB.fire( 'click' );
* ```
*
* **Note**: A view collection can be used directly in the {@link module:ui/template~TemplateDefinition definition}
* of a {@link module:ui/template~Template template}.
*/ class ViewCollection extends Collection {
/**
* A parent element within which child views are rendered and managed in DOM.
*/ _parentElement;
/**
* Creates a new instance of the {@link module:ui/viewcollection~ViewCollection}.
*
* @param initialItems The initial items of the collection.
*/ constructor(initialItems = []){
super(initialItems, {
// An #id Number attribute should be legal and not break the `ViewCollection` instance.
// https://github.com/ckeditor/ckeditor5-ui/issues/93
idProperty: 'viewUid'
});
// Handle {@link module:ui/view~View#element} in DOM when a new view is added to the collection.
this.on('add', (evt, view, index)=>{
this._renderViewIntoCollectionParent(view, index);
});
// Handle {@link module:ui/view~View#element} in DOM when a view is removed from the collection.
this.on('remove', (evt, view)=>{
if (view.element && this._parentElement) {
view.element.remove();
}
});
this._parentElement = null;
}
/**
* Destroys the view collection along with child views.
* See the view {@link module:ui/view~View#destroy} method.
*/ destroy() {
this.map((view)=>view.destroy());
}
/**
* Sets the parent HTML element of this collection. When parent is set, {@link #add adding} and
* {@link #remove removing} views in the collection synchronizes their
* {@link module:ui/view~View#element elements} in the parent element.
*
* @param elementOrDocFragment A new parent element or document fragment.
*/ setParent(elementOrDocFragment) {
this._parentElement = elementOrDocFragment;
// Take care of the initial collection items passed to the constructor.
for (const view of this){
this._renderViewIntoCollectionParent(view);
}
}
/**
* Delegates selected events coming from within views in the collection to any
* {@link module:utils/emittermixin~Emitter}.
*
* For the following views and collection:
*
* ```ts
* const viewA = new View();
* const viewB = new View();
* const viewC = new View();
*
* const views = parentView.createCollection();
*
* views.delegate( 'eventX' ).to( viewB );
* views.delegate( 'eventX', 'eventY' ).to( viewC );
*
* views.add( viewA );
* ```
*
* the `eventX` is delegated (fired by) `viewB` and `viewC` along with `customData`:
*
* ```ts
* viewA.fire( 'eventX', customData );
* ```
*
* and `eventY` is delegated (fired by) `viewC` along with `customData`:
*
* ```ts
* viewA.fire( 'eventY', customData );
* ```
*
* See {@link module:utils/emittermixin~Emitter#delegate}.
*
* @param events {@link module:ui/view~View} event names to be delegated to another
* {@link module:utils/emittermixin~Emitter}.
* @returns Object with `to` property, a function which accepts the destination
* of {@link module:utils/emittermixin~Emitter#delegate delegated} events.
*/ delegate(...events) {
if (!events.length || !isStringArray(events)) {
/**
* All event names must be strings.
*
* @error ui-viewcollection-delegate-wrong-events
*/ throw new CKEditorError('ui-viewcollection-delegate-wrong-events', this);
}
return {
to: (dest)=>{
// Activate delegating on existing views in this collection.
for (const view of this){
for (const evtName of events){
view.delegate(evtName).to(dest);
}
}
// Activate delegating on future views in this collection.
this.on('add', (evt, view)=>{
for (const evtName of events){
view.delegate(evtName).to(dest);
}
});
// Deactivate delegating when view is removed from this collection.
this.on('remove', (evt, view)=>{
for (const evtName of events){
view.stopDelegating(evtName, dest);
}
});
}
};
}
/**
* This method {@link module:ui/view~View#render renders} a new view added to the collection.
*
* If the {@link #_parentElement parent element} of the collection is set, this method also adds
* the view's {@link module:ui/view~View#element} as a child of the parent in DOM at a specified index.
*
* **Note**: If index is not specified, the view's element is pushed as the last child
* of the parent element.
*
* @param view A new view added to the collection.
* @param index An index the view holds in the collection. When not specified,
* the view is added at the end.
*/ _renderViewIntoCollectionParent(view, index) {
if (!view.isRendered) {
view.render();
}
if (view.element && this._parentElement) {
this._parentElement.insertBefore(view.element, this._parentElement.children[index]);
}
}
/**
* Removes a child view from the collection. If the {@link #setParent parent element} of the
* collection has been set, the {@link module:ui/view~View#element element} of the view is also removed
* in DOM, reflecting the order of the collection.
*
* See the {@link #add} method.
*
* @param subject The view to remove, its id or index in the collection.
* @returns The removed view.
*/ remove(subject) {
return super.remove(subject);
}
}
/**
* Check if all entries of the array are of `String` type.
*
* @param arr An array to be checked.
*/ function isStringArray(arr) {
return arr.every((a)=>typeof a == 'string');
}
const xhtmlNs = 'http://www.w3.org/1999/xhtml';
/**
* A basic Template class. It renders a DOM HTML element or text from a
* {@link module:ui/template~TemplateDefinition definition} and supports element attributes, children,
* bindings to {@link module:utils/observablemixin~Observable observables} and DOM event propagation.
*
* A simple template can look like this:
*
* ```ts
* const bind = Template.bind( observable, emitter );
*
* new Template( {
* tag: 'p',
* attributes: {
* class: 'foo',
* style: {
* backgroundColor: 'yellow'
* }
* },
* on: {
* click: bind.to( 'clicked' )
* },
* children: [
* 'A paragraph.'
* ]
* } ).render();
* ```
*
* and it will render the following HTML element:
*
* ```html
* <p class="foo" style="background-color: yellow;">A paragraph.</p>
* ```
*
* Additionally, the `observable` will always fire `clicked` upon clicking `<p>` in the DOM.
*
* See {@link module:ui/template~TemplateDefinition} to know more about templates and complex
* template definitions.
*/ class Template extends /* #__PURE__ */ EmitterMixin() {
ns;
/**
* The tag (`tagName`) of this template, e.g. `div`. It also indicates that the template
* renders to an HTML element.
*/ tag;
/**
* The text of the template. It also indicates that the template renders to a DOM text node.
*/ text;
/**
* The attributes of the template, e.g. `{ id: [ 'ck-id' ] }`, corresponding with
* the attributes of an HTML element.
*
* **Note**: This property only makes sense when {@link #tag} is defined.
*/ attributes;
/**
* The children of the template. They can be either:
* * independent instances of {@link ~Template} (sub–templates),
* * native DOM Nodes.
*
* **Note**: This property only makes sense when {@link #tag} is defined.
*/ children;
/**
* The DOM event listeners of the template.
*/ eventListeners;
/**
* Indicates whether this particular Template instance has been
* {@link #render rendered}.
*/ _isRendered;
/**
* The data used by the {@link #revert} method to restore a node to its original state.
*
* See: {@link #apply}.
*/ _revertData;
/**
* Creates an instance of the {@link ~Template} class.
*
* @param def The definition of the template.
*/ constructor(def){
super();
Object.assign(this, normalize(clone(def)));
this._isRendered = false;
this._revertData = null;
}
/**
* Renders a DOM Node (an HTML element or text) out of the template.
*
* ```ts
* const domNode = new Template( { ... } ).render();
* ```
*
* See: {@link #apply}.
*/ render() {
const node = this._renderNode({
intoFragment: true
});
this._isRendered = true;
return node;
}
/**
* Applies the template to an existing DOM Node, either HTML element or text.
*
* **Note:** No new DOM nodes will be created. Applying extends:
*
* {@link module:ui/template~TemplateDefinition attributes},
* {@link module:ui/template~TemplateDefinition event listeners}, and
* `textContent` of {@link module:ui/template~TemplateDefinition children} only.
*
* **Note:** Existing `class` and `style` attributes are extended when a template
* is applied to an HTML element, while other attributes and `textContent` are overridden.
*
* **Note:** The process of applying a template can be easily reverted using the
* {@link module:ui/template~Template#revert} method.
*
* ```ts
* const element = document.createElement( 'div' );
* const observable = new Model( { divClass: 'my-div' } );
* const emitter = Object.create( EmitterMixin );
* const bind = Template.bind( observable, emitter );
*
* new Template( {
* attributes: {
* id: 'first-div',
* class: bind.to( 'divClass' )
* },
* on: {
* click: bind( 'elementClicked' ) // Will be fired by the observable.
* },
* children: [
* 'Div text.'
* ]
* } ).apply( element );
*
* console.log( element.outerHTML ); // -> '<div id="first-div" class="my-div"></div>'
* ```
*
* @see module:ui/template~Template#render
* @see module:ui/template~Template#revert
* @param node Root node for the template to apply.
*/ apply(node) {
this._revertData = getEmptyRevertData();
this._renderNode({
node,
intoFragment: false,
isApplying: true,
revertData: this._revertData
});
return node;
}
/**
* Reverts a template {@link module:ui/template~Template#apply applied} to a DOM node.
*
* @param node The root node for the template to revert. In most of the cases, it is the
* same node used by {@link module:ui/template~Template#apply}.
*/ revert(node) {
if (!this._revertData) {
/**
* Attempting to revert a template which has not been applied yet.
*
* @error ui-template-revert-not-applied
*/ throw new CKEditorError('ui-template-revert-not-applied', [
this,
node
]);
}
this._revertTemplateFromNode(node, this._revertData);
}
/**
* Returns an iterator which traverses the template in search of {@link module:ui/view~View}
* instances and returns them one by one.
*
* ```ts
* const viewFoo = new View();
* const viewBar = new View();
* const viewBaz = new View();
* const template = new Template( {
* tag: 'div',
* children: [
* viewFoo,
* {
* tag: 'div',
* children: [
* viewBar
* ]
* },
* viewBaz
* ]
* } );
*
* // Logs: viewFoo, viewBar, viewBaz
* for ( const view of template.getViews() ) {
* console.log( view );
* }
* ```
*/ *getViews() {
function* search(def) {
if (def.children) {
for (const child of def.children){
if (isView(child)) {
yield child;
} else if (isTemplate(child)) {
yield* search(child);
}
}
}
}
yield* search(this);
}
/**
* An entry point to the interface which binds DOM nodes to
* {@link module:utils/observablemixin~Observable observables}.
* There are two types of bindings:
*
* * HTML element attributes or text `textContent` synchronized with attributes of an
* {@link module:utils/observablemixin~Observable}. Learn more about {@link module:ui/template~BindChain#to}
* and {@link module:ui/template~BindChain#if}.
*
* ```ts
* const bind = Template.bind( observable, emitter );
*
* new Template( {
* attributes: {
* // Binds the element "class" attribute to observable#classAttribute.
* class: bind.to( 'classAttribute' )
* }
* } ).render();
* ```
*
* * DOM events fired on HTML element propagated through
* {@link module:utils/observablemixin~Observable}. Learn more about {@link module:ui/template~BindChain#to}.
*
* ```ts
* const bind = Template.bind( observable, emitter );
*
* new Template( {
* on: {
* // Will be fired by the observable.
* click: bind( 'elementClicked' )
* }
* } ).render();
* ```
*
* Also see {@link module:ui/view~View#bindTemplate}.
*
* @param observable An observable which provides boundable attributes.
* @param emitter An emitter that listens to observable attribute
* changes or DOM Events (depending on the kind of the binding). Usually, a {@link module:ui/view~View} instance.
*/ static bind(observable, emitter) {
return {
to (eventNameOrFunctionOrAttribute, callback) {
return new TemplateToBinding({
eventNameOrFunction: eventNameOrFunctionOrAttribute,
attribute: eventNameOrFunctionOrAttribute,
observable,
emitter,
callback
});
},
if (attribute, valueIfTrue, callback) {
return new TemplateIfBinding({
observable,
emitter,
attribute,
valueIfTrue,
callback
});
}
};
}
/**
* Extends an existing {@link module:ui/template~Template} instance with some additional content
* from another {@link module:ui/template~TemplateDefinition}.
*
* ```ts
* const bind = Template.bind( observable, emitter );
*
* const template = new Template( {
* tag: 'p',
* attributes: {
* class: 'a',
* data-x: bind.to( 'foo' )
* },
* children: [
* {
* tag: 'span',
* attributes: {
* class: 'b'
* },
* children: [
* 'Span'
* ]
* }
* ]
* } );
*
* // Instance-level extension.
* Template.extend( template, {
* attributes: {
* class: 'b',
* data-x: bind.to( 'bar' )
* },
* children: [
* {
* attributes: {
* class: 'c'
* }
* }
* ]
* } );
*
* // Child extension.
* Template.extend( template.children[ 0 ], {
* attributes: {
* class: 'd'
* }
* } );
* ```
*
* the `outerHTML` of `template.render()` is:
*
* ```html
* <p class="a b" data-x="{ observable.foo } { observable.bar }">
* <span class="b c d">Span</span>
* </p>
* ```
*
* @param template An existing template instance to be extended.
* @param def Additional definition to be applied to a template.
*/ static extend(template, def) {
if (template._isRendered) {
/**
* Extending a template after rendering may not work as expected. To make sure
* the {@link module:ui/template~Template.extend extending} works for an element,
* make sure it happens before {@link module:ui/template~Template#render} is called.
*
* @error template-extend-render
*/ throw new CKEditorError('template-extend-render', [
this,
template
]);
}
extendTemplate(template, normalize(clone(def)));
}
/**
* Renders a DOM Node (either an HTML element or text) out of the template.
*
* @param data Rendering data.
*/ _renderNode(data) {
let isInvalid;
if (data.node) {
// When applying, a definition cannot have "tag" and "text" at the same time.
isInvalid = this.tag && this.text;
} else {
// When rendering, a definition must have either "tag" or "text": XOR( this.tag, this.text ).
isInvalid = this.tag ? this.text : !this.text;
}
if (isInvalid) {
/**
* Node definition cannot have the "tag" and "text" properties at the same time.
* Node definition must have either "tag" or "text" when rendering a new Node.
*
* @error ui-template-wrong-syntax
*/ throw new CKEditorError('ui-template-wrong-syntax', this);
}
if (this.text) {
return this._renderText(data);
} else {
return this._renderElement(data);
}
}
/**
* Renders an HTML element out of the template.
*
* @param data Rendering data.
*/ _renderElement(data) {
let node = data.node;
if (!node) {
node = data.node = document.createElementNS(this.ns || xhtmlNs, this.tag);
}
this._renderAttributes(data);
this._renderElementChildren(data);
this._setUpListeners(data);
return node;
}
/**
* Renders a text node out of {@link module:ui/template~Template#text}.
*
* @param data Rendering data.
*/ _renderText(data) {
let node = data.node;
// Save the original textContent to revert it in #revert().
if (node) {
data.revertData.text = node.textContent;
} else {
node = data.node = document.createTextNode('');
}
// Check if this Text Node is bound to Observable. Cases:
//
// text: [ Template.bind( ... ).to( ... ) ]
//
// text: [
// 'foo',
// Template.bind( ... ).to( ... ),
// ...
// ]
//
if (hasTemplateBinding(this.text)) {
this._bindToObservable({
schema: this.text,
updater: getTextUpdater(node),
data
});
} else {
node.textContent = this.text.join('');
}
return node;
}
/**
* Renders HTML element attributes out of {@link module:ui/template~Template#attributes}.
*
* @param data Rendering data.
*/ _renderAttributes(data) {
if (!this.attributes) {
return;
}
const node = data.node;
const revertData = data.revertData;
for(const attrName in this.attributes){
// Current attribute value in DOM.
const domAttrValue = node.getAttribute(attrName);
// The value to be set.
const attrValue = this.attributes[attrName];
// Save revert data.
if (revertData) {
revertData.attributes[attrName] = domAttrValue;
}
// Detect custom namespace:
//
// class: {
// ns: 'abc',
// value: Template.bind( ... ).to( ... )
// }
//
const attrNs = isNamespaced(attrValue) ? attrValue[0].ns : null;
// Activate binding if one is found. Cases:
//
// class: [
// Template.bind( ... ).to( ... )
// ]
//
// class: [
// 'bar',
// Template.bind( ... ).to( ... ),
// 'baz'
// ]
//
// class: {
// ns: 'abc',
// value: Template.bind( ... ).to( ... )
// }
//
if (hasTemplateBinding(attrValue)) {
// Normalize attributes with additional data like namespace:
//
// class: {
// ns: 'abc',
// value: [ ... ]
// }
//
const valueToBind = isNamespaced(attrValue) ? attrValue[0].value : attrValue;
// Extend the original value of attributes like "style" and "class",
// don't override them.
if (revertData && shouldExtend(attrName)) {
valueToBind.unshift(domAttrValue);
}
this._bindToObservable({
schema: valueToBind,
updater: getAttributeUpdater(node, attrName, attrNs),
data
});
} else if (attrName == 'style' && typeof attrValue[0] !== 'string') {
this._renderStyleAttribute(attrValue[0], data);
} else {
// Extend the original value of attributes like "style" and "class",
// don't override them.
if (revertData && domAttrValue && shouldExtend(attrName)) {
attrValue.unshift(domAttrValue);
}
const value = attrValue// Retrieve "values" from:
//
// class: [
// {
// ns: 'abc',
// value: [ ... ]
// }
// ]
//
.map((val)=>val ? val.value || val : val)// Flatten the array.
.reduce((prev, next)=>prev.concat(next), [])// Convert into string.
.reduce(arrayValueReducer, '');
if (!isFalsy(value)) {
node.setAttributeNS(attrNs, attrName, value);
}
}
}
}
/**
* Renders the `style` attribute of an HTML element based on
* {@link module:ui/template~Template#attributes}.
*
* A style attribute is an object with static values:
*
* ```ts
* attributes: {
* style: {
* color: 'red'
* }
* }
* ```
*
* or values bound to {@link module:ui/model~Model} properties:
*
* ```ts
* attributes: {
* style: {
* color: bind.to( ... )
* }
* }
* ```
*
* Note: The `style` attribute is rendered without setting the namespace. It does not seem to be
* needed.
*
* @param styles Styles located in `attributes.style` of {@link module:ui/template~TemplateDefinition}.
* @param data Rendering data.
*/ _renderStyleAttribute(styles, data) {
const node = data.node;
for(const styleName in styles){
const styleValue = styles[styleName];
// Cases:
//
// style: {
// color: bind.to( 'attribute' )
// }
//
if (hasTemplateBinding(styleValue)) {
this._bindToObservable({
schema: [
styleValue
],
updater: getStyleUpdater(node, styleName),
data
});
} else {
node.style[styleName] = styleValue;
}
}
}
/**
* Recursively renders HTML element's children from {@link module:ui/template~Template#children}.
*
* @param data Rendering data.
*/ _renderElementChildren(data) {
const node = data.node;
const container = data.intoFragment ? document.createDocumentFragment() : node;
const isApplying = data.isApplying;
let childIndex = 0;
for (const child of this.children){
if (isViewCollection(child)) {
if (!isApplying) {
child.setParent(node);
// Note: ViewCollection renders its children.
for (const view of child){
container.appendChild(view.element);
}
}
} else if (isView(child)) {
if (!isApplying) {
if (!child.isRendered) {
child.render();
}
container.appendChild(child.element);
}
} else if (isNode(child)) {
container.appendChild(child);
} else {
if (isApplying) {
const revertData = data.revertData;
const childRevertData = getEmptyRevertData();
revertData.children.push(childRevertData);
child._renderNode({
intoFragment: false,
node: container.childNodes[childIndex++],
isApplying: true,
revertData: childRevertData
});
} else {
container.appendChild(child.render());
}
}
}
if (data.intoFragment) {
node.appendChild(container);
}
}
/**
* Activates `on` event listeners from the {@link module:ui/template~TemplateDefinition}
* on an HTML element.
*
* @param data Rendering data.
*/ _setUpListeners(data) {
if (!this.eventListeners) {
return;
}
for(const key in this.eventListeners){
const revertBindings = this.eventListeners[key].map((schemaItem)=>{
const [domEvtName, domSelector] = key.split('@');
return schemaItem.activateDomEventListener(domEvtName, domSelector, data);
});
if (data.revertData) {
data.revertData.bindings.push(revertBindings);
}
}
}
/**
* For a given {@link module:ui/template~TemplateValueSchema} containing {@link module:ui/template~TemplateBinding}
* activates the binding and sets its initial value.
*
* Note: {@link module:ui/template~TemplateValueSchema} can be for HTML element attributes or
* text node `textContent`.
*
* @param options Binding options.
* @param options.updater A function which updates the DOM (like attribute or text).
* @param options.data Rendering data.
*/ _bindToObservable({ schema, updater, data }) {
const revertData = data.revertData;
// Set initial values.
syncValueSchemaValue(schema, updater, data);
const revertBindings = schema// Filter "falsy" (false, undefined, null, '') value schema components out.
.filter((item)=>!isFalsy(item))// Filter inactive bindings from schema, like static strings ('foo'), numbers (42), etc.
.filter((item)=>item.observable)// Once only the actual binding are left, let the emitter listen to observable change:attribute event.
// TODO: Reduce the number of listeners attached as many bindings may listen
// to the same observable attribute.
.map((templateBinding)=>templateBinding.activateAttributeListener(schema, updater, data));
if (revertData) {
revertData.bindings.push(revertBindings);
}
}
/**
* Reverts {@link module:ui/template~RenderData#revertData template data} from a node to
* return it to the original state.
*
* @param node A node to be reverted.
* @param revertData An object that stores information about what changes have been made by
* {@link #apply} to the node. See {@link module:ui/template~RenderData#revertData} for more information.
*/ _revertTemplateFromNode(node, revertData) {
for (const binding of revertData.bindings){
// Each binding may consist of several observable+observable#attribute.
// like the following has 2:
//
// class: [
// 'x',
// bind.to( 'foo' ),
// 'y',
// bind.to( 'bar' )
// ]
//
for (const revertBinding of binding){
revertBinding();
}
}
if (revertData.text) {
node.textContent = revertData.text;
return;
}
const element = node;
for(const attrName in revertData.attributes){
const attrValue = revertData.attributes[attrName];
// When the attribute has **not** been set before #apply().
if (attrValue === null) {
element.removeAttribute(attrName);
} else {
element.setAttribute(attrName, attrValue);
}
}
for(let i = 0; i < revertData.children.length; ++i){
this._revertTemplateFromNode(element.childNodes[i], revertData.children[i]);
}
}
}
/**
* Describes a binding created by the {@link module:ui/template~Template.bind} interface.
*
* @internal
*/ class TemplateBinding {
/**
* The name of the {@link module:ui/template~TemplateBinding#observable observed attribute}.
*/ attribute;
/**
* An observable instance of the binding. It either:
*
* * provides the attribute with the value,
* * or passes the event when a corresponding DOM event is fired.
*/ observable;
/**
* An {@link module:utils/emittermixin~Emitter} used by the binding to:
*
* * listen to the attribute change in the {@link module:ui/template~TemplateBinding#observable},
* * or listen to the event in the DOM.
*/ emitter;
/**
* A custom function to process the value of the {@link module:ui/template~TemplateBinding#attribute}.
*/ callback;
/**
* Creates an instance of the {@link module:ui/template~TemplateBinding} class.
*
* @param def The definition of the binding.
*/ constructor(def){
this.attribute = def.attribute;
this.observable = def.observable;
this.emitter = def.emitter;
this.callback = def.callback;
}
/**
* Returns the value of the binding. It is the value of the {@link module:ui/template~TemplateBinding#attribute} in
* {@link module:ui/template~TemplateBinding#observable}. The value may be processed by the
* {@link module:ui/template~TemplateBinding#callback}, if such has been passed to the binding.
*
* @param node A native DOM node, passed to the custom {@link module:ui/template~TemplateBinding#callback}.
* @returns The value of {@link module:ui/template~TemplateBinding#attribute} in
* {@link module:ui/template~TemplateBinding#observable}.
*/ getValue(node) {
const value = this.observable[this.attribute];
return this.callback ? this.callback(value, node) : value;
}
/**
* Activates the listener which waits for changes of the {@link module:ui/template~TemplateBinding#attribute} in
* {@link module:ui/template~TemplateBinding#observable}, then updates the DOM with the aggregated
* value of {@link module:ui/template~TemplateValueSchema}.
*
* @param schema A full schema to generate an attribute or text in the DOM.
* @param updater A DOM updater function used to update the native DOM attribute or text.
* @param data Rendering data.
* @returns A function to sever the listener binding.
*/ activateAttributeListener(schema, updater, data) {
const callback = ()=>syncValueSchemaValue(schema, updater, data);
this.emitter.listenTo(this.observable, `change:${this.attribute}`, callback);
// Allows revert of the listener.
return ()=>{
this.emitter.stopListening(this.observable, `change:${this.attribute}`, callback);
};
}
}
/**
* Describes either:
*
* * a binding to an {@link module:utils/observablemixin~Observable},
* * or a native DOM event binding.
*
* It is created by the {@link module:ui/template~BindChain#to} method.
*
* @internal
*/ class TemplateToBinding extends TemplateBinding {
eventNameOrFunction;
constructor(def){
super(def);
this.eventNameOrFunction = def.eventNameOrFunction;
}
/**
* Activates the listener for the native DOM event, which when fired, is propagated by
* the {@link module:ui/template~TemplateBinding#emitter}.
*
* @param domEvtName The name of the native DOM event.
* @param domSelector The selector in the DOM to filter delegated events.
* @param data Rendering data.
* @returns A function to sever the listener binding.
*/ activateDomEventListener(domEvtName, domSelector, data) {
const callback = (evt, domEvt)=>{
if (!domSelector || domEvt.target.matches(domSelector)) {
if (typeof this.eventNameOrFunction == 'function') {
this.eventNameOrFunction(domEvt);
} else {
this.observable.fire(this.eventNameOrFunction, domEvt);
}
}
};
this.emitter.listenTo(data.node, domEvtName, callback);
// Allows revert of the listener.
return ()=>{
this.emitter.stopListening(data.node, domEvtName, callback);
};
}
}
/**
* Describes a binding to {@link module:utils/observablemixin~Observable} created by the {@link module:ui/template~BindChain#if}
* method.
*
* @internal
*/ class TemplateIfBinding extends TemplateBinding {
/**
* The value of the DOM attribute or text to be set if the {@link module:ui/template~TemplateBinding#attribute} in
* {@link module:ui/template~TemplateBinding#observable} is `true`.
*/ valueIfTrue;
constructor(def){
super(def);
this.valueIfTrue = def.valueIfTrue;
}
/**
* @inheritDoc
*/ getValue(node) {
const value = super.getValue(node);
return isFalsy(value) ? false : this.valueIfTrue || true;
}
}
/**
* Checks whether given {@link module:ui/template~TemplateValueSchema} contains a
* {@link module:ui/template~TemplateBinding}.
*/ function hasTemplateBinding(schema) {
if (!schema) {
return false;
}
// Normalize attributes with additional data like namespace:
//
// class: {
// ns: 'abc',
// value: [ ... ]
// }
//
if (schema.value) {
schema = schema.value;
}
if (Array.isArray(schema)) {
return schema.some(hasTemplateBinding);
} else if (schema instanceof TemplateBinding) {
return true;
}
return false;
}
/**
* Assembles the value using {@link module:ui/template~TemplateValueSchema} and stores it in a form of
* an Array. Each entry of the Array corresponds to one of {@link module:ui/template~TemplateValueSchema}
* items.
*
* @param node DOM Node updated when {@link module:utils/observablemixin~Observable} changes.
*/ function getValueSchemaValue(schema, node) {
return schema.map((schemaItem)=>{
// Process {@link module:ui/template~TemplateBinding} bindings.
if (schemaItem instanceof TemplateBinding) {
return schemaItem.getValue(node);
}
// All static values like strings, numbers, and "falsy" values (false, null, undefined, '', etc.) just pass.
return schemaItem;
});
}
/**
* A function executed each time the bound Observable attribute changes, which updates the DOM with a value
* constructed from {@link module:ui/template~TemplateValueSchema}.
*
* @param updater A function which updates the DOM (like attribute or text).
* @param node DOM Node updated when {@link module:utils/observablemixin~Observable} changes.
*/ function syncValueSchemaValue(schema, updater, { node }) {
const values = getValueSchemaValue(schema, node);
let value;
// Check if schema is a single Template.bind.if, like:
//
// class: Template.bind.if( 'foo' )
//
if (schema.length == 1 && schema[0] instanceof TemplateIfBinding) {
value = values[0];
} else {
value = values.reduce(arrayValueReducer, '');
}
if (isFalsy(value)) {
updater.remove();
} else {
updater.set(value);
}
}
/**
* Returns an object consisting of `set` and `remove` functions, which
* can be used in the context of DOM Node to set or reset `textContent`.
* @see module:ui/view~View#_bindToObservable
*
* @param node DOM Node to be modified.
*/ function getTextUpdater(node) {
return {
set (value) {
node.textContent = value;
},
remove () {
node.textContent = '';
}
};
}
/**
* Returns an object consisting of `set` and `remove` functions, which
* can be used in the context of DOM Node to set or reset an attribute.
* @see module:ui/view~View#_bindToObservable
*
* @param el DOM Node to be modified.
* @param attrName Name of the attribute to be modified.
* @param ns Namespace to use.
*/ function getAttributeUpdater(el, attrName, ns) {
return {
set (value) {
el.setAttributeNS(ns, attrName, value);
},
remove () {
el.removeAttributeNS(ns, attrName);
}
};
}
/**
* Returns an object consisting of `set` and `remove` functions, which
* can be used in the context of CSSStyleDeclaration to set or remove a style.
* @see module:ui/view~View#_bindToObservable
*
* @param el DOM Node to be modified.
* @param styleName Name of the style to be modified.
*/ function getStyleUpdater(el, styleName) {
return {
set (value) {
el.style[styleName] = value;
},
remove () {
el.style[styleName] = null;
}
};
}
/**
* Clones definition of the template.
*/ function clone(def) {
const clone = cloneDeepWith(def, (value)=>{
// Don't clone the `Template.bind`* bindings because of the references to Observable
// and DomEmitterMixin instances inside, which would also be traversed and cloned by greedy
// cloneDeepWith algorithm. There's no point in cloning Observable/DomEmitterMixins
// along with the definition.
//
// Don't clone Template instances if provided as a child. They're simply #render()ed
// and nothing should interfere.
//
// Also don't clone View instances if provided as a child of the Template. The template
// instance will be extracted from the View during the normalization and there's no need
// to clone it.
if (value && (value instanceof TemplateBinding || isTemplate(value) || isView(value) || isViewCollection(value))) {
return value;
}
});
return clone;
}
/**
* Normalizes given {@link module:ui/template~TemplateDefinition}.
*
* See:
* * {@link normalizeAttributes}
* * {@link normalizeListeners}
* * {@link normalizePlainTextDefinition}
* * {@link normalizeTextDefinition}
*
* @param def A template definition.
* @returns Normalized definition.
*/ function normalize(def) {
if (typeof def == 'string') {
def = normalizePlainTextDefinition(def);
} else if (def.text) {
normalizeTextDefinition(def);
}
if (def.on) {
def.eventListeners = normalizeListeners(def.on);
// Template mixes EmitterMixin, so delete #on to avoid collision.
delete def.on;
}
if (!def.text) {
if (def.attributes) {
normalizeAttributes(def.attributes);
}
const children = [];
if (def.children) {
if (isViewCollection(def.children)) {
children.push(def.children);
} else {
for (const child of def.children){
if (isTemplate(child) || isView(child) || isNode(child)) {
children.push(child);
} else {
children.push(new Template(child));
}
}
}
}
def.children = children;
}
return def;
}
/**
* Normalizes "attributes" section of {@link module:ui/template~TemplateDefinition}.
*
* ```
* attributes: {
* a: 'bar',
* b: {@link module:ui/template~TemplateBinding},
* c: {
* value: 'bar'
* }
* }
* ```
*
* becomes
*
* ```
* attributes: {
* a: [ 'bar' ],
* b: [ {@link module:ui/template~TemplateBinding} ],
* c: {
* value: [ 'bar' ]
* }
* }
* ```
*/ function normalizeAttributes(attributes) {
for(const a in attributes){
if (attributes[a].value) {
attributes[a].value = toArray(attributes[a].value);
}
arrayify(attributes, a);
}
}
/**
* Normalizes "on" section of {@link module:ui/template~TemplateDefinition}.
*
* ```
* on: {
* a: 'bar',
* b: {@link module:ui/template~TemplateBinding},
* c: [ {@link module:ui/template~TemplateBinding}, () => { ... } ]
* }
* ```
*
* becomes
*
* ```
* on: {
* a: [ 'bar' ],
* b: [ {@link module:ui/template~TemplateBinding} ],
* c: [ {@link module:ui/template~TemplateBinding}, () => { ... } ]
* }
* ```
*
* @returns Object containing normalized listeners.
*/ function normalizeListeners(listeners) {
for(const l in listeners){
arrayify(listeners, l);
}
return listeners;
}
/**
* Normalizes "string" {@link module:ui/template~TemplateDefinition}.
*
* ```
* "foo"
* ```
*
* becomes
*
* ```
* { text: [ 'foo' ] },
* ```
*
* @returns Normalized template definition.
*/ function normalizePlainTextDefinition(def) {
return {
text: [
def
]
};
}
/**
* Normalizes text {@link module:ui/template~TemplateDefinition}.
*
* ```
* children: [
* { text: 'def' },
* { text: {@link module:ui/template~TemplateBinding} }
* ]
* ```
*
* becomes
*
* ```
* children: [
* { text: [ 'def' ] },
* { text: [ {@link module:ui/template~TemplateBinding} ] }
* ]
* ```
*/ function normalizeTextDefinition(def) {
def.text = toArray(def.text);
}
/**
* Wraps an entry in Object in an Array, if not already one.
*
* ```
* {
* x: 'y',
* a: [ 'b' ]
* }
* ```
*
* becomes
*
* ```
* {
* x: [ 'y' ],
* a: [ 'b' ]
* }
* ```
*/ function arrayify(obj, key) {
obj[key] = toArray(obj[key]);
}
/**
* A helper which concatenates the value avoiding unwanted
* leading white spaces.
*/ function arrayValueReducer(prev, cur) {
if (isFalsy(cur)) {
return prev;
} else if (isFalsy(prev)) {
return cur;
} else {
return `${prev} ${cur}`;
}
}
/**
* Extends one object defined in the following format:
*
* ```
* {
* key1: [Array1],
* key2: [Array2],
* ...
* keyN: [ArrayN]
* }
* ```
*
* with another object of the same data format.
*
* @param obj Base object.
* @param ext Object extending base.
*/ function extendObjectValueArray(obj, ext) {
for(const a in ext){
if (obj[a]) {
obj[a].push(...ext[a]);
} else {
obj[a] = ext[a];
}
}
}
/**
* A helper for {@link module:ui/template~Template#extend}. Recursively extends {@link module:ui/template~Template} instance
* with content from {@link module:ui/template~TemplateDefinition}. See {@link module:ui/template~Template#extend} to learn more.
*
* @param def A template instance to be extended.
* @param def A definition which is to extend the template instance.
* @param Error context.
*/ function extendTemplate(template, def) {
if (def.attributes) {
if (!template.attributes) {
template.attributes = {};
}
extendObjectValueArray(template.attributes, def.attributes);
}
if (def.eventListeners) {
if (!template.eventListeners) {
template.eventListeners = {};
}
extendObjectValueArray(template.eventListeners, def.eventListeners);
}
if (def.text) {
template.text.push(...def.text);
}
if (def.children && def.children.length) {
if (template.children.length != def.children.length) {
/**
* The number of children in extended definition does not match.
*
* @error ui-template-extend-children-mismatch
*/ throw new CKEditorError('ui-template-extend-children-mismatch', template);
}
let childIndex = 0;
for (const childDef of def.children){
extendTemplate(template.children[childIndex++], childDef);
}
}
}
/**
* Checks if value is "falsy".
* Note: 0 (Number) is not "falsy" in this context.
*
* @param value Value to be checked.
*/ function isFalsy(value) {
return !value && value !== 0;
}
/**
* Checks if the item is an instance of {@link module:ui/view~View}
*
* @param value Value to be checked.
*/ function isView(item) {
return item instanceof View;
}
/**
* Checks if the item is an instance of {@link module:ui/template~Template}
*
* @param value Value to be checked.
*/ function isTemplate(item) {
return item instanceof Template;
}
/**
* Checks if the item is an instance of {@link module:ui/viewcollection~ViewCollection}
*
* @param value Value to be checked.
*/ function isViewCollection(item) {
return item instanceof ViewCollection;
}
/**
* Checks if value array contains the one with namespace.
*/ function isNamespaced(attrValue) {
return isObject(attrValue[0]) && attrValue[0].ns;
}
/**
* Creates an empty skeleton for {@link module:ui/template~Template#revert}
* data.
*/ function getEmptyRevertData() {
return {
children: [],
bindings: [],
attributes: {}
};
}
/**
* Checks whether an attribute should be extended when
* {@link module:ui/template~Template#apply} is called.
*
* @param attrName Attribute name to check.
*/ function shouldExtend(attrName) {
return attrName == 'class' || attrName == 'style';
}
/**
* The basic view class, which represents an HTML element created out of a
* {@link module:ui/view~View#template}. Views are building blocks of the user interface and handle
* interaction
*
* Views {@link module:ui/view~View#registerChild aggregate} children in
* {@link module:ui/view~View#createCollection collections} and manage the life cycle of DOM
* listeners e.g. by handling rendering and destruction.
*
* See the {@link module:ui/template~TemplateDefinition} syntax to learn more about shaping view
* elements, attributes and listeners.
*
* ```ts
* class SampleView extends View {
* constructor( locale ) {
* super( locale );
*
* const bind = this.bindTemplate;
*
* // Views define their interface (state) using observable attributes.
* this.set( 'elementClass', 'bar' );
*
* this.setTemplate( {
* tag: 'p',
*
* // The element of the view can be defined with its children.
* children: [
* 'Hello',
* {
* tag: 'b',
* children: [ 'world!' ]
* }
* ],
* attributes: {
* class: [
* 'foo',
*
* // Observable attributes control the state of the view in DOM.
* bind.to( 'elementClass' )
* ]
* },
* on: {
* // Views listen to DOM events and propagate them.
* click: bind.to( 'clicked' )
* }
* } );
* }
* }
*
* const view = new SampleView( locale );
*
* view.render();
*
* // Append <p class="foo bar">Hello<b>world</b></p> to the <body>
* document.body.appendChild( view.element );
*
* // Change the class attribute to <p class="foo baz">Hello<b>world</b></p>
* view.elementClass = 'baz';
*
* // Respond to the "click" event in DOM by executing a custom action.
* view.on( 'clicked', () => {
* console.log( 'The view has been clicked!' );
* } );
* ```
*/ class View extends /* #__PURE__ */ DomEmitterMixin(/* #__PURE__ */ ObservableMixin()) {
/**
* An HTML element of the view. `null` until {@link #render rendered}
* from the {@link #template}.
*
* ```ts
* class SampleView extends View {
* constructor() {
* super();
*
* // A template instance the #element will be create