diff --git a/packages/ckeditor5-engine/src/controller/datacontroller.js b/packages/ckeditor5-engine/_src/controller/datacontroller.js similarity index 100% rename from packages/ckeditor5-engine/src/controller/datacontroller.js rename to packages/ckeditor5-engine/_src/controller/datacontroller.js diff --git a/packages/ckeditor5-engine/src/controller/editingcontroller.js b/packages/ckeditor5-engine/_src/controller/editingcontroller.js similarity index 100% rename from packages/ckeditor5-engine/src/controller/editingcontroller.js rename to packages/ckeditor5-engine/_src/controller/editingcontroller.js diff --git a/packages/ckeditor5-engine/src/conversion/conversion.js b/packages/ckeditor5-engine/_src/conversion/conversion.js similarity index 100% rename from packages/ckeditor5-engine/src/conversion/conversion.js rename to packages/ckeditor5-engine/_src/conversion/conversion.js diff --git a/packages/ckeditor5-engine/src/conversion/conversionhelpers.js b/packages/ckeditor5-engine/_src/conversion/conversionhelpers.js similarity index 100% rename from packages/ckeditor5-engine/src/conversion/conversionhelpers.js rename to packages/ckeditor5-engine/_src/conversion/conversionhelpers.js diff --git a/packages/ckeditor5-engine/src/conversion/downcastdispatcher.js b/packages/ckeditor5-engine/_src/conversion/downcastdispatcher.js similarity index 100% rename from packages/ckeditor5-engine/src/conversion/downcastdispatcher.js rename to packages/ckeditor5-engine/_src/conversion/downcastdispatcher.js diff --git a/packages/ckeditor5-engine/src/conversion/downcasthelpers.js b/packages/ckeditor5-engine/_src/conversion/downcasthelpers.js similarity index 100% rename from packages/ckeditor5-engine/src/conversion/downcasthelpers.js rename to packages/ckeditor5-engine/_src/conversion/downcasthelpers.js diff --git a/packages/ckeditor5-engine/src/conversion/mapper.js b/packages/ckeditor5-engine/_src/conversion/mapper.js similarity index 100% rename from packages/ckeditor5-engine/src/conversion/mapper.js rename to packages/ckeditor5-engine/_src/conversion/mapper.js diff --git a/packages/ckeditor5-engine/src/conversion/modelconsumable.js b/packages/ckeditor5-engine/_src/conversion/modelconsumable.js similarity index 100% rename from packages/ckeditor5-engine/src/conversion/modelconsumable.js rename to packages/ckeditor5-engine/_src/conversion/modelconsumable.js diff --git a/packages/ckeditor5-engine/src/conversion/upcastdispatcher.js b/packages/ckeditor5-engine/_src/conversion/upcastdispatcher.js similarity index 100% rename from packages/ckeditor5-engine/src/conversion/upcastdispatcher.js rename to packages/ckeditor5-engine/_src/conversion/upcastdispatcher.js diff --git a/packages/ckeditor5-engine/src/conversion/upcasthelpers.js b/packages/ckeditor5-engine/_src/conversion/upcasthelpers.js similarity index 100% rename from packages/ckeditor5-engine/src/conversion/upcasthelpers.js rename to packages/ckeditor5-engine/_src/conversion/upcasthelpers.js diff --git a/packages/ckeditor5-engine/src/conversion/viewconsumable.js b/packages/ckeditor5-engine/_src/conversion/viewconsumable.js similarity index 100% rename from packages/ckeditor5-engine/src/conversion/viewconsumable.js rename to packages/ckeditor5-engine/_src/conversion/viewconsumable.js diff --git a/packages/ckeditor5-engine/src/dataprocessor/basichtmlwriter.js b/packages/ckeditor5-engine/_src/dataprocessor/basichtmlwriter.js similarity index 100% rename from packages/ckeditor5-engine/src/dataprocessor/basichtmlwriter.js rename to packages/ckeditor5-engine/_src/dataprocessor/basichtmlwriter.js diff --git a/packages/ckeditor5-engine/src/dataprocessor/dataprocessor.jsdoc b/packages/ckeditor5-engine/_src/dataprocessor/dataprocessor.jsdoc similarity index 100% rename from packages/ckeditor5-engine/src/dataprocessor/dataprocessor.jsdoc rename to packages/ckeditor5-engine/_src/dataprocessor/dataprocessor.jsdoc diff --git a/packages/ckeditor5-engine/src/dataprocessor/htmldataprocessor.js b/packages/ckeditor5-engine/_src/dataprocessor/htmldataprocessor.js similarity index 100% rename from packages/ckeditor5-engine/src/dataprocessor/htmldataprocessor.js rename to packages/ckeditor5-engine/_src/dataprocessor/htmldataprocessor.js diff --git a/packages/ckeditor5-engine/src/dataprocessor/htmlwriter.js b/packages/ckeditor5-engine/_src/dataprocessor/htmlwriter.js similarity index 100% rename from packages/ckeditor5-engine/src/dataprocessor/htmlwriter.js rename to packages/ckeditor5-engine/_src/dataprocessor/htmlwriter.js diff --git a/packages/ckeditor5-engine/src/dataprocessor/xmldataprocessor.js b/packages/ckeditor5-engine/_src/dataprocessor/xmldataprocessor.js similarity index 100% rename from packages/ckeditor5-engine/src/dataprocessor/xmldataprocessor.js rename to packages/ckeditor5-engine/_src/dataprocessor/xmldataprocessor.js diff --git a/packages/ckeditor5-engine/src/dev-utils/model.js b/packages/ckeditor5-engine/_src/dev-utils/model.js similarity index 100% rename from packages/ckeditor5-engine/src/dev-utils/model.js rename to packages/ckeditor5-engine/_src/dev-utils/model.js diff --git a/packages/ckeditor5-engine/src/dev-utils/operationreplayer.js b/packages/ckeditor5-engine/_src/dev-utils/operationreplayer.js similarity index 100% rename from packages/ckeditor5-engine/src/dev-utils/operationreplayer.js rename to packages/ckeditor5-engine/_src/dev-utils/operationreplayer.js diff --git a/packages/ckeditor5-engine/src/dev-utils/utils.js b/packages/ckeditor5-engine/_src/dev-utils/utils.js similarity index 100% rename from packages/ckeditor5-engine/src/dev-utils/utils.js rename to packages/ckeditor5-engine/_src/dev-utils/utils.js diff --git a/packages/ckeditor5-engine/src/dev-utils/view.js b/packages/ckeditor5-engine/_src/dev-utils/view.js similarity index 100% rename from packages/ckeditor5-engine/src/dev-utils/view.js rename to packages/ckeditor5-engine/_src/dev-utils/view.js diff --git a/packages/ckeditor5-engine/src/index.js b/packages/ckeditor5-engine/_src/index.js similarity index 100% rename from packages/ckeditor5-engine/src/index.js rename to packages/ckeditor5-engine/_src/index.js diff --git a/packages/ckeditor5-engine/src/model/batch.js b/packages/ckeditor5-engine/_src/model/batch.js similarity index 100% rename from packages/ckeditor5-engine/src/model/batch.js rename to packages/ckeditor5-engine/_src/model/batch.js diff --git a/packages/ckeditor5-engine/src/model/differ.js b/packages/ckeditor5-engine/_src/model/differ.js similarity index 100% rename from packages/ckeditor5-engine/src/model/differ.js rename to packages/ckeditor5-engine/_src/model/differ.js diff --git a/packages/ckeditor5-engine/src/model/document.js b/packages/ckeditor5-engine/_src/model/document.js similarity index 100% rename from packages/ckeditor5-engine/src/model/document.js rename to packages/ckeditor5-engine/_src/model/document.js diff --git a/packages/ckeditor5-engine/src/model/documentfragment.js b/packages/ckeditor5-engine/_src/model/documentfragment.js similarity index 100% rename from packages/ckeditor5-engine/src/model/documentfragment.js rename to packages/ckeditor5-engine/_src/model/documentfragment.js diff --git a/packages/ckeditor5-engine/src/model/documentselection.js b/packages/ckeditor5-engine/_src/model/documentselection.js similarity index 100% rename from packages/ckeditor5-engine/src/model/documentselection.js rename to packages/ckeditor5-engine/_src/model/documentselection.js diff --git a/packages/ckeditor5-engine/src/model/element.js b/packages/ckeditor5-engine/_src/model/element.js similarity index 100% rename from packages/ckeditor5-engine/src/model/element.js rename to packages/ckeditor5-engine/_src/model/element.js diff --git a/packages/ckeditor5-engine/src/model/history.js b/packages/ckeditor5-engine/_src/model/history.js similarity index 100% rename from packages/ckeditor5-engine/src/model/history.js rename to packages/ckeditor5-engine/_src/model/history.js diff --git a/packages/ckeditor5-engine/_src/model/item.jsdoc b/packages/ckeditor5-engine/_src/model/item.jsdoc new file mode 100644 index 00000000000..4ef8ecba1d5 --- /dev/null +++ b/packages/ckeditor5-engine/_src/model/item.jsdoc @@ -0,0 +1,14 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module engine/model/item + */ + +/** + * Item is a {@link module:engine/model/node~Node} or {@link module:engine/model/textproxy~TextProxy}. + * + * @typedef {module:engine/model/node~Node|module:engine/model/textproxy~TextProxy} module:engine/model/item~Item + */ diff --git a/packages/ckeditor5-engine/src/model/liveposition.js b/packages/ckeditor5-engine/_src/model/liveposition.js similarity index 100% rename from packages/ckeditor5-engine/src/model/liveposition.js rename to packages/ckeditor5-engine/_src/model/liveposition.js diff --git a/packages/ckeditor5-engine/src/model/liverange.js b/packages/ckeditor5-engine/_src/model/liverange.js similarity index 100% rename from packages/ckeditor5-engine/src/model/liverange.js rename to packages/ckeditor5-engine/_src/model/liverange.js diff --git a/packages/ckeditor5-engine/src/model/markercollection.js b/packages/ckeditor5-engine/_src/model/markercollection.js similarity index 100% rename from packages/ckeditor5-engine/src/model/markercollection.js rename to packages/ckeditor5-engine/_src/model/markercollection.js diff --git a/packages/ckeditor5-engine/src/model/model.js b/packages/ckeditor5-engine/_src/model/model.js similarity index 100% rename from packages/ckeditor5-engine/src/model/model.js rename to packages/ckeditor5-engine/_src/model/model.js diff --git a/packages/ckeditor5-engine/src/model/node.js b/packages/ckeditor5-engine/_src/model/node.js similarity index 100% rename from packages/ckeditor5-engine/src/model/node.js rename to packages/ckeditor5-engine/_src/model/node.js diff --git a/packages/ckeditor5-engine/src/model/nodelist.js b/packages/ckeditor5-engine/_src/model/nodelist.js similarity index 100% rename from packages/ckeditor5-engine/src/model/nodelist.js rename to packages/ckeditor5-engine/_src/model/nodelist.js diff --git a/packages/ckeditor5-engine/src/model/operation/attributeoperation.js b/packages/ckeditor5-engine/_src/model/operation/attributeoperation.js similarity index 100% rename from packages/ckeditor5-engine/src/model/operation/attributeoperation.js rename to packages/ckeditor5-engine/_src/model/operation/attributeoperation.js diff --git a/packages/ckeditor5-engine/src/model/operation/detachoperation.js b/packages/ckeditor5-engine/_src/model/operation/detachoperation.js similarity index 100% rename from packages/ckeditor5-engine/src/model/operation/detachoperation.js rename to packages/ckeditor5-engine/_src/model/operation/detachoperation.js diff --git a/packages/ckeditor5-engine/src/model/operation/insertoperation.js b/packages/ckeditor5-engine/_src/model/operation/insertoperation.js similarity index 100% rename from packages/ckeditor5-engine/src/model/operation/insertoperation.js rename to packages/ckeditor5-engine/_src/model/operation/insertoperation.js diff --git a/packages/ckeditor5-engine/src/model/operation/markeroperation.js b/packages/ckeditor5-engine/_src/model/operation/markeroperation.js similarity index 100% rename from packages/ckeditor5-engine/src/model/operation/markeroperation.js rename to packages/ckeditor5-engine/_src/model/operation/markeroperation.js diff --git a/packages/ckeditor5-engine/src/model/operation/mergeoperation.js b/packages/ckeditor5-engine/_src/model/operation/mergeoperation.js similarity index 100% rename from packages/ckeditor5-engine/src/model/operation/mergeoperation.js rename to packages/ckeditor5-engine/_src/model/operation/mergeoperation.js diff --git a/packages/ckeditor5-engine/src/model/operation/moveoperation.js b/packages/ckeditor5-engine/_src/model/operation/moveoperation.js similarity index 100% rename from packages/ckeditor5-engine/src/model/operation/moveoperation.js rename to packages/ckeditor5-engine/_src/model/operation/moveoperation.js diff --git a/packages/ckeditor5-engine/src/model/operation/nooperation.js b/packages/ckeditor5-engine/_src/model/operation/nooperation.js similarity index 100% rename from packages/ckeditor5-engine/src/model/operation/nooperation.js rename to packages/ckeditor5-engine/_src/model/operation/nooperation.js diff --git a/packages/ckeditor5-engine/src/model/operation/operation.js b/packages/ckeditor5-engine/_src/model/operation/operation.js similarity index 100% rename from packages/ckeditor5-engine/src/model/operation/operation.js rename to packages/ckeditor5-engine/_src/model/operation/operation.js diff --git a/packages/ckeditor5-engine/src/model/operation/operationfactory.js b/packages/ckeditor5-engine/_src/model/operation/operationfactory.js similarity index 100% rename from packages/ckeditor5-engine/src/model/operation/operationfactory.js rename to packages/ckeditor5-engine/_src/model/operation/operationfactory.js diff --git a/packages/ckeditor5-engine/src/model/operation/renameoperation.js b/packages/ckeditor5-engine/_src/model/operation/renameoperation.js similarity index 100% rename from packages/ckeditor5-engine/src/model/operation/renameoperation.js rename to packages/ckeditor5-engine/_src/model/operation/renameoperation.js diff --git a/packages/ckeditor5-engine/src/model/operation/rootattributeoperation.js b/packages/ckeditor5-engine/_src/model/operation/rootattributeoperation.js similarity index 100% rename from packages/ckeditor5-engine/src/model/operation/rootattributeoperation.js rename to packages/ckeditor5-engine/_src/model/operation/rootattributeoperation.js diff --git a/packages/ckeditor5-engine/src/model/operation/splitoperation.js b/packages/ckeditor5-engine/_src/model/operation/splitoperation.js similarity index 100% rename from packages/ckeditor5-engine/src/model/operation/splitoperation.js rename to packages/ckeditor5-engine/_src/model/operation/splitoperation.js diff --git a/packages/ckeditor5-engine/src/model/operation/transform.js b/packages/ckeditor5-engine/_src/model/operation/transform.js similarity index 100% rename from packages/ckeditor5-engine/src/model/operation/transform.js rename to packages/ckeditor5-engine/_src/model/operation/transform.js diff --git a/packages/ckeditor5-engine/src/model/operation/utils.js b/packages/ckeditor5-engine/_src/model/operation/utils.js similarity index 100% rename from packages/ckeditor5-engine/src/model/operation/utils.js rename to packages/ckeditor5-engine/_src/model/operation/utils.js diff --git a/packages/ckeditor5-engine/src/model/position.js b/packages/ckeditor5-engine/_src/model/position.js similarity index 100% rename from packages/ckeditor5-engine/src/model/position.js rename to packages/ckeditor5-engine/_src/model/position.js diff --git a/packages/ckeditor5-engine/src/model/range.js b/packages/ckeditor5-engine/_src/model/range.js similarity index 100% rename from packages/ckeditor5-engine/src/model/range.js rename to packages/ckeditor5-engine/_src/model/range.js diff --git a/packages/ckeditor5-engine/src/model/rootelement.js b/packages/ckeditor5-engine/_src/model/rootelement.js similarity index 100% rename from packages/ckeditor5-engine/src/model/rootelement.js rename to packages/ckeditor5-engine/_src/model/rootelement.js diff --git a/packages/ckeditor5-engine/src/model/schema.js b/packages/ckeditor5-engine/_src/model/schema.js similarity index 100% rename from packages/ckeditor5-engine/src/model/schema.js rename to packages/ckeditor5-engine/_src/model/schema.js diff --git a/packages/ckeditor5-engine/src/model/selection.js b/packages/ckeditor5-engine/_src/model/selection.js similarity index 100% rename from packages/ckeditor5-engine/src/model/selection.js rename to packages/ckeditor5-engine/_src/model/selection.js diff --git a/packages/ckeditor5-engine/src/model/text.js b/packages/ckeditor5-engine/_src/model/text.js similarity index 100% rename from packages/ckeditor5-engine/src/model/text.js rename to packages/ckeditor5-engine/_src/model/text.js diff --git a/packages/ckeditor5-engine/src/model/textproxy.js b/packages/ckeditor5-engine/_src/model/textproxy.js similarity index 100% rename from packages/ckeditor5-engine/src/model/textproxy.js rename to packages/ckeditor5-engine/_src/model/textproxy.js diff --git a/packages/ckeditor5-engine/src/model/treewalker.js b/packages/ckeditor5-engine/_src/model/treewalker.js similarity index 100% rename from packages/ckeditor5-engine/src/model/treewalker.js rename to packages/ckeditor5-engine/_src/model/treewalker.js diff --git a/packages/ckeditor5-engine/src/model/utils/autoparagraphing.js b/packages/ckeditor5-engine/_src/model/utils/autoparagraphing.js similarity index 100% rename from packages/ckeditor5-engine/src/model/utils/autoparagraphing.js rename to packages/ckeditor5-engine/_src/model/utils/autoparagraphing.js diff --git a/packages/ckeditor5-engine/src/model/utils/deletecontent.js b/packages/ckeditor5-engine/_src/model/utils/deletecontent.js similarity index 100% rename from packages/ckeditor5-engine/src/model/utils/deletecontent.js rename to packages/ckeditor5-engine/_src/model/utils/deletecontent.js diff --git a/packages/ckeditor5-engine/src/model/utils/findoptimalinsertionrange.js b/packages/ckeditor5-engine/_src/model/utils/findoptimalinsertionrange.js similarity index 100% rename from packages/ckeditor5-engine/src/model/utils/findoptimalinsertionrange.js rename to packages/ckeditor5-engine/_src/model/utils/findoptimalinsertionrange.js diff --git a/packages/ckeditor5-engine/src/model/utils/getselectedcontent.js b/packages/ckeditor5-engine/_src/model/utils/getselectedcontent.js similarity index 100% rename from packages/ckeditor5-engine/src/model/utils/getselectedcontent.js rename to packages/ckeditor5-engine/_src/model/utils/getselectedcontent.js diff --git a/packages/ckeditor5-engine/src/model/utils/insertcontent.js b/packages/ckeditor5-engine/_src/model/utils/insertcontent.js similarity index 100% rename from packages/ckeditor5-engine/src/model/utils/insertcontent.js rename to packages/ckeditor5-engine/_src/model/utils/insertcontent.js diff --git a/packages/ckeditor5-engine/src/model/utils/insertobject.js b/packages/ckeditor5-engine/_src/model/utils/insertobject.js similarity index 100% rename from packages/ckeditor5-engine/src/model/utils/insertobject.js rename to packages/ckeditor5-engine/_src/model/utils/insertobject.js diff --git a/packages/ckeditor5-engine/src/model/utils/modifyselection.js b/packages/ckeditor5-engine/_src/model/utils/modifyselection.js similarity index 100% rename from packages/ckeditor5-engine/src/model/utils/modifyselection.js rename to packages/ckeditor5-engine/_src/model/utils/modifyselection.js diff --git a/packages/ckeditor5-engine/src/model/utils/selection-post-fixer.js b/packages/ckeditor5-engine/_src/model/utils/selection-post-fixer.js similarity index 100% rename from packages/ckeditor5-engine/src/model/utils/selection-post-fixer.js rename to packages/ckeditor5-engine/_src/model/utils/selection-post-fixer.js diff --git a/packages/ckeditor5-engine/src/model/writer.js b/packages/ckeditor5-engine/_src/model/writer.js similarity index 100% rename from packages/ckeditor5-engine/src/model/writer.js rename to packages/ckeditor5-engine/_src/model/writer.js diff --git a/packages/ckeditor5-engine/src/view/attributeelement.js b/packages/ckeditor5-engine/_src/view/attributeelement.js similarity index 100% rename from packages/ckeditor5-engine/src/view/attributeelement.js rename to packages/ckeditor5-engine/_src/view/attributeelement.js diff --git a/packages/ckeditor5-engine/src/view/containerelement.js b/packages/ckeditor5-engine/_src/view/containerelement.js similarity index 100% rename from packages/ckeditor5-engine/src/view/containerelement.js rename to packages/ckeditor5-engine/_src/view/containerelement.js diff --git a/packages/ckeditor5-engine/src/view/document.js b/packages/ckeditor5-engine/_src/view/document.js similarity index 100% rename from packages/ckeditor5-engine/src/view/document.js rename to packages/ckeditor5-engine/_src/view/document.js diff --git a/packages/ckeditor5-engine/src/view/documentfragment.js b/packages/ckeditor5-engine/_src/view/documentfragment.js similarity index 100% rename from packages/ckeditor5-engine/src/view/documentfragment.js rename to packages/ckeditor5-engine/_src/view/documentfragment.js diff --git a/packages/ckeditor5-engine/src/view/documentselection.js b/packages/ckeditor5-engine/_src/view/documentselection.js similarity index 100% rename from packages/ckeditor5-engine/src/view/documentselection.js rename to packages/ckeditor5-engine/_src/view/documentselection.js diff --git a/packages/ckeditor5-engine/src/view/domconverter.js b/packages/ckeditor5-engine/_src/view/domconverter.js similarity index 100% rename from packages/ckeditor5-engine/src/view/domconverter.js rename to packages/ckeditor5-engine/_src/view/domconverter.js diff --git a/packages/ckeditor5-engine/src/view/downcastwriter.js b/packages/ckeditor5-engine/_src/view/downcastwriter.js similarity index 100% rename from packages/ckeditor5-engine/src/view/downcastwriter.js rename to packages/ckeditor5-engine/_src/view/downcastwriter.js diff --git a/packages/ckeditor5-engine/src/view/editableelement.js b/packages/ckeditor5-engine/_src/view/editableelement.js similarity index 100% rename from packages/ckeditor5-engine/src/view/editableelement.js rename to packages/ckeditor5-engine/_src/view/editableelement.js diff --git a/packages/ckeditor5-engine/src/view/element.js b/packages/ckeditor5-engine/_src/view/element.js similarity index 100% rename from packages/ckeditor5-engine/src/view/element.js rename to packages/ckeditor5-engine/_src/view/element.js diff --git a/packages/ckeditor5-engine/src/view/elementdefinition.jsdoc b/packages/ckeditor5-engine/_src/view/elementdefinition.jsdoc similarity index 100% rename from packages/ckeditor5-engine/src/view/elementdefinition.jsdoc rename to packages/ckeditor5-engine/_src/view/elementdefinition.jsdoc diff --git a/packages/ckeditor5-engine/src/view/emptyelement.js b/packages/ckeditor5-engine/_src/view/emptyelement.js similarity index 100% rename from packages/ckeditor5-engine/src/view/emptyelement.js rename to packages/ckeditor5-engine/_src/view/emptyelement.js diff --git a/packages/ckeditor5-engine/src/view/filler.js b/packages/ckeditor5-engine/_src/view/filler.js similarity index 100% rename from packages/ckeditor5-engine/src/view/filler.js rename to packages/ckeditor5-engine/_src/view/filler.js diff --git a/packages/ckeditor5-engine/src/view/item.jsdoc b/packages/ckeditor5-engine/_src/view/item.jsdoc similarity index 100% rename from packages/ckeditor5-engine/src/view/item.jsdoc rename to packages/ckeditor5-engine/_src/view/item.jsdoc diff --git a/packages/ckeditor5-engine/src/view/matcher.js b/packages/ckeditor5-engine/_src/view/matcher.js similarity index 100% rename from packages/ckeditor5-engine/src/view/matcher.js rename to packages/ckeditor5-engine/_src/view/matcher.js diff --git a/packages/ckeditor5-engine/src/view/node.js b/packages/ckeditor5-engine/_src/view/node.js similarity index 100% rename from packages/ckeditor5-engine/src/view/node.js rename to packages/ckeditor5-engine/_src/view/node.js diff --git a/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js b/packages/ckeditor5-engine/_src/view/observer/arrowkeysobserver.js similarity index 100% rename from packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js rename to packages/ckeditor5-engine/_src/view/observer/arrowkeysobserver.js diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js b/packages/ckeditor5-engine/_src/view/observer/bubblingemittermixin.js similarity index 100% rename from packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js rename to packages/ckeditor5-engine/_src/view/observer/bubblingemittermixin.js diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingeventinfo.js b/packages/ckeditor5-engine/_src/view/observer/bubblingeventinfo.js similarity index 100% rename from packages/ckeditor5-engine/src/view/observer/bubblingeventinfo.js rename to packages/ckeditor5-engine/_src/view/observer/bubblingeventinfo.js diff --git a/packages/ckeditor5-engine/src/view/observer/clickobserver.js b/packages/ckeditor5-engine/_src/view/observer/clickobserver.js similarity index 100% rename from packages/ckeditor5-engine/src/view/observer/clickobserver.js rename to packages/ckeditor5-engine/_src/view/observer/clickobserver.js diff --git a/packages/ckeditor5-engine/src/view/observer/compositionobserver.js b/packages/ckeditor5-engine/_src/view/observer/compositionobserver.js similarity index 100% rename from packages/ckeditor5-engine/src/view/observer/compositionobserver.js rename to packages/ckeditor5-engine/_src/view/observer/compositionobserver.js diff --git a/packages/ckeditor5-engine/src/view/observer/domeventdata.js b/packages/ckeditor5-engine/_src/view/observer/domeventdata.js similarity index 100% rename from packages/ckeditor5-engine/src/view/observer/domeventdata.js rename to packages/ckeditor5-engine/_src/view/observer/domeventdata.js diff --git a/packages/ckeditor5-engine/src/view/observer/domeventobserver.js b/packages/ckeditor5-engine/_src/view/observer/domeventobserver.js similarity index 100% rename from packages/ckeditor5-engine/src/view/observer/domeventobserver.js rename to packages/ckeditor5-engine/_src/view/observer/domeventobserver.js diff --git a/packages/ckeditor5-engine/src/view/observer/fakeselectionobserver.js b/packages/ckeditor5-engine/_src/view/observer/fakeselectionobserver.js similarity index 100% rename from packages/ckeditor5-engine/src/view/observer/fakeselectionobserver.js rename to packages/ckeditor5-engine/_src/view/observer/fakeselectionobserver.js diff --git a/packages/ckeditor5-engine/src/view/observer/focusobserver.js b/packages/ckeditor5-engine/_src/view/observer/focusobserver.js similarity index 100% rename from packages/ckeditor5-engine/src/view/observer/focusobserver.js rename to packages/ckeditor5-engine/_src/view/observer/focusobserver.js diff --git a/packages/ckeditor5-engine/src/view/observer/inputobserver.js b/packages/ckeditor5-engine/_src/view/observer/inputobserver.js similarity index 100% rename from packages/ckeditor5-engine/src/view/observer/inputobserver.js rename to packages/ckeditor5-engine/_src/view/observer/inputobserver.js diff --git a/packages/ckeditor5-engine/src/view/observer/keyobserver.js b/packages/ckeditor5-engine/_src/view/observer/keyobserver.js similarity index 100% rename from packages/ckeditor5-engine/src/view/observer/keyobserver.js rename to packages/ckeditor5-engine/_src/view/observer/keyobserver.js diff --git a/packages/ckeditor5-engine/src/view/observer/mouseobserver.js b/packages/ckeditor5-engine/_src/view/observer/mouseobserver.js similarity index 100% rename from packages/ckeditor5-engine/src/view/observer/mouseobserver.js rename to packages/ckeditor5-engine/_src/view/observer/mouseobserver.js diff --git a/packages/ckeditor5-engine/src/view/observer/mutationobserver.js b/packages/ckeditor5-engine/_src/view/observer/mutationobserver.js similarity index 100% rename from packages/ckeditor5-engine/src/view/observer/mutationobserver.js rename to packages/ckeditor5-engine/_src/view/observer/mutationobserver.js diff --git a/packages/ckeditor5-engine/src/view/observer/observer.js b/packages/ckeditor5-engine/_src/view/observer/observer.js similarity index 100% rename from packages/ckeditor5-engine/src/view/observer/observer.js rename to packages/ckeditor5-engine/_src/view/observer/observer.js diff --git a/packages/ckeditor5-engine/src/view/observer/selectionobserver.js b/packages/ckeditor5-engine/_src/view/observer/selectionobserver.js similarity index 100% rename from packages/ckeditor5-engine/src/view/observer/selectionobserver.js rename to packages/ckeditor5-engine/_src/view/observer/selectionobserver.js diff --git a/packages/ckeditor5-engine/src/view/observer/tabobserver.js b/packages/ckeditor5-engine/_src/view/observer/tabobserver.js similarity index 100% rename from packages/ckeditor5-engine/src/view/observer/tabobserver.js rename to packages/ckeditor5-engine/_src/view/observer/tabobserver.js diff --git a/packages/ckeditor5-engine/src/view/placeholder.js b/packages/ckeditor5-engine/_src/view/placeholder.js similarity index 100% rename from packages/ckeditor5-engine/src/view/placeholder.js rename to packages/ckeditor5-engine/_src/view/placeholder.js diff --git a/packages/ckeditor5-engine/src/view/position.js b/packages/ckeditor5-engine/_src/view/position.js similarity index 100% rename from packages/ckeditor5-engine/src/view/position.js rename to packages/ckeditor5-engine/_src/view/position.js diff --git a/packages/ckeditor5-engine/src/view/range.js b/packages/ckeditor5-engine/_src/view/range.js similarity index 100% rename from packages/ckeditor5-engine/src/view/range.js rename to packages/ckeditor5-engine/_src/view/range.js diff --git a/packages/ckeditor5-engine/src/view/rawelement.js b/packages/ckeditor5-engine/_src/view/rawelement.js similarity index 100% rename from packages/ckeditor5-engine/src/view/rawelement.js rename to packages/ckeditor5-engine/_src/view/rawelement.js diff --git a/packages/ckeditor5-engine/src/view/renderer.js b/packages/ckeditor5-engine/_src/view/renderer.js similarity index 100% rename from packages/ckeditor5-engine/src/view/renderer.js rename to packages/ckeditor5-engine/_src/view/renderer.js diff --git a/packages/ckeditor5-engine/src/view/rooteditableelement.js b/packages/ckeditor5-engine/_src/view/rooteditableelement.js similarity index 100% rename from packages/ckeditor5-engine/src/view/rooteditableelement.js rename to packages/ckeditor5-engine/_src/view/rooteditableelement.js diff --git a/packages/ckeditor5-engine/src/view/selection.js b/packages/ckeditor5-engine/_src/view/selection.js similarity index 100% rename from packages/ckeditor5-engine/src/view/selection.js rename to packages/ckeditor5-engine/_src/view/selection.js diff --git a/packages/ckeditor5-engine/src/view/styles/background.js b/packages/ckeditor5-engine/_src/view/styles/background.js similarity index 100% rename from packages/ckeditor5-engine/src/view/styles/background.js rename to packages/ckeditor5-engine/_src/view/styles/background.js diff --git a/packages/ckeditor5-engine/src/view/styles/border.js b/packages/ckeditor5-engine/_src/view/styles/border.js similarity index 100% rename from packages/ckeditor5-engine/src/view/styles/border.js rename to packages/ckeditor5-engine/_src/view/styles/border.js diff --git a/packages/ckeditor5-engine/src/view/styles/margin.js b/packages/ckeditor5-engine/_src/view/styles/margin.js similarity index 100% rename from packages/ckeditor5-engine/src/view/styles/margin.js rename to packages/ckeditor5-engine/_src/view/styles/margin.js diff --git a/packages/ckeditor5-engine/src/view/styles/padding.js b/packages/ckeditor5-engine/_src/view/styles/padding.js similarity index 100% rename from packages/ckeditor5-engine/src/view/styles/padding.js rename to packages/ckeditor5-engine/_src/view/styles/padding.js diff --git a/packages/ckeditor5-engine/src/view/styles/utils.js b/packages/ckeditor5-engine/_src/view/styles/utils.js similarity index 100% rename from packages/ckeditor5-engine/src/view/styles/utils.js rename to packages/ckeditor5-engine/_src/view/styles/utils.js diff --git a/packages/ckeditor5-engine/src/view/stylesmap.js b/packages/ckeditor5-engine/_src/view/stylesmap.js similarity index 100% rename from packages/ckeditor5-engine/src/view/stylesmap.js rename to packages/ckeditor5-engine/_src/view/stylesmap.js diff --git a/packages/ckeditor5-engine/src/view/text.js b/packages/ckeditor5-engine/_src/view/text.js similarity index 100% rename from packages/ckeditor5-engine/src/view/text.js rename to packages/ckeditor5-engine/_src/view/text.js diff --git a/packages/ckeditor5-engine/src/view/textproxy.js b/packages/ckeditor5-engine/_src/view/textproxy.js similarity index 100% rename from packages/ckeditor5-engine/src/view/textproxy.js rename to packages/ckeditor5-engine/_src/view/textproxy.js diff --git a/packages/ckeditor5-engine/src/view/treewalker.js b/packages/ckeditor5-engine/_src/view/treewalker.js similarity index 100% rename from packages/ckeditor5-engine/src/view/treewalker.js rename to packages/ckeditor5-engine/_src/view/treewalker.js diff --git a/packages/ckeditor5-engine/src/view/uielement.js b/packages/ckeditor5-engine/_src/view/uielement.js similarity index 100% rename from packages/ckeditor5-engine/src/view/uielement.js rename to packages/ckeditor5-engine/_src/view/uielement.js diff --git a/packages/ckeditor5-engine/src/view/upcastwriter.js b/packages/ckeditor5-engine/_src/view/upcastwriter.js similarity index 100% rename from packages/ckeditor5-engine/src/view/upcastwriter.js rename to packages/ckeditor5-engine/_src/view/upcastwriter.js diff --git a/packages/ckeditor5-engine/src/view/view.js b/packages/ckeditor5-engine/_src/view/view.js similarity index 100% rename from packages/ckeditor5-engine/src/view/view.js rename to packages/ckeditor5-engine/_src/view/view.js diff --git a/packages/ckeditor5-engine/package.json b/packages/ckeditor5-engine/package.json index 299bc2b75bf..65c52981030 100644 --- a/packages/ckeditor5-engine/package.json +++ b/packages/ckeditor5-engine/package.json @@ -21,7 +21,7 @@ "ckeditor5-lib", "ckeditor5-dll" ], - "main": "src/index.js", + "main": "src/index.ts", "dependencies": { "@ckeditor/ckeditor5-utils": "^35.0.1", "lodash-es": "^4.17.15" @@ -47,6 +47,7 @@ "@ckeditor/ckeditor5-ui": "^35.0.1", "@ckeditor/ckeditor5-undo": "^35.0.1", "@ckeditor/ckeditor5-widget": "^35.0.1", + "typescript": "^4.6.4", "webpack": "^5.58.1", "webpack-cli": "^4.9.0" }, @@ -65,9 +66,14 @@ }, "files": [ "lang", - "src", + "src/**/*.js", + "src/**/*.d.ts", "theme", "ckeditor5-metadata.json", "CHANGELOG.md" - ] + ], + "scripts": { + "build": "tsc -p ./tsconfig.release.json", + "postversion": "npm run build" + } } diff --git a/packages/ckeditor5-engine/src/controller/datacontroller.ts b/packages/ckeditor5-engine/src/controller/datacontroller.ts new file mode 100644 index 00000000000..45217a500e0 --- /dev/null +++ b/packages/ckeditor5-engine/src/controller/datacontroller.ts @@ -0,0 +1,641 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module engine/controller/datacontroller + */ + +import { Observable } from '@ckeditor/ckeditor5-utils/src/observablemixin'; +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; +import { Emitter } from '@ckeditor/ckeditor5-utils/src/emittermixin'; + +import Mapper from '../conversion/mapper'; + +import DowncastDispatcher, { type DowncastInsertEvent } from '../conversion/downcastdispatcher'; +import { insertAttributesAndChildren, insertText } from '../conversion/downcasthelpers'; + +import UpcastDispatcher, { + type UpcastDocumentFragmentEvent, + type UpcastElementEvent, + type UpcastTextEvent +} from '../conversion/upcastdispatcher'; +import { convertText, convertToModelFragment } from '../conversion/upcasthelpers'; + +import ViewDocumentFragment from '../view/documentfragment'; +import ViewDocument from '../view/document'; +import ViewDowncastWriter from '../view/downcastwriter'; +import type ViewElement from '../view/element'; +import { type StylesProcessor } from '../view/stylesmap'; +import { type MatcherPattern } from '../view/matcher'; + +import ModelRange from '../model/range'; +import type Model from '../model/model'; +import type ModelText from '../model/text'; +import type ModelElement from '../model/element'; +import type ModelTextProxy from '../model/textproxy'; +import type ModelDocumentFragment from '../model/documentfragment'; +import { type SchemaContextDefinition } from '../model/schema'; +import { type BatchType } from '../model/batch'; +import { autoParagraphEmptyRoots } from '../model/utils/autoparagraphing'; + +import HtmlDataProcessor from '../dataprocessor/htmldataprocessor'; +import type DataProcessor from '../dataprocessor/dataprocessor'; + +/** + * Controller for the data pipeline. The data pipeline controls how data is retrieved from the document + * and set inside it. Hence, the controller features two methods which allow to {@link ~DataController#get get} + * and {@link ~DataController#set set} data of the {@link ~DataController#model model} + * using the given: + * + * * {@link module:engine/dataprocessor/dataprocessor~DataProcessor data processor}, + * * downcast converters, + * * upcast converters. + * + * An instance of the data controller is always available in the {@link module:core/editor/editor~Editor#data `editor.data`} + * property: + * + * editor.data.get( { rootName: 'customRoot' } ); // -> '

Hello!

' + * + * @mixes module:utils/emittermixin~EmitterMixin + */ +export default class DataController extends Emitter { + public readonly model: Model; + public readonly mapper: Mapper; + public readonly downcastDispatcher: DowncastDispatcher; + public readonly upcastDispatcher: UpcastDispatcher; + public readonly viewDocument: ViewDocument; + public readonly stylesProcessor: StylesProcessor; + public readonly htmlProcessor: HtmlDataProcessor; + public processor: DataProcessor; + + private readonly _viewWriter: ViewDowncastWriter; + + /** + * Creates a data controller instance. + * + * @param {module:engine/model/model~Model} model Data model. + * @param {module:engine/view/stylesmap~StylesProcessor} stylesProcessor The styles processor instance. + */ + constructor( model: Model, stylesProcessor: StylesProcessor ) { + super(); + + /** + * Data model. + * + * @readonly + * @member {module:engine/model/model~Model} + */ + this.model = model; + + /** + * Mapper used for the conversion. It has no permanent bindings, because these are created while getting data and + * ae cleared directly after the data are converted. However, the mapper is defined as a class property, because + * it needs to be passed to the `DowncastDispatcher` as a conversion API. + * + * @readonly + * @member {module:engine/conversion/mapper~Mapper} + */ + this.mapper = new Mapper(); + + /** + * Downcast dispatcher used by the {@link #get get method}. Downcast converters should be attached to it. + * + * @readonly + * @member {module:engine/conversion/downcastdispatcher~DowncastDispatcher} + */ + this.downcastDispatcher = new DowncastDispatcher( { + mapper: this.mapper, + schema: model.schema + } ); + this.downcastDispatcher.on>( 'insert:$text', insertText(), { priority: 'lowest' } ); + this.downcastDispatcher.on( 'insert', insertAttributesAndChildren(), { priority: 'lowest' } ); + + /** + * Upcast dispatcher used by the {@link #set set method}. Upcast converters should be attached to it. + * + * @readonly + * @member {module:engine/conversion/upcastdispatcher~UpcastDispatcher} + */ + this.upcastDispatcher = new UpcastDispatcher( { + schema: model.schema + } ); + + /** + * The view document used by the data controller. + * + * @readonly + * @member {module:engine/view/document~Document} + */ + this.viewDocument = new ViewDocument( stylesProcessor ); + + /** + * Styles processor used during the conversion. + * + * @readonly + * @member {module:engine/view/stylesmap~StylesProcessor} + */ + this.stylesProcessor = stylesProcessor; + + /** + * Data processor used specifically for HTML conversion. + * + * @readonly + * @member {module:engine/dataprocessor/htmldataprocessor~HtmlDataProcessor} #htmlProcessor + */ + this.htmlProcessor = new HtmlDataProcessor( this.viewDocument ); + + /** + * Data processor used during the conversion. + * Same instance as {@link #htmlProcessor} by default. Can be replaced at run time to handle different format, e.g. XML or Markdown. + * + * @member {module:engine/dataprocessor/dataprocessor~DataProcessor} #processor + */ + this.processor = this.htmlProcessor; + + /** + * The view downcast writer just for data conversion purposes, i.e. to modify + * the {@link #viewDocument}. + * + * @private + * @readonly + * @member {module:engine/view/downcastwriter~DowncastWriter} + */ + this._viewWriter = new ViewDowncastWriter( this.viewDocument ); + + // Define default converters for text and elements. + // + // Note that if there is no default converter for the element it will be skipped, for instance `foo` will be + // converted to nothing. We therefore add `convertToModelFragment` as a last converter so it converts children of that + // element to the document fragment so `foo` will still be converted to `foo` even if there is no converter for ``. + this.upcastDispatcher.on( 'text', convertText(), { priority: 'lowest' } ); + this.upcastDispatcher.on( 'element', convertToModelFragment(), { priority: 'lowest' } ); + this.upcastDispatcher.on( 'documentFragment', convertToModelFragment(), { priority: 'lowest' } ); + + Observable.prototype.decorate.call( this, 'init' as any ); + Observable.prototype.decorate.call( this, 'set' as any ); + Observable.prototype.decorate.call( this, 'get' as any ); + + // Fire the `ready` event when the initialization has completed. Such low-level listener offers the possibility + // to plug into the initialization pipeline without interrupting the initialization flow. + this.on( 'init', () => { + this.fire( 'ready' ); + }, { priority: 'lowest' } ); + + // Fix empty roots after DataController is 'ready' (note that the init method could be decorated and stopped). + // We need to handle this event because initial data could be empty and the post-fixer would not get triggered. + this.on( 'ready', () => { + this.model.enqueueChange( { isUndoable: false }, autoParagraphEmptyRoots ); + }, { priority: 'lowest' } ); + } + + /** + * Returns the model's data converted by downcast dispatchers attached to {@link #downcastDispatcher} and + * formatted by the {@link #processor data processor}. + * + * @fires get + * @param {Object} [options] Additional configuration for the retrieved data. `DataController` provides two optional + * properties: `rootName` and `trim`. Other properties of this object are specified by various editor features. + * @param {String} [options.rootName='main'] Root name. + * @param {String} [options.trim='empty'] Whether returned data should be trimmed. This option is set to `empty` by default, + * which means whenever editor content is considered empty, an empty string will be returned. To turn off trimming completely + * use `'none'`. In such cases the exact content will be returned (for example a `

 

` for an empty editor). + * @returns {String} Output data. + */ + public get( options: Record = {} ): string { + const { rootName = 'main', trim = 'empty' } = options as Record; + + if ( !this._checkIfRootsExists( [ rootName ] ) ) { + /** + * Cannot get data from a non-existing root. This error is thrown when {@link #get DataController#get() method} + * is called with a non-existent root name. For example, if there is an editor instance with only `main` root, + * calling {@link #get} like: + * + * data.get( { rootName: 'root2' } ); + * + * will throw this error. + * + * @error datacontroller-get-non-existent-root + */ + throw new CKEditorError( 'datacontroller-get-non-existent-root', this ); + } + + const root = this.model.document.getRoot( rootName )!; + + if ( trim === 'empty' && !this.model.hasContent( root, { ignoreWhitespaces: true } ) ) { + return ''; + } + + return this.stringify( root, options ); + } + + /** + * Returns the content of the given {@link module:engine/model/element~Element model's element} or + * {@link module:engine/model/documentfragment~DocumentFragment model document fragment} converted by the downcast converters + * attached to the {@link #downcastDispatcher} and formatted by the {@link #processor data processor}. + * + * @param {module:engine/model/element~Element|module:engine/model/documentfragment~DocumentFragment} modelElementOrFragment + * The element whose content will be stringified. + * @param {Object} [options] Additional configuration passed to the conversion process. + * @returns {String} Output data. + */ + public stringify( + modelElementOrFragment: ModelElement | ModelDocumentFragment, + options: Record = {} + ): string { + // Model -> view. + const viewDocumentFragment = this.toView( modelElementOrFragment, options ); + + // View -> data. + return this.processor.toData( viewDocumentFragment ); + } + + /** + * Returns the content of the given {@link module:engine/model/element~Element model element} or + * {@link module:engine/model/documentfragment~DocumentFragment model document fragment} converted by the downcast + * converters attached to {@link #downcastDispatcher} into a + * {@link module:engine/view/documentfragment~DocumentFragment view document fragment}. + * + * @param {module:engine/model/element~Element|module:engine/model/documentfragment~DocumentFragment} modelElementOrFragment + * Element or document fragment whose content will be converted. + * @param {Object} [options={}] Additional configuration that will be available through the + * {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi#options} during the conversion process. + * @returns {module:engine/view/documentfragment~DocumentFragment} Output view DocumentFragment. + */ + public toView( + modelElementOrFragment: ModelElement | ModelDocumentFragment, + options: Record = {} + ): ViewDocumentFragment { + const viewDocument = this.viewDocument; + const viewWriter = this._viewWriter; + + // Clear bindings so the call to this method returns correct results. + this.mapper.clearBindings(); + + // First, convert elements. + const modelRange = ModelRange._createIn( modelElementOrFragment ); + const viewDocumentFragment = new ViewDocumentFragment( viewDocument ); + + this.mapper.bindElements( modelElementOrFragment, viewDocumentFragment ); + + // Prepare list of markers. + // For document fragment, simply take the markers assigned to this document fragment. + // For model root, all markers in that root will be taken. + // For model element, we need to check which markers are intersecting with this element and relatively modify the markers' ranges. + // Collapsed markers at element boundary, although considered as not intersecting with the element, will also be returned. + const markers = modelElementOrFragment.is( 'documentFragment' ) ? + modelElementOrFragment.markers : + _getMarkersRelativeToElement( modelElementOrFragment ); + + this.downcastDispatcher.convert( modelRange, markers, viewWriter, options ); + + return viewDocumentFragment; + } + + /** + * Sets the initial input data parsed by the {@link #processor data processor} and + * converted by the {@link #upcastDispatcher view-to-model converters}. + * Initial data can be only set to a document whose {@link module:engine/model/document~Document#version} is equal 0. + * + * **Note** This method is {@link module:utils/observablemixin~ObservableMixin#decorate decorated} which is + * used by e.g. collaborative editing plugin that syncs remote data on init. + * + * When data is passed as a string, it is initialized on the default `main` root: + * + * dataController.init( '

Foo

' ); // Initializes data on the `main` root only, as no other is specified. + * + * To initialize data on a different root or multiple roots at once, an object containing `rootName` - `data` pairs should be passed: + * + * dataController.init( { main: '

Foo

', title: '

Bar

' } ); // Initializes data on both the `main` and `title` roots. + * + * @fires init + * @param {String|Object.} data Input data as a string or an object containing the `rootName` - `data` + * pairs to initialize data on multiple roots at once. + * @returns {Promise} Promise that is resolved after the data is set on the editor. + */ + public init( data: string | Record ): Promise { + if ( this.model.document.version ) { + /** + * Cannot set initial data to a non-empty {@link module:engine/model/document~Document}. + * Initial data should be set once, during the {@link module:core/editor/editor~Editor} initialization, + * when the {@link module:engine/model/document~Document#version} is equal 0. + * + * @error datacontroller-init-document-not-empty + */ + throw new CKEditorError( 'datacontroller-init-document-not-empty', this ); + } + + let initialData: Record = {}; + + if ( typeof data === 'string' ) { + initialData.main = data; // Default root is 'main'. To initiate data on a different root, object should be passed. + } else { + initialData = data; + } + + if ( !this._checkIfRootsExists( Object.keys( initialData ) ) ) { + /** + * Cannot init data on a non-existent root. This error is thrown when {@link #init DataController#init() method} + * is called with non-existent root name. For example, if there is an editor instance with only `main` root, + * calling {@link #init} like: + * + * data.init( { main: '

Foo

', root2: '

Bar

' } ); + * + * will throw this error. + * + * @error datacontroller-init-non-existent-root + */ + throw new CKEditorError( 'datacontroller-init-non-existent-root', this ); + } + + this.model.enqueueChange( { isUndoable: false }, writer => { + for ( const rootName of Object.keys( initialData ) ) { + const modelRoot = this.model.document.getRoot( rootName )!; + + writer.insert( this.parse( initialData[ rootName ], modelRoot ), modelRoot, 0 ); + } + } ); + + return Promise.resolve(); + } + + /** + * Sets the input data parsed by the {@link #processor data processor} and + * converted by the {@link #upcastDispatcher view-to-model converters}. + * This method can be used any time to replace existing editor data with the new one without clearing the + * {@link module:engine/model/document~Document#history document history}. + * + * This method also creates a batch with all the changes applied. If all you need is to parse data, use + * the {@link #parse} method. + * + * When data is passed as a string it is set on the default `main` root: + * + * dataController.set( '

Foo

' ); // Sets data on the `main` root, as no other is specified. + * + * To set data on a different root or multiple roots at once, an object containing `rootName` - `data` pairs should be passed: + * + * dataController.set( { main: '

Foo

', title: '

Bar

' } ); // Sets data on the `main` and `title` roots as specified. + * + * To set the data with a preserved undo stack and add the change to the undo stack, set `{ isUndoable: true }` as a `batchType` option. + * + * dataController.set( '

Foo

', { batchType: { isUndoable: true } } ); + * + * @fires set + * @param {String|Object.} data Input data as a string or an object containing the `rootName` - `data` + * pairs to set data on multiple roots at once. + * @param {Object} [options={}] Options for setting data. + * @param {Object} [options.batchType] The batch type that will be used to create a batch for the changes applied by this method. + * By default, the batch will be set as {@link module:engine/model/batch~Batch#isUndoable not undoable} and the undo stack will be + * cleared after the new data is applied (all undo steps will be removed). If the batch type `isUndoable` flag is be set to `true`, + * the undo stack will be preserved instead and not cleared when new data is applied. + */ + public set( data: string | Record, options: { batchType?: BatchType } = {} ): void { + let newData: Record = {}; + + if ( typeof data === 'string' ) { + newData.main = data; // The default root is 'main'. To set data on a different root, an object should be passed. + } else { + newData = data; + } + + if ( !this._checkIfRootsExists( Object.keys( newData ) ) ) { + /** + * Cannot set data on a non-existent root. This error is thrown when the {@link #set DataController#set() method} + * is called with non-existent root name. For example, if there is an editor instance with only the default `main` root, + * calling {@link #set} like: + * + * data.set( { main: '

Foo

', root2: '

Bar

' } ); + * + * will throw this error. + * + * @error datacontroller-set-non-existent-root + */ + throw new CKEditorError( 'datacontroller-set-non-existent-root', this ); + } + + this.model.enqueueChange( options.batchType || {}, writer => { + writer.setSelection( null ); + writer.removeSelectionAttribute( this.model.document.selection.getAttributeKeys() ); + + for ( const rootName of Object.keys( newData ) ) { + // Save to model. + const modelRoot = this.model.document.getRoot( rootName )!; + + writer.remove( writer.createRangeIn( modelRoot ) ); + writer.insert( this.parse( newData[ rootName ], modelRoot ), modelRoot, 0 ); + } + } ); + } + + /** + * Returns the data parsed by the {@link #processor data processor} and then converted by upcast converters + * attached to the {@link #upcastDispatcher}. + * + * @see #set + * @param {String} data Data to parse. + * @param {module:engine/model/schema~SchemaContextDefinition} [context='$root'] Base context in which the view will + * be converted to the model. See: {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher#convert}. + * @returns {module:engine/model/documentfragment~DocumentFragment} Parsed data. + */ + public parse( data: string, context: SchemaContextDefinition = '$root' ): ModelDocumentFragment { + // data -> view + const viewDocumentFragment = this.processor.toView( data ); + + // view -> model + return this.toModel( viewDocumentFragment, context ); + } + + /** + * Returns the result of the given {@link module:engine/view/element~Element view element} or + * {@link module:engine/view/documentfragment~DocumentFragment view document fragment} converted by the + * {@link #upcastDispatcher view-to-model converters}, wrapped by {@link module:engine/model/documentfragment~DocumentFragment}. + * + * When marker elements were converted during the conversion process, it will be set as a document fragment's + * {@link module:engine/model/documentfragment~DocumentFragment#markers static markers map}. + * + * @param {module:engine/view/element~Element|module:engine/view/documentfragment~DocumentFragment} viewElementOrFragment + * The element or document fragment whose content will be converted. + * @param {module:engine/model/schema~SchemaContextDefinition} [context='$root'] Base context in which the view will + * be converted to the model. See: {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher#convert}. + * @returns {module:engine/model/documentfragment~DocumentFragment} Output document fragment. + */ + public toModel( + viewElementOrFragment: ViewElement | ViewDocumentFragment, + context: SchemaContextDefinition = '$root' + ): ModelDocumentFragment { + return this.model.change( writer => { + return this.upcastDispatcher.convert( viewElementOrFragment, writer, context ); + } ); + } + + /** + * Adds the style processor normalization rules. + * + * You can implement your own rules as well as use one of the available processor rules: + * + * * background: {@link module:engine/view/styles/background~addBackgroundRules} + * * border: {@link module:engine/view/styles/border~addBorderRules} + * * margin: {@link module:engine/view/styles/margin~addMarginRules} + * * padding: {@link module:engine/view/styles/padding~addPaddingRules} + * + * @param {Function} callback + */ + public addStyleProcessorRules( callback: ( stylesProcessor: StylesProcessor ) => void ): void { + callback( this.stylesProcessor ); + } + + /** + * Registers a {@link module:engine/view/matcher~MatcherPattern} on an {@link #htmlProcessor htmlProcessor} + * and a {@link #processor processor} for view elements whose content should be treated as raw data + * and not processed during the conversion from DOM to view elements. + * + * The raw data can be later accessed by the {@link module:engine/view/element~Element#getCustomProperty view element custom property} + * `"$rawContent"`. + * + * @param {module:engine/view/matcher~MatcherPattern} pattern Pattern matching all view elements whose content should + * be treated as a raw data. + */ + public registerRawContentMatcher( pattern: MatcherPattern ): void { + // No need to register the pattern if both the `htmlProcessor` and `processor` are the same instances. + if ( this.processor && this.processor !== this.htmlProcessor ) { + this.processor.registerRawContentMatcher( pattern ); + } + + this.htmlProcessor.registerRawContentMatcher( pattern ); + } + + /** + * Removes all event listeners set by the DataController. + */ + public destroy(): void { + this.stopListening(); + } + + /** + * Checks whether all provided root names are actually existing editor roots. + * + * @private + * @param {Array.} rootNames Root names to check. + * @returns {Boolean} Whether all provided root names are existing editor roots. + */ + private _checkIfRootsExists( rootNames: string[] ): boolean { + for ( const rootName of rootNames ) { + if ( !this.model.document.getRootNames().includes( rootName ) ) { + return false; + } + } + + return true; + } + + /** + * Event fired once the data initialization has finished. + * + * @event ready + */ + + /** + * An event fired after the {@link #init `init()` method} was run. It can be {@link #listenTo listened to} in order to adjust or modify + * the initialization flow. However, if the `init` event is stopped or prevented, the {@link #event:ready `ready` event} + * should be fired manually. + * + * The `init` event is fired by the decorated {@link #init} method. + * See {@link module:utils/observablemixin~ObservableMixin#decorate} for more information and samples. + * + * @event init + */ + + /** + * An event fired after {@link #set set() method} has been run. + * + * The `set` event is fired by the decorated {@link #set} method. + * See {@link module:utils/observablemixin~ObservableMixin#decorate} for more information and samples. + * + * @event set + */ + + /** + * Event fired after the {@link #get get() method} has been run. + * + * The `get` event is fired by the decorated {@link #get} method. + * See {@link module:utils/observablemixin~ObservableMixin#decorate} for more information and samples. + * + * @event get + */ +} + +// Helper function for downcast conversion. +// +// Takes a document element (element that is added to a model document) and checks which markers are inside it. If the marker is collapsed +// at element boundary, it is considered as contained inside the element and marker range is returned. Otherwise, if the marker is +// intersecting with the element, the intersection is returned. +function _getMarkersRelativeToElement( element: ModelElement ): Map { + const result: [ string, ModelRange ][] = []; + const doc = element.root.document; + + if ( !doc ) { + return new Map(); + } + + const elementRange = ModelRange._createIn( element ); + + for ( const marker of doc.model.markers ) { + const markerRange = marker.getRange(); + + const isMarkerCollapsed = markerRange.isCollapsed; + const isMarkerAtElementBoundary = markerRange.start.isEqual( elementRange.start ) || markerRange.end.isEqual( elementRange.end ); + + if ( isMarkerCollapsed && isMarkerAtElementBoundary ) { + result.push( [ marker.name, markerRange ] ); + } else { + const updatedMarkerRange = elementRange.getIntersection( markerRange ); + + if ( updatedMarkerRange ) { + result.push( [ marker.name, updatedMarkerRange ] ); + } + } + } + + // Sort the markers in a stable fashion to ensure that the order in which they are + // added to the model's marker collection does not affect how they are + // downcast. One particular use case that we are targeting here, is one where + // two markers are adjacent but not overlapping, such as an insertion/deletion + // suggestion pair representing the replacement of a range of text. In this + // case, putting the markers in DOM order causes the first marker's end to be + // serialized right after the second marker's start, while putting the markers + // in reverse DOM order causes it to be right before the second marker's + // start. So, we sort these in a way that ensures non-intersecting ranges are in + // reverse DOM order, and intersecting ranges are in something approximating + // reverse DOM order (since reverse DOM order doesn't have a precise meaning + // when working with intersecting ranges). + result.sort( ( [ n1, r1 ], [ n2, r2 ] ) => { + if ( r1.end.compareWith( r2.start ) !== 'after' ) { + // m1.end <= m2.start -- m1 is entirely <= m2 + return 1; + } else if ( r1.start.compareWith( r2.end ) !== 'before' ) { + // m1.start >= m2.end -- m1 is entirely >= m2 + return -1; + } else { + // they overlap, so use their start positions as the primary sort key and + // end positions as the secondary sort key + switch ( r1.start.compareWith( r2.start ) ) { + case 'before': + return 1; + case 'after': + return -1; + default: + switch ( r1.end.compareWith( r2.end ) ) { + case 'before': + return 1; + case 'after': + return -1; + default: + return n2.localeCompare( n1 ); + } + } + } + } ); + + return new Map( result ); +} diff --git a/packages/ckeditor5-engine/src/controller/editingcontroller.ts b/packages/ckeditor5-engine/src/controller/editingcontroller.ts new file mode 100644 index 00000000000..7845d42f257 --- /dev/null +++ b/packages/ckeditor5-engine/src/controller/editingcontroller.ts @@ -0,0 +1,243 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module engine/controller/editingcontroller + */ + +import RootEditableElement from '../view/rooteditableelement'; +import View from '../view/view'; +import Mapper from '../conversion/mapper'; +import DowncastDispatcher, { + type DowncastInsertEvent, + type DowncastRemoveEvent, + type DowncastSelectionEvent +} from '../conversion/downcastdispatcher'; +import { + clearAttributes, + convertCollapsedSelection, + convertRangeSelection, + insertAttributesAndChildren, + insertText, + remove +} from '../conversion/downcasthelpers'; + +import { Observable } from '@ckeditor/ckeditor5-utils/src/observablemixin'; +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; +import { convertSelectionChange } from '../conversion/upcasthelpers'; + +import type Model from '../model/model'; +import type ModelItem from '../model/item'; +import type ModelText from '../model/text'; +import type ModelTextProxy from '../model/textproxy'; +import type { ChangeEvent } from '../model/document'; +import type { Marker } from '../model/markercollection'; +import type { StylesProcessor } from '../view/stylesmap'; +import type { SelectionObserverEvent } from '../view/observer/selectionobserver'; + +// @if CK_DEBUG_ENGINE // const { dumpTrees, initDocumentDumping } = require( '../dev-utils/utils' ); + +/** + * A controller for the editing pipeline. The editing pipeline controls the {@link ~EditingController#model model} rendering, + * including selection handling. It also creates the {@link ~EditingController#view view} which builds a + * browser-independent virtualization over the DOM elements. The editing controller also attaches default converters. + * + * @mixes module:utils/observablemixin~ObservableMixin + */ +export default class EditingController extends Observable { + public readonly model: Model; + public readonly view: View; + public readonly mapper: Mapper; + public readonly downcastDispatcher: DowncastDispatcher; + + /** + * Creates an editing controller instance. + * + * @param {module:engine/model/model~Model} model Editing model. + * @param {module:engine/view/stylesmap~StylesProcessor} stylesProcessor The styles processor instance. + */ + constructor( model: Model, stylesProcessor: StylesProcessor ) { + super(); + + /** + * Editor model. + * + * @readonly + * @member {module:engine/model/model~Model} + */ + this.model = model; + + /** + * Editing view controller. + * + * @readonly + * @member {module:engine/view/view~View} + */ + this.view = new View( stylesProcessor ); + + /** + * A mapper that describes the model-view binding. + * + * @readonly + * @member {module:engine/conversion/mapper~Mapper} + */ + this.mapper = new Mapper(); + + /** + * Downcast dispatcher that converts changes from the model to the {@link #view editing view}. + * + * @readonly + * @member {module:engine/conversion/downcastdispatcher~DowncastDispatcher} #downcastDispatcher + */ + this.downcastDispatcher = new DowncastDispatcher( { + mapper: this.mapper, + schema: model.schema + } ); + + const doc = this.model.document; + const selection = doc.selection; + const markers = this.model.markers; + + // When plugins listen on model changes (on selection change, post fixers, etc.) and change the view as a result of + // the model's change, they might trigger view rendering before the conversion is completed (e.g. before the selection + // is converted). We disable rendering for the length of the outermost model change() block to prevent that. + // + // See https://github.com/ckeditor/ckeditor5-engine/issues/1528 + this.listenTo( this.model, '_beforeChanges', () => { + this.view._disableRendering( true ); + }, { priority: 'highest' } ); + + this.listenTo( this.model, '_afterChanges', () => { + this.view._disableRendering( false ); + }, { priority: 'lowest' } ); + + // Whenever model document is changed, convert those changes to the view (using model.Document#differ). + // Do it on 'low' priority, so changes are converted after other listeners did their job. + // Also convert model selection. + this.listenTo( doc, 'change', () => { + this.view.change( writer => { + this.downcastDispatcher.convertChanges( doc.differ, markers, writer ); + this.downcastDispatcher.convertSelection( selection, markers, writer ); + } ); + }, { priority: 'low' } ); + + // Convert selection from the view to the model when it changes in the view. + this.listenTo( this.view.document, 'selectionChange', convertSelectionChange( this.model, this.mapper ) ); + + // Attach default model converters. + this.downcastDispatcher.on>( 'insert:$text', insertText(), { priority: 'lowest' } ); + this.downcastDispatcher.on( 'insert', insertAttributesAndChildren(), { priority: 'lowest' } ); + this.downcastDispatcher.on( 'remove', remove(), { priority: 'low' } ); + + // Attach default model selection converters. + this.downcastDispatcher.on( 'selection', clearAttributes(), { priority: 'high' } ); + this.downcastDispatcher.on( 'selection', convertRangeSelection(), { priority: 'low' } ); + this.downcastDispatcher.on( 'selection', convertCollapsedSelection(), { priority: 'low' } ); + + // Binds {@link module:engine/view/document~Document#roots view roots collection} to + // {@link module:engine/model/document~Document#roots model roots collection} so creating + // model root automatically creates corresponding view root. + this.view.document.roots.bindTo( this.model.document.roots ).using( root => { + // $graveyard is a special root that has no reflection in the view. + if ( root.rootName == '$graveyard' ) { + return null; + } + + const viewRoot = new RootEditableElement( this.view.document, root.name ); + + viewRoot.rootName = root.rootName; + this.mapper.bindElements( root, viewRoot ); + + return viewRoot; + } ); + + // @if CK_DEBUG_ENGINE // initDocumentDumping( this.model.document ); + // @if CK_DEBUG_ENGINE // initDocumentDumping( this.view.document ); + + // @if CK_DEBUG_ENGINE // dumpTrees( this.model.document, this.model.document.version ); + // @if CK_DEBUG_ENGINE // dumpTrees( this.view.document, this.model.document.version ); + + // @if CK_DEBUG_ENGINE // this.model.document.on( 'change', () => { + // @if CK_DEBUG_ENGINE // dumpTrees( this.view.document, this.model.document.version ); + // @if CK_DEBUG_ENGINE // }, { priority: 'lowest' } ); + } + + /** + * Removes all event listeners attached to the `EditingController`. Destroys all objects created + * by `EditingController` that need to be destroyed. + */ + public destroy(): void { + this.view.destroy(); + this.stopListening(); + } + + /** + * Calling this method will refresh the marker by triggering the downcast conversion for it. + * + * Reconverting the marker is useful when you want to change its {@link module:engine/view/element~Element view element} + * without changing any marker data. For instance: + * + * let isCommentActive = false; + * + * model.conversion.markerToHighlight( { + * model: 'comment', + * view: data => { + * const classes = [ 'comment-marker' ]; + * + * if ( isCommentActive ) { + * classes.push( 'comment-marker--active' ); + * } + * + * return { classes }; + * } + * } ); + * + * // ... + * + * // Change the property that indicates if marker is displayed as active or not. + * isCommentActive = true; + * + * // Reconverting will downcast and synchronize the marker with the new isCommentActive state value. + * editor.editing.reconvertMarker( 'comment' ); + * + * **Note**: If you want to reconvert a model item, use {@link #reconvertItem} instead. + * + * @param {String|module:engine/model/markercollection~Marker} markerOrName Name of a marker to update, or a marker instance. + */ + public reconvertMarker( markerOrName: Marker | string ): void { + const markerName = typeof markerOrName == 'string' ? markerOrName : markerOrName.name; + const currentMarker = this.model.markers.get( markerName ); + + if ( !currentMarker ) { + /** + * The marker with the provided name does not exist and cannot be reconverted. + * + * @error editingcontroller-reconvertmarker-marker-not-exist + * @param {String} markerName The name of the reconverted marker. + */ + throw new CKEditorError( 'editingcontroller-reconvertmarker-marker-not-exist', this, { markerName } ); + } + + this.model.change( () => { + this.model.markers._refresh( currentMarker ); + } ); + } + + /** + * Calling this method will downcast a model item on demand (by requesting a refresh in the {@link module:engine/model/differ~Differ}). + * + * You can use it if you want the view representation of a specific item updated as a response to external modifications. For instance, + * when the view structure depends not only on the associated model data but also on some external state. + * + * **Note**: If you want to reconvert a model marker, use {@link #reconvertMarker} instead. + * + * @param {module:engine/model/item~Item} item Item to refresh. + */ + public reconvertItem( item: ModelItem ): void { + this.model.change( () => { + this.model.document.differ._refreshItem( item ); + } ); + } +} diff --git a/packages/ckeditor5-engine/src/conversion/conversion.ts b/packages/ckeditor5-engine/src/conversion/conversion.ts new file mode 100644 index 00000000000..542b49e9826 --- /dev/null +++ b/packages/ckeditor5-engine/src/conversion/conversion.ts @@ -0,0 +1,731 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module engine/conversion/conversion + */ + +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; +import UpcastHelpers from './upcasthelpers'; +import DowncastHelpers, { + type AttributeCreatorFunction, + type AttributeDescriptor +} from './downcasthelpers'; +import toArray, { type ArrayOrItem } from '@ckeditor/ckeditor5-utils/src/toarray'; + +import type DowncastDispatcher from './downcastdispatcher'; +import type UpcastDispatcher from './upcastdispatcher'; +import type { PriorityString } from '@ckeditor/ckeditor5-utils/src/priorities'; +import type ElementDefinition from '../view/elementdefinition'; +import type { MatcherPattern } from '../view/matcher'; + +/** + * A utility class that helps add converters to upcast and downcast dispatchers. + * + * We recommend reading the {@glink framework/guides/deep-dive/conversion/intro editor conversion} guide first to + * understand the core concepts of the conversion mechanisms. + * + * An instance of the conversion manager is available in the + * {@link module:core/editor/editor~Editor#conversion `editor.conversion`} property + * and by default has the following groups of dispatchers (i.e. directions of conversion): + * + * * `downcast` (editing and data downcasts) + * * `editingDowncast` + * * `dataDowncast` + * * `upcast` + * + * # One-way converters + * + * To add a converter to a specific group, use the {@link module:engine/conversion/conversion~Conversion#for `for()`} + * method: + * + * // Add a converter to editing downcast and data downcast. + * editor.conversion.for( 'downcast' ).elementToElement( config ) ); + * + * // Add a converter to the data pipepline only: + * editor.conversion.for( 'dataDowncast' ).elementToElement( dataConversionConfig ) ); + * + * // And a slightly different one for the editing pipeline: + * editor.conversion.for( 'editingDowncast' ).elementToElement( editingConversionConfig ) ); + * + * See {@link module:engine/conversion/conversion~Conversion#for `for()`} method documentation to learn more about + * available conversion helpers and how to use your custom ones. + * + * # Two-way converters + * + * Besides using one-way converters via the `for()` method, you can also use other methods available in this + * class to add two-way converters (upcast and downcast): + * + * * {@link module:engine/conversion/conversion~Conversion#elementToElement `elementToElement()`} – + * Model element to view element and vice versa. + * * {@link module:engine/conversion/conversion~Conversion#attributeToElement `attributeToElement()`} – + * Model attribute to view element and vice versa. + * * {@link module:engine/conversion/conversion~Conversion#attributeToAttribute `attributeToAttribute()`} – + * Model attribute to view attribute and vice versa. + */ +export default class Conversion { + private readonly _helpers: Map; + private readonly _downcast: DowncastDispatcher[]; + private readonly _upcast: UpcastDispatcher[]; + + /** + * Creates a new conversion instance. + * + * @param {module:engine/conversion/downcastdispatcher~DowncastDispatcher| + * Array.} downcastDispatchers + * @param {module:engine/conversion/upcastdispatcher~UpcastDispatcher| + * Array.} upcastDispatchers + */ + constructor( + downcastDispatchers: ArrayOrItem, + upcastDispatchers: ArrayOrItem + ) { + /** + * Maps dispatchers group name to ConversionHelpers instances. + * + * @private + * @member {Map.} + */ + this._helpers = new Map(); + + // Define default 'downcast' & 'upcast' dispatchers groups. Those groups are always available as two-way converters needs them. + this._downcast = toArray( downcastDispatchers ); + this._createConversionHelpers( { name: 'downcast', dispatchers: this._downcast, isDowncast: true } ); + + this._upcast = toArray( upcastDispatchers ); + this._createConversionHelpers( { name: 'upcast', dispatchers: this._upcast, isDowncast: false } ); + } + + public addAlias( + alias: `${ string }Downcast`, + dispatcher: DowncastDispatcher + ): void; + public addAlias( + alias: `${ string }Upcast`, + dispatcher: UpcastDispatcher + ): void; + public addAlias( + alias: string, + dispatcher: DowncastDispatcher | UpcastDispatcher + ): void; + + /** + * Define an alias for registered dispatcher. + * + * const conversion = new Conversion( + * [ dataDowncastDispatcher, editingDowncastDispatcher ], + * upcastDispatcher + * ); + * + * conversion.addAlias( 'dataDowncast', dataDowncastDispatcher ); + * + * @param {String} alias An alias of a dispatcher. + * @param {module:engine/conversion/downcastdispatcher~DowncastDispatcher| + * module:engine/conversion/upcastdispatcher~UpcastDispatcher} dispatcher Dispatcher which should have an alias. + */ + public addAlias( + alias: string, + dispatcher: DowncastDispatcher | UpcastDispatcher + ): void { + const isDowncast = this._downcast.includes( dispatcher as any ); + const isUpcast = this._upcast.includes( dispatcher as any ); + + if ( !isUpcast && !isDowncast ) { + /** + * Trying to register an alias for a dispatcher that nas not been registered. + * + * @error conversion-add-alias-dispatcher-not-registered + */ + throw new CKEditorError( + 'conversion-add-alias-dispatcher-not-registered', + this + ); + } + + this._createConversionHelpers( { name: alias, dispatchers: [ dispatcher ], isDowncast } ); + } + + public for( groupName: 'downcast' | `${ string }Downcast` ): DowncastHelpers; + public for( groupName: 'upcast' | `${ string }Upcast` ): UpcastHelpers; + public for( groupName: string ): DowncastHelpers | UpcastHelpers; + + /** + * Provides a chainable API to assign converters to a conversion dispatchers group. + * + * If the given group name has not been registered, the + * {@link module:utils/ckeditorerror~CKEditorError `conversion-for-unknown-group` error} is thrown. + * + * You can use conversion helpers available directly in the `for()` chain or your custom ones via + * the {@link module:engine/conversion/conversionhelpers~ConversionHelpers#add `add()`} method. + * + * # Using built-in conversion helpers + * + * The `for()` chain comes with a set of conversion helpers which you can use like this: + * + * editor.conversion.for( 'downcast' ) + * .elementToElement( config1 ) // Adds an element-to-element downcast converter. + * .attributeToElement( config2 ); // Adds an attribute-to-element downcast converter. + * + * editor.conversion.for( 'upcast' ) + * .elementToAttribute( config3 ); // Adds an element-to-attribute upcast converter. + * + * Refer to the documentation of built-in conversion helpers to learn about their configuration options. + * + * * downcast (model-to-view) conversion helpers: + * + * * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement `elementToElement()`}, + * * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#attributeToElement `attributeToElement()`}, + * * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#attributeToAttribute `attributeToAttribute()`}. + * * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToElement `markerToElement()`}. + * * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToHighlight `markerToHighlight()`}. + * + * * upcast (view-to-model) conversion helpers: + * + * * {@link module:engine/conversion/upcasthelpers~UpcastHelpers#elementToElement `elementToElement()`}, + * * {@link module:engine/conversion/upcasthelpers~UpcastHelpers#elementToAttribute `elementToAttribute()`}, + * * {@link module:engine/conversion/upcasthelpers~UpcastHelpers#attributeToAttribute `attributeToAttribute()`}. + * * {@link module:engine/conversion/upcasthelpers~UpcastHelpers#elementToMarker `elementToMarker()`}. + * + * # Using custom conversion helpers + * + * If you need to implement an atypical converter, you can do so by calling: + * + * editor.conversion.for( direction ).add( customHelper ); + * + * The `.add()` method takes exactly one parameter, which is a function. This function should accept one parameter that + * is a dispatcher instance. The function should add an actual converter to the passed dispatcher instance. + * + * Example: + * + * editor.conversion.for( 'upcast' ).add( dispatcher => { + * dispatcher.on( 'element:a', ( evt, data, conversionApi ) => { + * // Do something with a view element. + * } ); + * } ); + * + * Refer to the documentation of {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher} + * and {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher} to learn how to write + * custom converters. + * + * @param {String} groupName The name of dispatchers group to add the converters to. + * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers|module:engine/conversion/upcasthelpers~UpcastHelpers} + */ + public for( groupName: string ): DowncastHelpers | UpcastHelpers { + if ( !this._helpers.has( groupName ) ) { + /** + * Trying to add a converter to an unknown dispatchers group. + * + * @error conversion-for-unknown-group + */ + throw new CKEditorError( 'conversion-for-unknown-group', this ); + } + + return this._helpers.get( groupName )!; + } + + /** + * Sets up converters between the model and the view that convert a model element to a view element (and vice versa). + * For example, the model `Foo` is turned into `

Foo

` in the view. + * + * // A simple conversion from the `paragraph` model element to the `

` view element (and vice versa). + * editor.conversion.elementToElement( { model: 'paragraph', view: 'p' } ); + * + * // Override other converters by specifying a converter definition with a higher priority. + * editor.conversion.elementToElement( { model: 'paragraph', view: 'div', converterPriority: 'high' } ); + * + * // View specified as an object instead of a string. + * editor.conversion.elementToElement( { + * model: 'fancyParagraph', + * view: { + * name: 'p', + * classes: 'fancy' + * } + * } ); + * + * // Use `upcastAlso` to define other view elements that should also be converted to a `paragraph` element. + * editor.conversion.elementToElement( { + * model: 'paragraph', + * view: 'p', + * upcastAlso: [ + * 'div', + * { + * // Any element with the `display: block` style. + * styles: { + * display: 'block' + * } + * } + * ] + * } ); + * + * // `upcastAlso` set as callback enables a conversion of a wide range of different view elements. + * editor.conversion.elementToElement( { + * model: 'heading', + * view: 'h2', + * // Convert "heading-like" paragraphs to headings. + * upcastAlso: viewElement => { + * const fontSize = viewElement.getStyle( 'font-size' ); + * + * if ( !fontSize ) { + * return null; + * } + * + * const match = fontSize.match( /(\d+)\s*px/ ); + * + * if ( !match ) { + * return null; + * } + * + * const size = Number( match[ 1 ] ); + * + * if ( size > 26 ) { + * // Returned value can be an object with the matched properties. + * // These properties will be "consumed" during the conversion. + * // See `engine.view.Matcher~MatcherPattern` and `engine.view.Matcher#match` for more details. + * + * return { name: true, styles: [ 'font-size' ] }; + * } + * + * return null; + * } + * } ); + * + * `definition.model` is a `String` with a model element name to convert from or to. + * See {@link module:engine/conversion/conversion~ConverterDefinition} to learn about other parameters. + * + * @param {module:engine/conversion/conversion~ConverterDefinition} definition The converter definition. + */ + public elementToElement( definition: { + model: string; + view: ElementDefinition; + upcastAlso?: ArrayOrItem; + converterPriority?: PriorityString | number; + } ): void { + // Set up downcast converter. + this.for( 'downcast' ).elementToElement( definition ); + + // Set up upcast converter. + for ( const { model, view } of _getAllUpcastDefinitions( definition ) ) { + this.for( 'upcast' ) + .elementToElement( { + model, + view, + converterPriority: definition.converterPriority + } ); + } + } + + /** + * Sets up converters between the model and the view that convert a model attribute to a view element (and vice versa). + * For example, a model text node with `"Foo"` as data and the `bold` attribute will be turned to `Foo` in the view. + * + * // A simple conversion from the `bold=true` attribute to the `` view element (and vice versa). + * editor.conversion.attributeToElement( { model: 'bold', view: 'strong' } ); + * + * // Override other converters by specifying a converter definition with a higher priority. + * editor.conversion.attributeToElement( { model: 'bold', view: 'b', converterPriority: 'high' } ); + * + * // View specified as an object instead of a string. + * editor.conversion.attributeToElement( { + * model: 'bold', + * view: { + * name: 'span', + * classes: 'bold' + * } + * } ); + * + * // Use `config.model.name` to define the conversion only from a given node type, `$text` in this case. + * // The same attribute on different elements may then be handled by a different converter. + * editor.conversion.attributeToElement( { + * model: { + * key: 'textDecoration', + * values: [ 'underline', 'lineThrough' ], + * name: '$text' + * }, + * view: { + * underline: { + * name: 'span', + * styles: { + * 'text-decoration': 'underline' + * } + * }, + * lineThrough: { + * name: 'span', + * styles: { + * 'text-decoration': 'line-through' + * } + * } + * } + * } ); + * + * // Use `upcastAlso` to define other view elements that should also be converted to the `bold` attribute. + * editor.conversion.attributeToElement( { + * model: 'bold', + * view: 'strong', + * upcastAlso: [ + * 'b', + * { + * name: 'span', + * classes: 'bold' + * }, + * { + * name: 'span', + * styles: { + * 'font-weight': 'bold' + * } + * }, + * viewElement => { + * const fontWeight = viewElement.getStyle( 'font-weight' ); + * + * if ( viewElement.is( 'element', 'span' ) && fontWeight && /\d+/.test() && Number( fontWeight ) > 500 ) { + * // Returned value can be an object with the matched properties. + * // These properties will be "consumed" during the conversion. + * // See `engine.view.Matcher~MatcherPattern` and `engine.view.Matcher#match` for more details. + * + * return { + * name: true, + * styles: [ 'font-weight' ] + * }; + * } + * } + * ] + * } ); + * + * // Conversion from and to a model attribute key whose value is an enum (`fontSize=big|small`). + * // `upcastAlso` set as callback enables a conversion of a wide range of different view elements. + * editor.conversion.attributeToElement( { + * model: { + * key: 'fontSize', + * values: [ 'big', 'small' ] + * }, + * view: { + * big: { + * name: 'span', + * styles: { + * 'font-size': '1.2em' + * } + * }, + * small: { + * name: 'span', + * styles: { + * 'font-size': '0.8em' + * } + * } + * }, + * upcastAlso: { + * big: viewElement => { + * const fontSize = viewElement.getStyle( 'font-size' ); + * + * if ( !fontSize ) { + * return null; + * } + * + * const match = fontSize.match( /(\d+)\s*px/ ); + * + * if ( !match ) { + * return null; + * } + * + * const size = Number( match[ 1 ] ); + * + * if ( viewElement.is( 'element', 'span' ) && size > 10 ) { + * // Returned value can be an object with the matched properties. + * // These properties will be "consumed" during the conversion. + * // See `engine.view.Matcher~MatcherPattern` and `engine.view.Matcher#match` for more details. + * + * return { name: true, styles: [ 'font-size' ] }; + * } + * + * return null; + * }, + * small: viewElement => { + * const fontSize = viewElement.getStyle( 'font-size' ); + * + * if ( !fontSize ) { + * return null; + * } + * + * const match = fontSize.match( /(\d+)\s*px/ ); + * + * if ( !match ) { + * return null; + * } + * + * const size = Number( match[ 1 ] ); + * + * if ( viewElement.is( 'element', 'span' ) && size < 10 ) { + * // Returned value can be an object with the matched properties. + * // These properties will be "consumed" during the conversion. + * // See `engine.view.Matcher~MatcherPattern` and `engine.view.Matcher#match` for more details. + * + * return { name: true, styles: [ 'font-size' ] }; + * } + * + * return null; + * } + * } + * } ); + * + * The `definition.model` parameter specifies which model attribute should be converted from or to. It can be a `{ key, value }` object + * describing the attribute key and value to convert or a `String` specifying just the attribute key (in such a case + * `value` is set to `true`). + * See {@link module:engine/conversion/conversion~ConverterDefinition} to learn about other parameters. + * + * @param {module:engine/conversion/conversion~ConverterDefinition} definition The converter definition. + */ + public attributeToElement( + definition: { + model: string | { + key: string; + name?: string; + }; + view: ElementDefinition; + upcastAlso?: ArrayOrItem; + converterPriority?: PriorityString | number; + } | { + model: { + key: string; + name?: string; + values: TValues[]; + }; + view: Record; + upcastAlso?: Record; + converterPriority?: PriorityString | number; + } + ): void { + // Set up downcast converter. + this.for( 'downcast' ).attributeToElement( definition ); + + // Set up upcast converter. + for ( const { model, view } of _getAllUpcastDefinitions( definition ) ) { + this.for( 'upcast' ) + .elementToAttribute( { + view, + model, + converterPriority: definition.converterPriority + } ); + } + } + + /** + * Sets up converters between the model and the view that convert a model attribute to a view attribute (and vice versa). For example, + * `` is converted to `` (the same attribute key and value). + * This type of converters is intended to be used with {@link module:engine/model/element~Element model element} nodes. + * To convert the text attributes, + * the {@link module:engine/conversion/conversion~Conversion#attributeToElement `attributeToElement converter`}should be set up. + * + * // A simple conversion from the `source` model attribute to the `src` view attribute (and vice versa). + * editor.conversion.attributeToAttribute( { model: 'source', view: 'src' } ); + * + * // Attribute values are strictly specified. + * editor.conversion.attributeToAttribute( { + * model: { + * name: 'imageInline', + * key: 'aside', + * values: [ 'aside' ] + * }, + * view: { + * aside: { + * name: 'img', + * key: 'class', + * value: [ 'aside', 'half-size' ] + * } + * } + * } ); + * + * // Set the style attribute. + * editor.conversion.attributeToAttribute( { + * model: { + * name: 'imageInline', + * key: 'aside', + * values: [ 'aside' ] + * }, + * view: { + * aside: { + * name: 'img', + * key: 'style', + * value: { + * float: 'right', + * width: '50%', + * margin: '5px' + * } + * } + * } + * } ); + * + * // Conversion from and to a model attribute key whose value is an enum (`align=right|center`). + * // Use `upcastAlso` to define other view elements that should also be converted to the `align=right` attribute. + * editor.conversion.attributeToAttribute( { + * model: { + * key: 'align', + * values: [ 'right', 'center' ] + * }, + * view: { + * right: { + * key: 'class', + * value: 'align-right' + * }, + * center: { + * key: 'class', + * value: 'align-center' + * } + * }, + * upcastAlso: { + * right: { + * styles: { + * 'text-align': 'right' + * } + * }, + * center: { + * styles: { + * 'text-align': 'center' + * } + * } + * } + * } ); + * + * The `definition.model` parameter specifies which model attribute should be converted from and to. + * It can be a `{ key, [ values ], [ name ] }` object or a `String`, which will be treated like `{ key: definition.model }`. + * The `key` property is the model attribute key to convert from and to. + * The `values` are the possible model attribute values. If the `values` parameter is not set, the model attribute value + * will be the same as the view attribute value. + * If `name` is set, the conversion will be set up only for model elements with the given name. + * + * The `definition.view` parameter specifies which view attribute should be converted from and to. + * It can be a `{ key, value, [ name ] }` object or a `String`, which will be treated like `{ key: definition.view }`. + * The `key` property is the view attribute key to convert from and to. + * The `value` is the view attribute value to convert from and to. If `definition.value` is not set, the view attribute value will be + * the same as the model attribute value. + * If `key` is `'class'`, `value` can be a `String` or an array of `String`s. + * If `key` is `'style'`, `value` is an object with key-value pairs. + * In other cases, `value` is a `String`. + * If `name` is set, the conversion will be set up only for model elements with the given name. + * If `definition.model.values` is set, `definition.view` is an object that assigns values from `definition.model.values` + * to `{ key, value, [ name ] }` objects. + * + * `definition.upcastAlso` specifies which other matching view elements should also be upcast to the given model configuration. + * If `definition.model.values` is set, `definition.upcastAlso` should be an object assigning values from `definition.model.values` + * to {@link module:engine/view/matcher~MatcherPattern}s or arrays of {@link module:engine/view/matcher~MatcherPattern}s. + * + * **Note:** `definition.model` and `definition.view` form should be mirrored, so the same types of parameters should + * be given in both parameters. + * + * @param {Object} definition The converter definition. + * @param {String|Object} definition.model The model attribute to convert from and to. + * @param {String|Object} definition.view The view attribute to convert from and to. + * @param {module:engine/view/matcher~MatcherPattern|Array.} [definition.upcastAlso] + * Any view element matching `definition.upcastAlso` will also be converted to the given model attribute. `definition.upcastAlso` + * is used only if `config.model.values` is specified. + */ + public attributeToAttribute( + definition: { + model: string | { + key: string; + name?: string; + }; + view: string | ( AttributeDescriptor & { name?: string } ); + upcastAlso?: ArrayOrItem; + converterPriority?: PriorityString | number; + } | { + model: { + key: string; + name?: string; + values: TValues[]; + }; + view: Record; + upcastAlso?: Record; + converterPriority?: PriorityString | number; + } ): void { + // Set up downcast converter. + this.for( 'downcast' ).attributeToAttribute( definition ); + + // Set up upcast converter. + for ( const { model, view } of _getAllUpcastDefinitions( definition ) ) { + this.for( 'upcast' ) + .attributeToAttribute( { + view, + model + } ); + } + } + + /** + * Creates and caches conversion helpers for given dispatchers group. + * + * @private + * @param {Object} options + * @param {String} options.name Group name. + * @param {Array.} options.dispatchers + * @param {Boolean} options.isDowncast + */ + private _createConversionHelpers( + { name, dispatchers, isDowncast }: { + name: string; + dispatchers: ( DowncastDispatcher | UpcastDispatcher )[]; + isDowncast: boolean; + } + ): void { + if ( this._helpers.has( name ) ) { + /** + * Trying to register a group name that has already been registered. + * + * @error conversion-group-exists + */ + throw new CKEditorError( 'conversion-group-exists', this ); + } + + const helpers = isDowncast ? + new DowncastHelpers( dispatchers as DowncastDispatcher[] ) : + new UpcastHelpers( dispatchers as UpcastDispatcher[] ); + + this._helpers.set( name, helpers ); + } +} + +/** + * Defines how the model should be converted from and to the view. + * + * @typedef {Object} module:engine/conversion/conversion~ConverterDefinition + * + * @property {*} [model] The model conversion definition. Describes the model element or model attribute to convert. This parameter differs + * for different functions that accept `ConverterDefinition`. See the description of the function to learn how to set it. + * @property {module:engine/view/elementdefinition~ElementDefinition|Object} view The definition of the view element to convert from and + * to. If `model` describes multiple values, `view` is an object that assigns these values (`view` object keys) to view element definitions + * (`view` object values). + * @property {module:engine/view/matcher~MatcherPattern|Array.} [upcastAlso] + * Any view element matching `upcastAlso` will also be converted to the model. If `model` describes multiple values, `upcastAlso` + * is an object that assigns these values (`upcastAlso` object keys) to {@link module:engine/view/matcher~MatcherPattern}s + * (`upcastAlso` object values). + * @property {module:utils/priorities~PriorityString} [converterPriority] The converter priority. + */ + +// Helper function that creates a joint array out of an item passed in `definition.view` and items passed in +// `definition.upcastAlso`. +// +// @param {module:engine/conversion/conversion~ConverterDefinition} definition +// @returns {Array} Array containing view definitions. +function* _getAllUpcastDefinitions( definition: any ): IterableIterator<{ model: any; view: any }> { + if ( definition.model.values ) { + for ( const value of definition.model.values ) { + const model = { key: definition.model.key, value }; + const view = definition.view[ value ]; + const upcastAlso = definition.upcastAlso ? definition.upcastAlso[ value ] : undefined; + + yield* _getUpcastDefinition( model, view, upcastAlso ); + } + } else { + yield* _getUpcastDefinition( definition.model, definition.view, definition.upcastAlso ); + } +} + +function* _getUpcastDefinition( model: unknown, view: unknown, upcastAlso?: unknown ): any { + yield { model, view }; + + if ( upcastAlso ) { + for ( const upcastAlsoItem of toArray( upcastAlso ) ) { + yield { model, view: upcastAlsoItem }; + } + } +} diff --git a/packages/ckeditor5-engine/src/conversion/conversionhelpers.ts b/packages/ckeditor5-engine/src/conversion/conversionhelpers.ts new file mode 100644 index 00000000000..8b34d02f5cd --- /dev/null +++ b/packages/ckeditor5-engine/src/conversion/conversionhelpers.ts @@ -0,0 +1,42 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module engine/conversion/conversionhelpers + */ + +/** + * Base class for conversion helpers. + */ +export default class ConversionHelpers { + private readonly _dispatchers: TDispatcher[]; + + /** + * Creates a conversion helpers instance. + * + * @param {Array.} dispatchers + */ + constructor( dispatchers: TDispatcher[] ) { + this._dispatchers = dispatchers; + } + + /** + * Registers a conversion helper. + * + * **Note**: See full usage example in the `{@link module:engine/conversion/conversion~Conversion#for conversion.for()}` + * method description. + * + * @param {Function} conversionHelper The function to be called on event. + * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers|module:engine/conversion/upcasthelpers~UpcastHelpers} + */ + public add( conversionHelper: ( dispatcher: TDispatcher ) => void ): this { + for ( const dispatcher of this._dispatchers ) { + conversionHelper( dispatcher ); + } + + return this; + } +} diff --git a/packages/ckeditor5-engine/src/conversion/downcastdispatcher.ts b/packages/ckeditor5-engine/src/conversion/downcastdispatcher.ts new file mode 100644 index 00000000000..401ed0de81f --- /dev/null +++ b/packages/ckeditor5-engine/src/conversion/downcastdispatcher.ts @@ -0,0 +1,997 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module engine/conversion/downcastdispatcher + */ + +import Consumable from './modelconsumable'; +import Range from '../model/range'; + +import { Emitter } from '@ckeditor/ckeditor5-utils/src/emittermixin'; + +import type { default as Differ, DiffItem } from '../model/differ'; +import type { default as MarkerCollection, Marker } from '../model/markercollection'; +import type { TreeWalkerValue } from '../model/treewalker'; +import type DocumentSelection from '../model/documentselection'; +import type DowncastWriter from '../view/downcastwriter'; +import type Element from '../model/element'; +import type Item from '../model/item'; +import type Mapper from './mapper'; +import type Position from '../model/position'; +import type Schema from '../model/schema'; +import type Selection from '../model/selection'; +import type ViewElement from '../view/element'; + +/** + * The downcast dispatcher is a central point of downcasting (conversion from the model to the view), which is a process of reacting + * to changes in the model and firing a set of events. The callbacks listening to these events are called converters. The + * converters' role is to convert the model changes to changes in view (for example, adding view nodes or + * changing attributes on view elements). + * + * During the conversion process, downcast dispatcher fires events basing on the state of the model and prepares + * data for these events. It is important to understand that the events are connected with the changes done on the model, + * for example: "a node has been inserted" or "an attribute has changed". This is in contrary to upcasting (a view-to-model conversion) + * where you convert the view state (view nodes) to a model tree. + * + * The events are prepared basing on a diff created by the {@link module:engine/model/differ~Differ Differ}, which buffers them + * and then passes to the downcast dispatcher as a diff between the old model state and the new model state. + * + * Note that because the changes are converted, there is a need to have a mapping between the model structure and the view structure. + * To map positions and elements during the downcast (a model-to-view conversion), use {@link module:engine/conversion/mapper~Mapper}. + * + * Downcast dispatcher fires the following events for model tree changes: + * + * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert `insert`} – + * If a range of nodes was inserted to the model tree. + * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:remove `remove`} – + * If a range of nodes was removed from the model tree. + * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute `attribute`} – + * If an attribute was added, changed or removed from a model node. + * + * For {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert `insert`} + * and {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute `attribute`}, + * the downcast dispatcher generates {@link module:engine/conversion/modelconsumable~ModelConsumable consumables}. + * These are used to have control over which changes have already been consumed. It is useful when some converters + * overwrite others or convert multiple changes (for example, it converts an insertion of an element and also converts that + * element's attributes during the insertion). + * + * Additionally, downcast dispatcher fires events for {@link module:engine/model/markercollection~Marker marker} changes: + * + * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:addMarker `addMarker`} – If a marker was added. + * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:removeMarker `removeMarker`} – If a marker was + * removed. + * + * Note that changing a marker is done through removing the marker from the old range and adding it to the new range, + * so both of these events are fired. + * + * Finally, a downcast dispatcher also handles firing events for the {@link module:engine/model/selection model selection} + * conversion: + * + * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:selection `selection`} + * – Converts the selection from the model to the view. + * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute `attribute`} + * – Fired for every selection attribute. + * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:addMarker `addMarker`} + * – Fired for every marker that contains a selection. + * + * Unlike the model tree and the markers, the events for selection are not fired for changes but for a selection state. + * + * When providing custom listeners for a downcast dispatcher, remember to check whether a given change has not been + * {@link module:engine/conversion/modelconsumable~ModelConsumable#consume consumed} yet. + * + * When providing custom listeners for a downcast dispatcher, keep in mind that you **should not** stop the event. If you stop it, + * then the default converter at the `lowest` priority will not trigger the conversion of this node's attributes and child nodes. + * + * When providing custom listeners for a downcast dispatcher, remember to use the provided + * {@link module:engine/view/downcastwriter~DowncastWriter view downcast writer} to apply changes to the view document. + * + * You can read more about conversion in the following guide: + * + * * {@glink framework/guides/deep-dive/conversion/downcast Downcast conversion} + * + * An example of a custom converter for the downcast dispatcher: + * + * // You will convert inserting a "paragraph" model element into the model. + * downcastDispatcher.on( 'insert:paragraph', ( evt, data, conversionApi ) => { + * // Remember to check whether the change has not been consumed yet and consume it. + * if ( !conversionApi.consumable.consume( data.item, 'insert' ) ) { + * return; + * } + * + * // Translate the position in the model to a position in the view. + * const viewPosition = conversionApi.mapper.toViewPosition( data.range.start ); + * + * // Create a

element that will be inserted into the view at the `viewPosition`. + * const viewElement = conversionApi.writer.createContainerElement( 'p' ); + * + * // Bind the newly created view element to the model element so positions will map accordingly in the future. + * conversionApi.mapper.bindElements( data.item, viewElement ); + * + * // Add the newly created view element to the view. + * conversionApi.writer.insert( viewPosition, viewElement ); + * } ); + */ +export default class DowncastDispatcher extends Emitter { + /** @internal */ + public readonly _conversionApi: Pick; + + private readonly _firedEventsMap: WeakMap>>; + + /** + * Creates a downcast dispatcher instance. + * + * @see module:engine/conversion/downcastdispatcher~DowncastConversionApi + * @param {Object} conversionApi Additional properties for an interface that will be passed to events fired + * by the downcast dispatcher. + */ + constructor( conversionApi: Pick ) { + super(); + + /** + * A template for an interface passed by the dispatcher to the event callbacks. + * + * @protected + * @member {module:engine/conversion/downcastdispatcher~DowncastConversionApi} + */ + this._conversionApi = { dispatcher: this, ...conversionApi }; + + /** + * A map of already fired events for a given `ModelConsumable`. + * + * @private + * @member {WeakMap.} + */ + this._firedEventsMap = new WeakMap(); + } + + /** + * Converts changes buffered in the given {@link module:engine/model/differ~Differ model differ} + * and fires conversion events based on it. + * + * @fires insert + * @fires remove + * @fires attribute + * @fires addMarker + * @fires removeMarker + * @fires reduceChanges + * @param {module:engine/model/differ~Differ} differ The differ object with buffered changes. + * @param {module:engine/model/markercollection~MarkerCollection} markers Markers related to the model fragment to convert. + * @param {module:engine/view/downcastwriter~DowncastWriter} writer The view writer that should be used to modify the view document. + */ + public convertChanges( + differ: Differ, + markers: MarkerCollection, + writer: DowncastWriter + ): void { + const conversionApi = this._createConversionApi( writer, differ.getRefreshedItems() ); + + // Before the view is updated, remove markers which have changed. + for ( const change of differ.getMarkersToRemove() ) { + this._convertMarkerRemove( change.name, change.range, conversionApi ); + } + + // Let features modify the change list (for example to allow reconversion). + const changes = this._reduceChanges( differ.getChanges() ); + + // Convert changes that happened on model tree. + for ( const entry of changes ) { + if ( entry.type === 'insert' ) { + this._convertInsert( Range._createFromPositionAndShift( entry.position, entry.length ), conversionApi ); + } else if ( entry.type === 'reinsert' ) { + this._convertReinsert( Range._createFromPositionAndShift( entry.position, entry.length ), conversionApi ); + } else if ( entry.type === 'remove' ) { + this._convertRemove( entry.position, entry.length, entry.name, conversionApi ); + } else { + // Defaults to 'attribute' change. + this._convertAttribute( entry.range, entry.attributeKey, entry.attributeOldValue, entry.attributeNewValue, conversionApi ); + } + } + + for ( const markerName of conversionApi.mapper.flushUnboundMarkerNames() ) { + const markerRange = markers.get( markerName )!.getRange(); + + this._convertMarkerRemove( markerName, markerRange, conversionApi ); + this._convertMarkerAdd( markerName, markerRange, conversionApi ); + } + + // After the view is updated, convert markers which have changed. + for ( const change of differ.getMarkersToAdd() ) { + this._convertMarkerAdd( change.name, change.range, conversionApi ); + } + + // Remove mappings for all removed view elements. + conversionApi.mapper.flushDeferredBindings(); + + // Verify if all insert consumables were consumed. + conversionApi.consumable.verifyAllConsumed( 'insert' ); + } + + /** + * Starts a conversion of a model range and the provided markers. + * + * @fires insert + * @fires attribute + * @fires addMarker + * @param {module:engine/model/range~Range} range The inserted range. + * @param {Map} markers The map of markers that should be down-casted. + * @param {module:engine/view/downcastwriter~DowncastWriter} writer The view writer that should be used to modify the view document. + * @param {Object} [options] Optional options object passed to `convertionApi.options`. + */ + public convert( + range: Range, + markers: Map, + writer: DowncastWriter, + options: unknown = {} + ): void { + const conversionApi = this._createConversionApi( writer, undefined, options ); + + this._convertInsert( range, conversionApi ); + + for ( const [ name, range ] of markers ) { + this._convertMarkerAdd( name, range, conversionApi ); + } + + // Verify if all insert consumables were consumed. + conversionApi.consumable.verifyAllConsumed( 'insert' ); + } + + /** + * Starts the model selection conversion. + * + * Fires events for a given {@link module:engine/model/selection~Selection selection} to start the selection conversion. + * + * @fires selection + * @fires addMarker + * @fires attribute + * @param {module:engine/model/selection~Selection} selection The selection to convert. + * @param {module:engine/model/markercollection~MarkerCollection} markers Markers connected with the converted model. + * @param {module:engine/view/downcastwriter~DowncastWriter} writer View writer that should be used to modify the view document. + */ + public convertSelection( + selection: Selection | DocumentSelection, + markers: MarkerCollection, + writer: DowncastWriter + ): void { + const markersAtSelection = Array.from( markers.getMarkersAtPosition( selection.getFirstPosition()! ) ); + + const conversionApi = this._createConversionApi( writer ); + + this._addConsumablesForSelection( conversionApi.consumable, selection, markersAtSelection ); + + this.fire( 'selection', { selection }, conversionApi ); + + if ( !selection.isCollapsed ) { + return; + } + + for ( const marker of markersAtSelection ) { + const markerRange = marker.getRange(); + + if ( !shouldMarkerChangeBeConverted( selection.getFirstPosition()!, marker, conversionApi.mapper ) ) { + continue; + } + + const data = { + item: selection, + markerName: marker.name, + markerRange + }; + + if ( conversionApi.consumable.test( selection, 'addMarker:' + marker.name ) ) { + this.fire( `addMarker:${ marker.name }`, data, conversionApi ); + } + } + + for ( const key of selection.getAttributeKeys() ) { + const data = { + item: selection, + range: selection.getFirstRange()!, + attributeKey: key, + attributeOldValue: null, + attributeNewValue: selection.getAttribute( key ) + }; + + // Do not fire event if the attribute has been consumed. + if ( conversionApi.consumable.test( selection, 'attribute:' + data.attributeKey ) ) { + this.fire( `attribute:${ data.attributeKey }:$text`, data, conversionApi ); + } + } + } + + /** + * Fires insertion conversion of a range of nodes. + * + * For each node in the range, {@link #event:insert `insert` event is fired}. For each attribute on each node, + * {@link #event:attribute `attribute` event is fired}. + * + * @protected + * @fires insert + * @fires attribute + * @param {module:engine/model/range~Range} range The inserted range. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion API object. + * @param {Object} [options] + * @param {Boolean} [options.doNotAddConsumables=false] Whether the ModelConsumable should not get populated + * for items in the provided range. + */ + private _convertInsert( + range: Range, + conversionApi: DowncastConversionApi, + options: { doNotAddConsumables?: boolean } = {} + ): void { + if ( !options.doNotAddConsumables ) { + // Collect a list of things that can be consumed, consisting of nodes and their attributes. + this._addConsumablesForInsert( conversionApi.consumable, Array.from( range ) ); + } + + // Fire a separate insert event for each node and text fragment contained in the range. + for ( const data of Array.from( range.getWalker( { shallow: true } ) ).map( walkerValueToEventData ) ) { + this._testAndFire( 'insert', data, conversionApi ); + } + } + + /** + * Fires conversion of a single node removal. Fires {@link #event:remove remove event} with provided data. + * + * @protected + * @param {module:engine/model/position~Position} position Position from which node was removed. + * @param {Number} length Offset size of removed node. + * @param {String} name Name of removed node. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion API object. + */ + private _convertRemove( + position: Position, + length: number, + name: string, + conversionApi: DowncastConversionApi + ): void { + this.fire( `remove:${ name }`, { position, length }, conversionApi ); + } + + /** + * Starts a conversion of an attribute change on a given `range`. + * + * For each node in the given `range`, {@link #event:attribute attribute event} is fired with the passed data. + * + * @protected + * @fires attribute + * @param {module:engine/model/range~Range} range Changed range. + * @param {String} key Key of the attribute that has changed. + * @param {*} oldValue Attribute value before the change or `null` if the attribute has not been set before. + * @param {*} newValue New attribute value or `null` if the attribute has been removed. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion API object. + */ + private _convertAttribute( + range: Range, + key: string, + oldValue: unknown, + newValue: unknown, + conversionApi: DowncastConversionApi + ): void { + // Create a list with attributes to consume. + this._addConsumablesForRange( conversionApi.consumable, range, `attribute:${ key }` ); + + // Create a separate attribute event for each node in the range. + for ( const value of range ) { + const data = { + item: value.item, + range: Range._createFromPositionAndShift( value.previousPosition, value.length! ), + attributeKey: key, + attributeOldValue: oldValue, + attributeNewValue: newValue + }; + + this._testAndFire( `attribute:${ key }`, data, conversionApi ); + } + } + + /** + * Fires re-insertion conversion (with a `reconversion` flag passed to `insert` events) + * of a range of elements (only elements on the range depth, without children). + * + * For each node in the range on its depth (without children), {@link #event:insert `insert` event} is fired. + * For each attribute on each node, {@link #event:attribute `attribute` event} is fired. + * + * @protected + * @fires insert + * @fires attribute + * @param {module:engine/model/range~Range} range The range to reinsert. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion API object. + */ + private _convertReinsert( range: Range, conversionApi: DowncastConversionApi ): void { + // Convert the elements - without converting children. + const walkerValues = Array.from( range.getWalker( { shallow: true } ) ); + + // Collect a list of things that can be consumed, consisting of nodes and their attributes. + this._addConsumablesForInsert( conversionApi.consumable, walkerValues ); + + // Fire a separate insert event for each node and text fragment contained shallowly in the range. + for ( const data of walkerValues.map( walkerValueToEventData ) ) { + this._testAndFire( 'insert', { ...data, reconversion: true }, conversionApi ); + } + } + + /** + * Converts the added marker. Fires the {@link #event:addMarker `addMarker`} event for each item + * in the marker's range. If the range is collapsed, a single event is dispatched. See the event description for more details. + * + * @protected + * @fires addMarker + * @param {String} markerName Marker name. + * @param {module:engine/model/range~Range} markerRange The marker range. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion API object. + */ + private _convertMarkerAdd( + markerName: string, + markerRange: Range, + conversionApi: DowncastConversionApi + ): void { + // Do not convert if range is in graveyard. + if ( markerRange.root.rootName == '$graveyard' ) { + return; + } + + // In markers' case, event name == consumable name. + const eventName = `addMarker:${ markerName }` as const; + + // + // First, fire an event for the whole marker. + // + conversionApi.consumable.add( markerRange, eventName ); + + this.fire( eventName, { markerName, markerRange }, conversionApi ); + + // + // Do not fire events for each item inside the range if the range got consumed. + // Also consume the whole marker consumable if it wasn't consumed. + // + if ( !conversionApi.consumable.consume( markerRange, eventName ) ) { + return; + } + + // + // Then, fire an event for each item inside the marker range. + // + this._addConsumablesForRange( conversionApi.consumable, markerRange, eventName ); + + for ( const item of markerRange.getItems() ) { + // Do not fire event for already consumed items. + if ( !conversionApi.consumable.test( item, eventName ) ) { + continue; + } + + const data = { item, range: Range._createOn( item ), markerName, markerRange }; + + this.fire( eventName, data, conversionApi ); + } + } + + /** + * Fires the conversion of the marker removal. Fires the {@link #event:removeMarker `removeMarker`} event with the provided data. + * + * @protected + * @fires removeMarker + * @param {String} markerName Marker name. + * @param {module:engine/model/range~Range} markerRange The marker range. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion API object. + */ + private _convertMarkerRemove( markerName: string, markerRange: Range, conversionApi: DowncastConversionApi ) { + // Do not convert if range is in graveyard. + if ( markerRange.root.rootName == '$graveyard' ) { + return; + } + + this.fire( `removeMarker:${ markerName }`, { markerName, markerRange }, conversionApi ); + } + + /** + * Fires the reduction of changes buffered in the {@link module:engine/model/differ~Differ `Differ`}. + * + * Features can replace selected {@link module:engine/model/differ~DiffItem `DiffItem`}s with `reinsert` entries to trigger + * reconversion. The {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToStructure + * `DowncastHelpers.elementToStructure()`} is using this event to trigger reconversion. + * + * @private + * @fires reduceChanges + * @param {Iterable.} changes + * @returns {Iterable.} + */ + private _reduceChanges( changes: Iterable ): Iterable { + const data: { changes: Iterable } = { changes }; + + this.fire( 'reduceChanges', data ); + + return data.changes; + } + + /** + * Populates provided {@link module:engine/conversion/modelconsumable~ModelConsumable} with values to consume from a given range, + * assuming that the range has just been inserted to the model. + * + * @private + * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable The consumable. + * @param {Iterable.} walkerValues The walker values for the inserted range. + * @returns {module:engine/conversion/modelconsumable~ModelConsumable} The values to consume. + */ + private _addConsumablesForInsert( + consumable: Consumable, + walkerValues: Iterable + ): Consumable { + for ( const value of walkerValues ) { + const item = value.item; + + // Add consumable if it wasn't there yet. + if ( consumable.test( item, 'insert' ) === null ) { + consumable.add( item, 'insert' ); + + for ( const key of item.getAttributeKeys() ) { + consumable.add( item, 'attribute:' + key ); + } + } + } + + return consumable; + } + + /** + * Populates provided {@link module:engine/conversion/modelconsumable~ModelConsumable} with values to consume for a given range. + * + * @private + * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable The consumable. + * @param {module:engine/model/range~Range} range The affected range. + * @param {String} type Consumable type. + * @returns {module:engine/conversion/modelconsumable~ModelConsumable} The values to consume. + */ + private _addConsumablesForRange( + consumable: Consumable, + range: Range, + type: string + ): Consumable { + for ( const item of range.getItems() ) { + consumable.add( item, type ); + } + + return consumable; + } + + /** + * Populates provided {@link module:engine/conversion/modelconsumable~ModelConsumable} with selection consumable values. + * + * @private + * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable The consumable. + * @param {module:engine/model/selection~Selection} selection The selection to create the consumable from. + * @param {Iterable.} markers Markers that contain the selection. + * @returns {module:engine/conversion/modelconsumable~ModelConsumable} The values to consume. + */ + private _addConsumablesForSelection( + consumable: Consumable, + selection: Selection | DocumentSelection, + markers: Iterable + ): Consumable { + consumable.add( selection, 'selection' ); + + for ( const marker of markers ) { + consumable.add( selection, 'addMarker:' + marker.name ); + } + + for ( const key of selection.getAttributeKeys() ) { + consumable.add( selection, 'attribute:' + key ); + } + + return consumable; + } + + /** + * Tests whether given event wasn't already fired and if so, fires it. + * + * @private + * @fires insert + * @fires attribute + * @param {String} type Event type. + * @param {Object} data Event data. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion API object. + */ + private _testAndFire( + type: TType | `${ TType }:${ string }`, + data: EventMap[ TType ], + conversionApi: DowncastConversionApi + ): void { + const eventName = getEventName( type, data ); + const itemKey = data.item.is( '$textProxy' ) ? conversionApi.consumable._getSymbolForTextProxy( data.item ) : data.item; + + const eventsFiredForConversion = this._firedEventsMap.get( conversionApi )!; + const eventsFiredForItem = eventsFiredForConversion.get( itemKey ); + + if ( !eventsFiredForItem ) { + eventsFiredForConversion.set( itemKey, new Set( [ eventName ] ) ); + } else if ( !eventsFiredForItem.has( eventName ) ) { + eventsFiredForItem.add( eventName ); + } else { + return; + } + + this.fire>( eventName, data, conversionApi ); + } + + /** + * Fires not already fired events for setting attributes on just inserted item. + * + * @private + * @param {module:engine/model/item~Item} item The model item to convert attributes for. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion API object. + */ + private _testAndFireAddAttributes( + item: Item, + conversionApi: DowncastConversionApi + ): void { + const data: EventMap[ 'attribute' ] = { + item, + range: Range._createOn( item ) + } as any; + + for ( const key of data.item.getAttributeKeys() ) { + data.attributeKey = key; + data.attributeOldValue = null; + data.attributeNewValue = data.item.getAttribute( key ); + + this._testAndFire( `attribute:${ key }`, data, conversionApi ); + } + } + + /** + * Builds an instance of the {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi} from a template and a given + * {@link module:engine/view/downcastwriter~DowncastWriter `DowncastWriter`} and options object. + * + * @private + * @param {module:engine/view/downcastwriter~DowncastWriter} writer View writer that should be used to modify the view document. + * @param {Set.} [refreshedItems] A set of model elements that should not reuse their + * previous view representations. + * @param {Object} [options] Optional options passed to `convertionApi.options`. + * @return {module:engine/conversion/downcastdispatcher~DowncastConversionApi} The conversion API object. + */ + private _createConversionApi( + writer: DowncastWriter, + refreshedItems: Set = new Set(), + options: unknown = {} + ): DowncastConversionApi { + const conversionApi: DowncastConversionApi = { + ...this._conversionApi, + consumable: new Consumable(), + writer, + options, + convertItem: item => this._convertInsert( Range._createOn( item ), conversionApi ), + convertChildren: element => this._convertInsert( Range._createIn( element ), conversionApi, { doNotAddConsumables: true } ), + convertAttributes: item => this._testAndFireAddAttributes( item, conversionApi ), + canReuseView: viewElement => !refreshedItems.has( conversionApi.mapper.toModelElement( viewElement )! ) + }; + + this._firedEventsMap.set( conversionApi, new Map() ); + + return conversionApi; + } + + /** + * Fired to enable reducing (transforming) changes buffered in the {@link module:engine/model/differ~Differ `Differ`} before + * {@link #convertChanges `convertChanges()`} will fire any conversion events. + * + * For instance, a feature can replace selected {@link module:engine/model/differ~DiffItem `DiffItem`}s with a `reinsert` entry + * to trigger reconversion of an element when e.g. its attribute has changes. + * The {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToStructure + * `DowncastHelpers.elementToStructure()`} helper is using this event to trigger reconversion of an element when the element, + * its attributes or direct children changed. + * + * @param {Object} data + * @param {Iterable.} data.changes A buffered changes to get reduced. + * @event reduceChanges + */ + + /** + * Fired for inserted nodes. + * + * `insert` is a namespace for a class of events. Names of actually called events follow this pattern: + * `insert:name`. `name` is either `'$text'`, when {@link module:engine/model/text~Text a text node} has been inserted, + * or {@link module:engine/model/element~Element#name name} of inserted element. + * + * This way, the listeners can either listen to a general `insert` event or specific event (for example `insert:paragraph`). + * + * @event insert + * @param {Object} data Additional information about the change. + * @param {module:engine/model/item~Item} data.item The inserted item. + * @param {module:engine/model/range~Range} data.range Range spanning over inserted item. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface + * to be used by callback, passed in the `DowncastDispatcher` constructor. + */ + + /** + * Fired for removed nodes. + * + * `remove` is a namespace for a class of events. Names of actually called events follow this pattern: + * `remove:name`. `name` is either `'$text'`, when a {@link module:engine/model/text~Text a text node} has been removed, + * or the {@link module:engine/model/element~Element#name name} of removed element. + * + * This way, listeners can either listen to a general `remove` event or specific event (for example `remove:paragraph`). + * + * @event remove + * @param {Object} data Additional information about the change. + * @param {module:engine/model/position~Position} data.position Position from which the node has been removed. + * @param {Number} data.length Offset size of the removed node. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface + * to be used by callback, passed in `DowncastDispatcher` constructor. + */ + + /** + * Fired in the following cases: + * + * * when an attribute has been added, changed, or removed from a node, + * * when a node with an attribute is inserted, + * * when a collapsed model selection attribute is converted. + * + * `attribute` is a namespace for a class of events. Names of actually called events follow this pattern: + * `attribute:attributeKey:name`. `attributeKey` is the key of added/changed/removed attribute. + * `name` is either `'$text'` if change was on {@link module:engine/model/text~Text a text node}, + * or the {@link module:engine/model/element~Element#name name} of element which attribute has changed. + * + * This way listeners can either listen to a general `attribute:bold` event or specific event (for example `attribute:src:imageBlock`). + * + * @event attribute + * @param {Object} data Additional information about the change. + * @param {module:engine/model/item~Item|module:engine/model/documentselection~DocumentSelection} data.item Changed item + * or converted selection. + * @param {module:engine/model/range~Range} data.range Range spanning over changed item or selection range. + * @param {String} data.attributeKey Attribute key. + * @param {*} data.attributeOldValue Attribute value before the change. This is `null` when selection attribute is converted. + * @param {*} data.attributeNewValue New attribute value. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface + * to be used by callback, passed in `DowncastDispatcher` constructor. + */ + + /** + * Fired for {@link module:engine/model/selection~Selection selection} changes. + * + * @event selection + * @param {module:engine/model/selection~Selection} selection Selection that is converted. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface + * to be used by callback, passed in `DowncastDispatcher` constructor. + */ + + /** + * Fired when a new marker is added to the model. Also fired when a collapsed model selection that is inside a marker is converted. + * + * `addMarker` is a namespace for a class of events. Names of actually called events follow this pattern: + * `addMarker:markerName`. By specifying certain marker names, you can make the events even more gradual. For example, + * if markers are named `foo:abc`, `foo:bar`, then it is possible to listen to `addMarker:foo` or `addMarker:foo:abc` and + * `addMarker:foo:bar` events. + * + * If the marker range is not collapsed: + * + * * the event is fired for each item in the marker range one by one, + * * `conversionApi.consumable` includes each item of the marker range and the consumable value is same as the event name. + * + * If the marker range is collapsed: + * + * * there is only one event, + * * `conversionApi.consumable` includes marker range with the event name. + * + * If the selection inside a marker is converted: + * + * * there is only one event, + * * `conversionApi.consumable` includes the selection instance with the event name. + * + * @event addMarker + * @param {Object} data Additional information about the change. + * @param {module:engine/model/item~Item|module:engine/model/selection~Selection} data.item Item inside the new marker or + * the selection that is being converted. + * @param {module:engine/model/range~Range} [data.range] Range spanning over converted item. Available only in marker conversion, if + * the marker range was not collapsed. + * @param {module:engine/model/range~Range} data.markerRange Marker range. + * @param {String} data.markerName Marker name. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface + * to be used by callback, passed in `DowncastDispatcher` constructor. + */ + + /** + * Fired when a marker is removed from the model. + * + * `removeMarker` is a namespace for a class of events. Names of actually called events follow this pattern: + * `removeMarker:markerName`. By specifying certain marker names, you can make the events even more gradual. For example, + * if markers are named `foo:abc`, `foo:bar`, then it is possible to listen to `removeMarker:foo` or `removeMarker:foo:abc` and + * `removeMarker:foo:bar` events. + * + * @event removeMarker + * @param {Object} data Additional information about the change. + * @param {module:engine/model/range~Range} data.markerRange Marker range. + * @param {String} data.markerName Marker name. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface + * to be used by callback, passed in `DowncastDispatcher` constructor. + */ +} + +export type ReduceChangesEvent = { + name: 'reduceChanges'; + args: [ data: { + changes: Iterable; + } ]; +}; + +type EventMap = { + insert: { + item: TItem; + range: Range; + reconversion?: boolean; + }; + remove: { + position: Position; + length: number; + }; + attribute: { + item: TItem; + range: Range; + attributeKey: string; + attributeOldValue: unknown; + attributeNewValue: unknown; + }; + selection: { + selection: Selection | DocumentSelection; + }; + addMarker: { + item?: Item | Selection | DocumentSelection; + range?: Range; + markerRange: Range; + markerName: string; + }; + removeMarker: { + markerRange: Range; + markerName: string; + }; +}; + +export type DowncastEvent, TItem = Item> = { + name: TName | `${ TName }:${ string }`; + args: [ data: EventMap[ TName ], conversionApi: DowncastConversionApi ]; +}; + +export type DowncastInsertEvent = DowncastEvent<'insert', TItem>; + +export type DowncastRemoveEvent = DowncastEvent<'remove'>; + +export type DowncastAttributeEvent = DowncastEvent<'attribute', TItem>; + +export type DowncastSelectionEvent = DowncastEvent<'selection'>; + +export type DowncastAddMarkerEvent = DowncastEvent<'addMarker'>; + +export type DowncastRemoveMarkerEvent = DowncastEvent<'removeMarker'>; + +export interface DiffItemReinsert { + type: 'reinsert'; + name: string; + position: Position; + length: number; +} + +// Helper function, checks whether change of `marker` at `modelPosition` should be converted. Marker changes are not +// converted if they happen inside an element with custom conversion method. +// +// @param {module:engine/model/position~Position} modelPosition +// @param {module:engine/model/markercollection~Marker} marker +// @param {module:engine/conversion/mapper~Mapper} mapper +// @returns {Boolean} +function shouldMarkerChangeBeConverted( + modelPosition: Position, + marker: Marker, + mapper: Mapper +): boolean { + const range = marker.getRange(); + const ancestors = Array.from( modelPosition.getAncestors() ); + ancestors.shift(); // Remove root element. It cannot be passed to `model.Range#containsItem`. + ancestors.reverse(); + + const hasCustomHandling = ( ancestors as Element[] ).some( element => { + if ( range.containsItem( element ) ) { + const viewElement = mapper.toViewElement( element )!; + + return !!viewElement.getCustomProperty( 'addHighlight' ); + } + } ); + + return !hasCustomHandling; +} + +function getEventName( type: TType, data: { item: Item | Selection | DocumentSelection } ) { + const name = data.item.is( 'element' ) ? data.item.name : '$text'; + + return `${ type }:${ name }` as const; +} + +function walkerValueToEventData( value: TreeWalkerValue ) { + const item = value.item; + const itemRange = Range._createFromPositionAndShift( value.previousPosition, value.length! ); + + return { + item, + range: itemRange + }; +} + +/** + * Conversion interface that is registered for given {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher} + * and is passed as one of parameters when {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher dispatcher} + * fires its events. + * + * @interface module:engine/conversion/downcastdispatcher~DowncastConversionApi + */ + +export interface DowncastConversionApi { + dispatcher: DowncastDispatcher; + consumable: Consumable; + mapper: Mapper; + schema: Schema; + writer: DowncastWriter; + options: unknown; + + convertItem( item: Item ): void; + convertChildren( element: Element ): void; + convertAttributes( item: Item ): void; + canReuseView( element: ViewElement ): boolean; +} + +/** + * The {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher} instance. + * + * @member {module:engine/conversion/downcastdispatcher~DowncastDispatcher} #dispatcher + */ + +/** + * Stores the information about what parts of a processed model item are still waiting to be handled. After a piece of a model item was + * converted, an appropriate consumable value should be {@link module:engine/conversion/modelconsumable~ModelConsumable#consume consumed}. + * + * @member {module:engine/conversion/modelconsumable~ModelConsumable} #consumable + */ + +/** + * The {@link module:engine/conversion/mapper~Mapper} instance. + * + * @member {module:engine/conversion/mapper~Mapper} #mapper + */ + +/** + * The {@link module:engine/model/schema~Schema} instance set for the model that is downcast. + * + * @member {module:engine/model/schema~Schema} #schema + */ + +/** + * The {@link module:engine/view/downcastwriter~DowncastWriter} instance used to manipulate the data during conversion. + * + * @member {module:engine/view/downcastwriter~DowncastWriter} #writer + */ + +/** + * Triggers conversion of a specified item. + * This conversion is triggered within (as a separate process of) the parent conversion. + * + * @method #convertItem + * @param {module:engine/model/item~Item} item The model item to trigger nested insert conversion on. + */ + +/** + * Triggers conversion of children of a specified element. + * + * @method #convertChildren + * @param {module:engine/model/element~Element} element The model element to trigger children insert conversion on. + */ + +/** + * Triggers conversion of attributes of a specified item. + * + * @method #convertAttributes + * @param {module:engine/model/item~Item} item The model item to trigger attribute conversion on. + */ + +/** + * An object with an additional configuration which can be used during the conversion process. Available only for data downcast conversion. + * + * @member {Object} #options + */ diff --git a/packages/ckeditor5-engine/src/conversion/downcasthelpers.ts b/packages/ckeditor5-engine/src/conversion/downcasthelpers.ts new file mode 100644 index 00000000000..c31b097a18b --- /dev/null +++ b/packages/ckeditor5-engine/src/conversion/downcasthelpers.ts @@ -0,0 +1,2930 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * Contains downcast (model-to-view) converters for {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}. + * + * @module engine/conversion/downcasthelpers + */ + +import ModelRange from '../model/range'; +import ModelSelection from '../model/selection'; +import ModelDocumentSelection from '../model/documentselection'; +import ModelElement from '../model/element'; +import ModelPosition from '../model/position'; + +import ViewAttributeElement from '../view/attributeelement'; +import ConversionHelpers from './conversionhelpers'; + +import { cloneDeep } from 'lodash-es'; +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; +import toArray from '@ckeditor/ckeditor5-utils/src/toarray'; + +import type { + default as DowncastDispatcher, + DiffItemReinsert, + DowncastConversionApi, + DowncastInsertEvent, + DowncastAddMarkerEvent, + DowncastAttributeEvent, + ReduceChangesEvent, + DowncastRemoveMarkerEvent +} from './downcastdispatcher'; +import type ModelConsumable from './modelconsumable'; +import type { DiffItem } from '../model/differ'; +import type ModelNode from '../model/node'; +import type ModelItem from '../model/item'; +import type ModelTextProxy from '../model/textproxy'; +import type ModelText from '../model/text'; + +import type DowncastWriter from '../view/downcastwriter'; +import type ElementDefinition from '../view/elementdefinition'; +import type ViewDocumentFragment from '../view/documentfragment'; +import type UIElement from '../view/uielement'; +import type ViewElement from '../view/element'; +import type ViewNode from '../view/node'; +import type ViewPosition from '../view/position'; +import type ViewRange from '../view/range'; +import type { + default as Mapper, + ModelToViewPositionEvent +} from './mapper'; +import type EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; +import type { PriorityString } from '@ckeditor/ckeditor5-utils/src/priorities'; + +/** + * Downcast conversion helper functions. + * + * Learn more about {@glink framework/guides/deep-dive/conversion/downcast downcast helpers}. + * + * @extends module:engine/conversion/conversionhelpers~ConversionHelpers + */ +export default class DowncastHelpers extends ConversionHelpers { + /** + * Model element to view element conversion helper. + * + * This conversion results in creating a view element. For example, model `Foo` becomes `

Foo

` in the view. + * + * editor.conversion.for( 'downcast' ).elementToElement( { + * model: 'paragraph', + * view: 'p' + * } ); + * + * editor.conversion.for( 'downcast' ).elementToElement( { + * model: 'paragraph', + * view: 'div', + * converterPriority: 'high' + * } ); + * + * editor.conversion.for( 'downcast' ).elementToElement( { + * model: 'fancyParagraph', + * view: { + * name: 'p', + * classes: 'fancy' + * } + * } ); + * + * editor.conversion.for( 'downcast' ).elementToElement( { + * model: 'heading', + * view: ( modelElement, conversionApi ) => { + * const { writer } = conversionApi; + * + * return writer.createContainerElement( 'h' + modelElement.getAttribute( 'level' ) ); + * } + * } ); + * + * The element-to-element conversion supports the reconversion mechanism. It can be enabled by using either the `attributes` or + * the `children` props on a model description. You will find a couple examples below. + * + * In order to reconvert an element if any of its direct children have been added or removed, use the `children` property on a `model` + * description. For example, this model: + * + * + * Some text. + * + * + * will be converted into this structure in the view: + * + *
+ *

Some text.

+ *
+ * + * But if more items were inserted in the model: + * + * + * Some text. + * Other item. + * + * + * it will be converted into this structure in the view (note the element `data-type` change): + * + *
+ *

Some text.

+ *

Other item.

+ *
+ * + * Such a converter would look like this (note that the `paragraph` elements are converted separately): + * + * editor.conversion.for( 'downcast' ).elementToElement( { + * model: { + * name: 'box', + * children: true + * }, + * view: ( modelElement, conversionApi ) => { + * const { writer } = conversionApi; + * + * return writer.createContainerElement( 'div', { + * class: 'box', + * 'data-type': modelElement.childCount == 1 ? 'single' : 'multiple' + * } ); + * } + * } ); + * + * In order to reconvert element if any of its attributes have been updated, use the `attributes` property on a `model` + * description. For example, this model: + * + * Some text. + * + * will be converted into this structure in the view: + * + *

Some text.

+ * + * But if the `heading` element's `level` attribute has been updated to `3` for example, then + * it will be converted into this structure in the view: + * + *

Some text.

+ * + * Such a converter would look as follows: + * + * editor.conversion.for( 'downcast' ).elementToElement( { + * model: { + * name: 'heading', + * attributes: 'level' + * }, + * view: ( modelElement, conversionApi ) => { + * const { writer } = conversionApi; + * + * return writer.createContainerElement( 'h' + modelElement.getAttribute( 'level' ) ); + * } + * } ); + * + * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter + * to the conversion process. + * + * You can read more about the element-to-element conversion in the + * {@glink framework/guides/deep-dive/conversion/downcast downcast conversion} guide. + * + * @method #elementToElement + * @param {Object} config Conversion configuration. + * @param {String|Object} config.model The description or a name of the model element to convert. + * @param {String|Array.} [config.model.attributes] The list of attribute names that should be consumed while creating + * the view element. Note that the view will be reconverted if any of the listed attributes changes. + * @param {Boolean} [config.model.children] Specifies whether the view element requires reconversion if the list + * of the model child nodes changed. + * @param {module:engine/view/elementdefinition~ElementDefinition|module:engine/conversion/downcasthelpers~ElementCreatorFunction} + * config.view A view element definition or a function that takes the model element and + * {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API} + * as parameters and returns a view container element. + * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers} + */ + public elementToElement( config: { + model: string | { + name: string; + attributes?: string | string[]; + children?: boolean; + }; + view: ElementDefinition | ElementCreatorFunction; + converterPriority?: PriorityString | number; + } ): this { + return this.add( downcastElementToElement( config ) ); + } + + /** + * The model element to view structure (several elements) conversion helper. + * + * This conversion results in creating a view structure with one or more slots defined for the child nodes. + * For example, a model `` may become this structure in the view: + * + *
+ *
+ * ${ slot for table rows } + *
+ * + * + * The children of the model's `` element will be inserted into the `` element. + * If the `elementToElement()` helper was used, the children would be inserted into the `
`. + * + * An example converter that converts the following model structure: + * + * Some text. + * + * into this structure in the view: + * + *
+ *

Some text.

+ *
+ * + * would look like this: + * + * editor.conversion.for( 'downcast' ).elementToStructure( { + * model: 'wrappedParagraph', + * view: ( modelElement, conversionApi ) => { + * const { writer } = conversionApi; + * + * const wrapperViewElement = writer.createContainerElement( 'div', { class: 'wrapper' } ); + * const paragraphViewElement = writer.createContainerElement( 'p' ); + * + * writer.insert( writer.createPositionAt( wrapperViewElement, 0 ), paragraphViewElement ); + * writer.insert( writer.createPositionAt( paragraphViewElement, 0 ), writer.createSlot() ); + * + * return wrapperViewElement; + * } + * } ); + * + * The `slorFor()` function can also take a callback that allows filtering which children of the model element + * should be converted into this slot. + * + * Imagine a table feature where for this model structure: + * + *
+ * ... table cells 1 ... + * ... table cells 2 ... + * ... table cells 3 ... + * + *
Caption text
+ * + * we want to generate this view structure: + * + *
+ * + * + * ... table cells 1 ... + * + * + * ... table cells 2 ... + * ... table cells 3 ... + * + *
+ *
Caption text
+ *
+ * + * The converter has to take the `headingRows` attribute into consideration when allocating the `` elements + * into the `` and `` elements. Hence, we need two slots and need to define proper filter callbacks for them. + * + * Additionally, all elements other than `` should be placed outside the `` tag. + * In the example above, this will handle the table caption. + * + * Such a converter would look like this: + * + * editor.conversion.for( 'downcast' ).elementToStructure( { + * model: { + * name: 'table', + * attributes: [ 'headingRows' ] + * }, + * view: ( modelElement, conversionApi ) => { + * const { writer } = conversionApi; + * + * const figureElement = writer.createContainerElement( 'figure', { class: 'table' } ); + * const tableElement = writer.createContainerElement( 'table' ); + * + * writer.insert( writer.createPositionAt( figureElement, 0 ), tableElement ); + * + * const headingRows = modelElement.getAttribute( 'headingRows' ) || 0; + * + * if ( headingRows > 0 ) { + * const tableHead = writer.createContainerElement( 'thead' ); + * + * const headSlot = writer.createSlot( node => node.is( 'element', 'tableRow' ) && node.index < headingRows ); + * + * writer.insert( writer.createPositionAt( tableElement, 'end' ), tableHead ); + * writer.insert( writer.createPositionAt( tableHead, 0 ), headSlot ); + * } + * + * if ( headingRows < tableUtils.getRows( table ) ) { + * const tableBody = writer.createContainerElement( 'tbody' ); + * + * const bodySlot = writer.createSlot( node => node.is( 'element', 'tableRow' ) && node.index >= headingRows ); + * + * writer.insert( writer.createPositionAt( tableElement, 'end' ), tableBody ); + * writer.insert( writer.createPositionAt( tableBody, 0 ), bodySlot ); + * } + * + * const restSlot = writer.createSlot( node => !node.is( 'element', 'tableRow' ) ); + * + * writer.insert( writer.createPositionAt( figureElement, 'end' ), restSlot ); + * + * return figureElement; + * } + * } ); + * + * Note: The children of a model element that's being converted must be allocated in the same order in the view + * in which they are placed in the model. + * + * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter + * to the conversion process. + * + * @method #elementToStructure + * @param {Object} config Conversion configuration. + * @param {String|Object} config.model The description or a name of the model element to convert. + * @param {String} [config.model.name] The name of the model element to convert. + * @param {String|Array.} [config.model.attributes] The list of attribute names that should be consumed while creating + * the view structure. Note that the view will be reconverted if any of the listed attributes will change. + * @param {module:engine/conversion/downcasthelpers~StructureCreatorFunction} config.view A function + * that takes the model element and {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast + * conversion API} as parameters and returns a view container element with slots for model child nodes to be converted into. + * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers} + */ + public elementToStructure( config: { + model: string | { + name: string; + attributes?: string | string[]; + }; + view: StructureCreatorFunction; + converterPriority?: PriorityString | number; + } ): this { + return this.add( downcastElementToStructure( config ) ); + } + + /** + * Model attribute to view element conversion helper. + * + * This conversion results in wrapping view nodes with a view attribute element. For example, a model text node with + * `"Foo"` as data and the `bold` attribute becomes `Foo` in the view. + * + * editor.conversion.for( 'downcast' ).attributeToElement( { + * model: 'bold', + * view: 'strong' + * } ); + * + * editor.conversion.for( 'downcast' ).attributeToElement( { + * model: 'bold', + * view: 'b', + * converterPriority: 'high' + * } ); + * + * editor.conversion.for( 'downcast' ).attributeToElement( { + * model: 'invert', + * view: { + * name: 'span', + * classes: [ 'font-light', 'bg-dark' ] + * } + * } ); + * + * editor.conversion.for( 'downcast' ).attributeToElement( { + * model: { + * key: 'fontSize', + * values: [ 'big', 'small' ] + * }, + * view: { + * big: { + * name: 'span', + * styles: { + * 'font-size': '1.2em' + * } + * }, + * small: { + * name: 'span', + * styles: { + * 'font-size': '0.8em' + * } + * } + * } + * } ); + * + * editor.conversion.for( 'downcast' ).attributeToElement( { + * model: 'bold', + * view: ( modelAttributeValue, conversionApi ) => { + * const { writer } = conversionApi; + * + * return writer.createAttributeElement( 'span', { + * style: 'font-weight:' + modelAttributeValue + * } ); + * } + * } ); + * + * editor.conversion.for( 'downcast' ).attributeToElement( { + * model: { + * key: 'color', + * name: '$text' + * }, + * view: ( modelAttributeValue, conversionApi ) => { + * const { writer } = conversionApi; + * + * return writer.createAttributeElement( 'span', { + * style: 'color:' + modelAttributeValue + * } ); + * } + * } ); + * + * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter + * to the conversion process. + * + * @method #attributeToElement + * @param {Object} config Conversion configuration. + * @param {String|Object} config.model The key of the attribute to convert from or a `{ key, values }` object. `values` is an array + * of `String`s with possible values if the model attribute is an enumerable. + * @param {module:engine/view/elementdefinition~ElementDefinition|Object| + * module:engine/conversion/downcasthelpers~AttributeElementCreatorFunction} config.view A view element definition or a function + * that takes the model attribute value and + * {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API} as parameters and returns a view + * attribute element. If `config.model.values` is given, `config.view` should be an object assigning values from `config.model.values` + * to view element definitions or functions. + * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority. + * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers} + */ + public attributeToElement( + config: { + model: string | { + key: string; + name?: string; + }; + view: ElementDefinition | AttributeElementCreatorFunction; + converterPriority?: PriorityString | number; + } | { + model: { + key: string; + name?: string; + values: TValues[]; + }; + view: Record; + converterPriority?: PriorityString | number; + } + ): this { + return this.add( downcastAttributeToElement( config ) ); + } + + /** + * Model attribute to view attribute conversion helper. + * + * This conversion results in adding an attribute to a view node, basing on an attribute from a model node. For example, + * `` is converted to ``. + * + * editor.conversion.for( 'downcast' ).attributeToAttribute( { + * model: 'source', + * view: 'src' + * } ); + * + * editor.conversion.for( 'downcast' ).attributeToAttribute( { + * model: 'source', + * view: 'href', + * converterPriority: 'high' + * } ); + * + * editor.conversion.for( 'downcast' ).attributeToAttribute( { + * model: { + * name: 'imageInline', + * key: 'source' + * }, + * view: 'src' + * } ); + * + * editor.conversion.for( 'downcast' ).attributeToAttribute( { + * model: { + * name: 'styled', + * values: [ 'dark', 'light' ] + * }, + * view: { + * dark: { + * key: 'class', + * value: [ 'styled', 'styled-dark' ] + * }, + * light: { + * key: 'class', + * value: [ 'styled', 'styled-light' ] + * } + * } + * } ); + * + * editor.conversion.for( 'downcast' ).attributeToAttribute( { + * model: 'styled', + * view: modelAttributeValue => ( { + * key: 'class', + * value: 'styled-' + modelAttributeValue + * } ) + * } ); + * + * **Note**: Downcasting to a style property requires providing `value` as an object: + * + * editor.conversion.for( 'downcast' ).attributeToAttribute( { + * model: 'lineHeight', + * view: modelAttributeValue => ( { + * key: 'style', + * value: { + * 'line-height': modelAttributeValue, + * 'border-bottom': '1px dotted #ba2' + * } + * } ) + * } ); + * + * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter + * to the conversion process. + * + * @method #attributeToAttribute + * @param {Object} config Conversion configuration. + * @param {String|Object} config.model The key of the attribute to convert from or a `{ key, values, [ name ] }` object describing + * the attribute key, possible values and, optionally, an element name to convert from. + * @param {String|Object|module:engine/conversion/downcasthelpers~AttributeCreatorFunction} config.view A view attribute key, + * or a `{ key, value }` object or a function that takes the model attribute value and + * {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API} + * as parameters and returns a `{ key, value }` object. If the `key` is `'class'`, the `value` can be a `String` or an + * array of `String`s. If the `key` is `'style'`, the `value` is an object with key-value pairs. In other cases, `value` is a `String`. + * If `config.model.values` is set, `config.view` should be an object assigning values from `config.model.values` to + * `{ key, value }` objects or a functions. + * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority. + * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers} + */ + public attributeToAttribute( + config: { + model: string | { + key: string; + name?: string; + }; + view: string | AttributeDescriptor | AttributeCreatorFunction; + converterPriority?: PriorityString | number; + } | { + model: { + key: string; + name?: string; + values: TValues[]; + }; + view: Record; + converterPriority?: PriorityString | number; + } + ): this { + return this.add( downcastAttributeToAttribute( config ) ); + } + + /** + * Model marker to view element conversion helper. + * + * **Note**: This method should be used mainly for editing the downcast and it is recommended + * to use the {@link #markerToData `#markerToData()`} helper instead. + * + * This helper may produce invalid HTML code (e.g. a span between table cells). + * It should only be used when you are sure that the produced HTML will be semantically correct. + * + * This conversion results in creating a view element on the boundaries of the converted marker. If the converted marker + * is collapsed, only one element is created. For example, a model marker set like this: `F[oo b]ar` + * becomes `

Foo bar

` in the view. + * + * editor.conversion.for( 'editingDowncast' ).markerToElement( { + * model: 'search', + * view: 'marker-search' + * } ); + * + * editor.conversion.for( 'editingDowncast' ).markerToElement( { + * model: 'search', + * view: 'search-result', + * converterPriority: 'high' + * } ); + * + * editor.conversion.for( 'editingDowncast' ).markerToElement( { + * model: 'search', + * view: { + * name: 'span', + * attributes: { + * 'data-marker': 'search' + * } + * } + * } ); + * + * editor.conversion.for( 'editingDowncast' ).markerToElement( { + * model: 'search', + * view: ( markerData, conversionApi ) => { + * const { writer } = conversionApi; + * + * return writer.createUIElement( 'span', { + * 'data-marker': 'search', + * 'data-start': markerData.isOpening + * } ); + * } + * } ); + * + * If a function is passed as the `config.view` parameter, it will be used to generate both boundary elements. The function + * receives the `data` object and {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API} + * as a parameters and should return an instance of the + * {@link module:engine/view/uielement~UIElement view UI element}. The `data` object and + * {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi `conversionApi`} are passed from + * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:addMarker}. Additionally, + * the `data.isOpening` parameter is passed, which is set to `true` for the marker start boundary element, and `false` for + * the marker end boundary element. + * + * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter + * to the conversion process. + * + * @method #markerToElement + * @param {Object} config Conversion configuration. + * @param {String} config.model The name of the model marker (or model marker group) to convert. + * @param {module:engine/view/elementdefinition~ElementDefinition|Function} config.view A view element definition or a function that + * takes the model marker data and {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API} + * as a parameters and returns a view UI element. + * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority. + * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers} + */ + public markerToElement( config: { + model: string; + view: ElementDefinition | MarkerElementCreatorFunction; + converterPriority?: PriorityString | number; + } ): this { + return this.add( downcastMarkerToElement( config ) ); + } + + /** + * Model marker to highlight conversion helper. + * + * This conversion results in creating a highlight on view nodes. For this kind of conversion, + * the {@link module:engine/conversion/downcasthelpers~HighlightDescriptor} should be provided. + * + * For text nodes, a `` {@link module:engine/view/attributeelement~AttributeElement} is created and it wraps all text nodes + * in the converted marker range. For example, a model marker set like this: `F[oo b]ar` becomes + * `

Foo bar

` in the view. + * + * {@link module:engine/view/containerelement~ContainerElement} may provide a custom way of handling highlight. Most often, + * the element itself is given classes and attributes described in the highlight descriptor (instead of being wrapped in ``). + * For example, a model marker set like this: + * `[]` becomes `` in the view. + * + * For container elements, the conversion is two-step. While the converter processes the highlight descriptor and passes it + * to a container element, it is the container element instance itself that applies values from the highlight descriptor. + * So, in a sense, the converter takes care of stating what should be applied on what, while the element decides how to apply that. + * + * editor.conversion.for( 'downcast' ).markerToHighlight( { model: 'comment', view: { classes: 'comment' } } ); + * + * editor.conversion.for( 'downcast' ).markerToHighlight( { + * model: 'comment', + * view: { classes: 'comment' }, + * converterPriority: 'high' + * } ); + * + * editor.conversion.for( 'downcast' ).markerToHighlight( { + * model: 'comment', + * view: ( data, conversionApi ) => { + * // Assuming that the marker name is in a form of comment:commentType:commentId. + * const [ , commentType, commentId ] = data.markerName.split( ':' ); + * + * return { + * classes: [ 'comment', 'comment-' + commentType ], + * attributes: { 'data-comment-id': commentId } + * }; + * } + * } ); + * + * If a function is passed as the `config.view` parameter, it will be used to generate the highlight descriptor. The function + * receives the `data` object and {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API} + * as the parameters and should return a + * {@link module:engine/conversion/downcasthelpers~HighlightDescriptor highlight descriptor}. + * The `data` object properties are passed from {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:addMarker}. + * + * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter + * to the conversion process. + * + * @method #markerToHighlight + * @param {Object} config Conversion configuration. + * @param {String} config.model The name of the model marker (or model marker group) to convert. + * @param {module:engine/conversion/downcasthelpers~HighlightDescriptor|Function} config.view A highlight descriptor + * that will be used for highlighting or a function that takes the model marker data and + * {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API} as a parameters + * and returns a highlight descriptor. + * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority. + * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers} + */ + public markerToHighlight( config: { + model: string; + view: HighlightDescriptor | HighlightDescriptorCreatorFunction; + converterPriority?: PriorityString | number; + } ): this { + return this.add( downcastMarkerToHighlight( config ) ); + } + + /** + * Model marker converter for data downcast. + * + * This conversion creates a representation for model marker boundaries in the view: + * + * * If the marker boundary is before or after a model element, a view attribute is set on a corresponding view element. + * * In other cases, a view element with the specified tag name is inserted at the corresponding view position. + * + * Typically, the marker names use the `group:uniqueId:otherData` convention. For example: `comment:e34zfk9k2n459df53sjl34:zx32c`. + * The default configuration for this conversion is that the first part is the `group` part and the rest of + * the marker name becomes the `name` part. + * + * Tag and attribute names and values are generated from the marker name: + * + * * The templates for attributes are `data-[group]-start-before="[name]"`, `data-[group]-start-after="[name]"`, + * `data-[group]-end-before="[name]"` and `data-[group]-end-after="[name]"`. + * * The templates for view elements are `<[group]-start name="[name]">` and `<[group]-end name="[name]">`. + * + * Attributes mark whether the given marker's start or end boundary is before or after the given element. + * The `data-[group]-start-before` and `data-[group]-end-after` attributes are favored. + * The other two are used when the former two cannot be used. + * + * The conversion configuration can take a function that will generate different group and name parts. + * If such a function is set as the `config.view` parameter, it is passed a marker name and it is expected to return an object with two + * properties: `group` and `name`. If the function returns a falsy value, the conversion will not take place. + * + * Basic usage: + * + * // Using the default conversion. + * // In this case, all markers with names starting with 'comment:' will be converted. + * // The `group` parameter will be set to `comment`. + * // The `name` parameter will be the rest of the marker name (without the `:`). + * editor.conversion.for( 'dataDowncast' ).markerToData( { + * model: 'comment' + * } ); + * + * An example of a view that may be generated by this conversion (assuming a marker with the name `comment:commentId:uid` marked + * by `[]`): + * + * // Model: + * Foo[bar + * ] + * + * // View: + *

Foobar

+ *
+ * + * In the example above, the comment starts before "bar" and ends after the image. + * + * If the `name` part is empty, the following view may be generated: + * + *

Foo bar

+ *
+ * + * **Note:** A situation where some markers have the `name` part and some do not, is incorrect and should be avoided. + * + * Examples where `data-group-start-after` and `data-group-end-before` are used: + * + * // Model: + *
[]Foo
+ * + * // View: + *

Foo

+ * + * Similarly, when a marker is collapsed after the last element: + * + * // Model: + *
Foo[]
+ * + * // View: + *

Foo

+ * + * When there are multiple markers from the same group stored in the same attribute of the same element, their + * name parts are put together in the attribute value, for example: `data-group-start-before="name1,name2,name3"`. + * + * Other examples of usage: + * + * // Using a custom function which is the same as the default conversion: + * editor.conversion.for( 'dataDowncast' ).markerToData( { + * model: 'comment' + * view: markerName => ( { + * group: 'comment', + * name: markerName.substr( 8 ) // Removes 'comment:' part. + * } ) + * } ); + * + * // Using the converter priority: + * editor.conversion.for( 'dataDowncast' ).markerToData( { + * model: 'comment' + * view: markerName => ( { + * group: 'comment', + * name: markerName.substr( 8 ) // Removes 'comment:' part. + * } ), + * converterPriority: 'high' + * } ); + * + * This kind of conversion is useful for saving data into the database, so it should be used in the data conversion pipeline. + * + * See the {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} API guide to learn how to + * add a converter to the conversion process. + * + * @method #markerToData + * @param {Object} config Conversion configuration. + * @param {String} config.model The name of the model marker (or the model marker group) to convert. + * @param {Function} [config.view] A function that takes the model marker name and + * {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API} as the parameters + * and returns an object with the `group` and `name` properties. + * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority. + * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers} + */ + public markerToData( config: { + model: string; + view?: MarkerDataCreatorFunction; + converterPriority?: PriorityString | number; + } ): this { + return this.add( downcastMarkerToData( config ) ); + } +} + +/** + * Function factory that creates a default downcast converter for text insertion changes. + * + * The converter automatically consumes the corresponding value from the consumables list and stops the event (see + * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}). + * + * modelDispatcher.on( 'insert:$text', insertText() ); + * + * @returns {Function} Insert text event converter. + */ +export function insertText() { + return ( + evt: EventInfo, + data: { item: ModelText | ModelTextProxy; range: ModelRange }, + conversionApi: DowncastConversionApi + ): void => { + if ( !conversionApi.consumable.consume( data.item, evt.name ) ) { + return; + } + + const viewWriter = conversionApi.writer; + const viewPosition = conversionApi.mapper.toViewPosition( data.range.start ); + const viewText = viewWriter.createText( data.item.data ); + + viewWriter.insert( viewPosition, viewText ); + }; +} + +/** + * Function factory that creates a default downcast converter for triggering attributes and children conversion. + * + * @returns {Function} The converter. + */ +export function insertAttributesAndChildren() { + return ( + evt: unknown, + data: { item: ModelItem; reconversion?: boolean }, + conversionApi: DowncastConversionApi + ): void => { + conversionApi.convertAttributes( data.item ); + + // Start converting children of the current item. + // In case of reconversion children were already re-inserted or converted separately. + if ( !data.reconversion && data.item.is( 'element' ) && !data.item.isEmpty ) { + conversionApi.convertChildren( data.item ); + } + }; +} + +/** + * Function factory that creates a default downcast converter for node remove changes. + * + * modelDispatcher.on( 'remove', remove() ); + * + * @returns {Function} Remove event converter. + */ +export function remove() { + return ( + evt: unknown, + data: { position: ModelPosition; length: number }, + conversionApi: DowncastConversionApi + ): void => { + // Find the view range start position by mapping the model position at which the remove happened. + const viewStart = conversionApi.mapper.toViewPosition( data.position ); + + const modelEnd = data.position.getShiftedBy( data.length ); + const viewEnd = conversionApi.mapper.toViewPosition( modelEnd, { isPhantom: true } ); + + const viewRange = conversionApi.writer.createRange( viewStart, viewEnd ); + + // Trim the range to remove in case some UI elements are on the view range boundaries. + const removed = conversionApi.writer.remove( viewRange.getTrimmed() ); + + // After the range is removed, unbind all view elements from the model. + // Range inside view document fragment is used to unbind deeply. + for ( const child of conversionApi.writer.createRangeIn( removed ).getItems() ) { + conversionApi.mapper.unbindViewElement( child as ViewElement, { defer: true } ); + } + }; +} + +/** + * Creates a `` {@link module:engine/view/attributeelement~AttributeElement view attribute element} from the information + * provided by the {@link module:engine/conversion/downcasthelpers~HighlightDescriptor highlight descriptor} object. If the priority + * is not provided in the descriptor, the default priority will be used. + * + * @param {module:engine/view/downcastwriter~DowncastWriter} writer + * @param {module:engine/conversion/downcasthelpers~HighlightDescriptor} descriptor + * @returns {module:engine/view/attributeelement~AttributeElement} + */ +export function createViewElementFromHighlightDescriptor( writer: DowncastWriter, descriptor: HighlightDescriptor ): ViewAttributeElement { + const viewElement = writer.createAttributeElement( 'span', descriptor.attributes ); + + if ( descriptor.classes ) { + viewElement._addClass( descriptor.classes ); + } + + if ( typeof descriptor.priority === 'number' ) { + ( viewElement as any )._priority = descriptor.priority; + } + + ( viewElement as any )._id = descriptor.id; + + return viewElement; +} + +/** + * Function factory that creates a converter which converts a non-collapsed {@link module:engine/model/selection~Selection model selection} + * to a {@link module:engine/view/documentselection~DocumentSelection view selection}. The converter consumes appropriate + * value from the `consumable` object and maps model positions from the selection to view positions. + * + * modelDispatcher.on( 'selection', convertRangeSelection() ); + * + * @returns {Function} Selection converter. + */ +export function convertRangeSelection() { + return ( + evt: EventInfo, + data: { selection: ModelSelection | ModelDocumentSelection }, + conversionApi: DowncastConversionApi + ): void => { + const selection = data.selection; + + if ( selection.isCollapsed ) { + return; + } + + if ( !conversionApi.consumable.consume( selection, 'selection' ) ) { + return; + } + + const viewRanges: ViewRange[] = []; + + for ( const range of selection.getRanges() ) { + viewRanges.push( conversionApi.mapper.toViewRange( range ) ); + } + + conversionApi.writer.setSelection( viewRanges, { backward: selection.isBackward } ); + }; +} + +/** + * Function factory that creates a converter which converts a collapsed {@link module:engine/model/selection~Selection model selection} to + * a {@link module:engine/view/documentselection~DocumentSelection view selection}. The converter consumes appropriate + * value from the `consumable` object, maps the model selection position to the view position and breaks + * {@link module:engine/view/attributeelement~AttributeElement attribute elements} at the selection position. + * + * modelDispatcher.on( 'selection', convertCollapsedSelection() ); + * + * An example of the view state before and after converting the collapsed selection: + * + *

f^oobar

+ * ->

f^oobar

+ * + * By breaking attribute elements like ``, the selection is in a correct element. Then, when the selection attribute is + * converted, broken attributes might be merged again, or the position where the selection is may be wrapped + * with different, appropriate attribute elements. + * + * See also {@link module:engine/conversion/downcasthelpers~clearAttributes} which does a clean-up + * by merging attributes. + * + * @returns {Function} Selection converter. + */ +export function convertCollapsedSelection() { + return ( + evt: EventInfo, + data: { selection: ModelSelection | ModelDocumentSelection }, + conversionApi: DowncastConversionApi + ): void => { + const selection = data.selection; + + if ( !selection.isCollapsed ) { + return; + } + + if ( !conversionApi.consumable.consume( selection, 'selection' ) ) { + return; + } + + const viewWriter = conversionApi.writer; + const modelPosition = selection.getFirstPosition()!; + const viewPosition = conversionApi.mapper.toViewPosition( modelPosition ); + const brokenPosition = viewWriter.breakAttributes( viewPosition ); + + viewWriter.setSelection( brokenPosition ); + }; +} + +/** + * Function factory that creates a converter which clears artifacts after the previous + * {@link module:engine/model/selection~Selection model selection} conversion. It removes all empty + * {@link module:engine/view/attributeelement~AttributeElement view attribute elements} and merges sibling attributes at all start and end + * positions of all ranges. + * + *

^

+ * ->

^

+ * + *

foo^barbar

+ * ->

foo^barbar

+ * + *

foo^barbar

+ * ->

foo^barbar

+ * + * This listener should be assigned before any converter for the new selection: + * + * modelDispatcher.on( 'selection', clearAttributes() ); + * + * See {@link module:engine/conversion/downcasthelpers~convertCollapsedSelection} + * which does the opposite by breaking attributes in the selection position. + * + * @returns {Function} Selection converter. + */ +export function clearAttributes() { + return ( + evt: EventInfo, + data: unknown, + conversionApi: DowncastConversionApi + ): void => { + const viewWriter = conversionApi.writer; + const viewSelection = viewWriter.document.selection; + + for ( const range of viewSelection.getRanges() ) { + // Not collapsed selection should not have artifacts. + if ( range.isCollapsed ) { + // Position might be in the node removed by the view writer. + if ( ( range.end.parent as ViewNode ).isAttached() ) { + conversionApi.writer.mergeAttributes( range.start ); + } + } + } + viewWriter.setSelection( null ); + }; +} + +/** + * Function factory that creates a converter which converts the set/change/remove attribute changes from the model to the view. + * It can also be used to convert selection attributes. In that case, an empty attribute element will be created and the + * selection will be put inside it. + * + * Attributes from the model are converted to a view element that will be wrapping these view nodes that are bound to + * model elements having the given attribute. This is useful for attributes like `bold` that may be set on text nodes in the model + * but are represented as an element in the view: + * + * [paragraph] MODEL ====> VIEW

+ * |- a {bold: true} |- + * |- b {bold: true} | |- ab + * |- c |- c + * + * Passed `Function` will be provided with the attribute value and then all the parameters of the + * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute `attribute` event}. + * It is expected that the function returns an {@link module:engine/view/element~Element}. + * The result of the function will be the wrapping element. + * When the provided `Function` does not return any element, no conversion will take place. + * + * The converter automatically consumes the corresponding value from the consumables list and stops the event (see + * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}). + * + * modelDispatcher.on( 'attribute:bold', wrap( ( modelAttributeValue, { writer } ) => { + * return writer.createAttributeElement( 'strong' ); + * } ); + * + * @protected + * @param {Function} elementCreator Function returning a view element that will be used for wrapping. + * @returns {Function} Set/change attribute converter. + */ +export function wrap( elementCreator: AttributeElementCreatorFunction ) { + return ( + evt: EventInfo, + data: { + item: ModelItem | ModelSelection | ModelDocumentSelection; + range: ModelRange; + attributeKey: string; + attributeOldValue: unknown; + attributeNewValue: unknown; + }, + conversionApi: DowncastConversionApi + ): void => { + if ( !conversionApi.consumable.test( data.item, evt.name ) ) { + return; + } + + // Recreate current wrapping node. It will be used to unwrap view range if the attribute value has changed + // or the attribute was removed. + const oldViewElement = elementCreator( data.attributeOldValue, conversionApi, data ); + + // Create node to wrap with. + const newViewElement = elementCreator( data.attributeNewValue, conversionApi, data ); + + if ( !oldViewElement && !newViewElement ) { + return; + } + + conversionApi.consumable.consume( data.item, evt.name ); + + const viewWriter = conversionApi.writer; + const viewSelection = viewWriter.document.selection; + + if ( data.item instanceof ModelSelection || data.item instanceof ModelDocumentSelection ) { + // Selection attribute conversion. + viewWriter.wrap( viewSelection.getFirstRange()!, newViewElement! ); + } else { + // Node attribute conversion. + let viewRange = conversionApi.mapper.toViewRange( data.range ); + + // First, unwrap the range from current wrapper. + if ( data.attributeOldValue !== null && oldViewElement ) { + viewRange = viewWriter.unwrap( viewRange, oldViewElement ); + } + + if ( data.attributeNewValue !== null && newViewElement ) { + viewWriter.wrap( viewRange, newViewElement ); + } + } + }; +} + +/** + * Function factory that creates a converter which converts node insertion changes from the model to the view. + * The function passed will be provided with all the parameters of the dispatcher's + * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert `insert` event}. + * It is expected that the function returns an {@link module:engine/view/element~Element}. + * The result of the function will be inserted into the view. + * + * The converter automatically consumes the corresponding value from the consumables list and binds the model and view elements. + * + * downcastDispatcher.on( + * 'insert:myElem', + * insertElement( ( modelItem, { writer } ) => { + * const text = writer.createText( 'myText' ); + * const myElem = writer.createElement( 'myElem', { myAttr: 'my-' + modelItem.getAttribute( 'myAttr' ) }, text ); + * + * // Do something fancy with `myElem` using `modelItem` or other parameters. + * + * return myElem; + * } + * ) ); + * + * @protected + * @param {Function} elementCreator Function returning a view element, which will be inserted. + * @param {module:engine/conversion/downcasthelpers~ConsumerFunction} [consumer] Function defining element consumption process. + * By default this function just consume passed item insertion. + * @returns {Function} Insert element event converter. + */ +export function insertElement( elementCreator: ElementCreatorFunction, consumer: ConsumerFunction = defaultConsumer ) { + return ( + evt: unknown, + data: { item: ModelElement; range: ModelRange; reconversion?: boolean }, + conversionApi: DowncastConversionApi + ): void => { + if ( !consumer( data.item, conversionApi.consumable, { preflight: true } ) ) { + return; + } + + const viewElement = elementCreator( data.item, conversionApi, data ); + + if ( !viewElement ) { + return; + } + + // Consume an element insertion and all present attributes that are specified as a reconversion triggers. + consumer( data.item, conversionApi.consumable ); + + const viewPosition = conversionApi.mapper.toViewPosition( data.range.start ); + + conversionApi.mapper.bindElements( data.item, viewElement ); + conversionApi.writer.insert( viewPosition, viewElement ); + + // Convert attributes before converting children. + conversionApi.convertAttributes( data.item ); + + // Convert children or reinsert previous view elements. + reinsertOrConvertNodes( viewElement, data.item.getChildren(), conversionApi, { reconversion: data.reconversion } ); + }; +} + +/** + * Function factory that creates a converter which converts a single model node insertion to a view structure. + * + * It is expected that the passed element creator function returns an {@link module:engine/view/element~Element} with attached slots + * created with `writer.createSlot()` to indicate where child nodes should be converted. + * + * @see module:engine/conversion/downcasthelpers~DowncastHelpers#elementToStructure + * + * @protected + * @param {module:engine/conversion/downcasthelpers~StructureCreatorFunction} elementCreator Function returning a view structure, + * which will be inserted. + * @param {module:engine/conversion/downcasthelpers~ConsumerFunction} consumer A callback that is expected to consume all the consumables + * that were used by the element creator. + * @returns {Function} Insert element event converter. +*/ +export function insertStructure( elementCreator: StructureCreatorFunction, consumer: ConsumerFunction ) { + return ( + evt: unknown, + data: { item: ModelElement; range: ModelRange; reconversion?: boolean }, + conversionApi: DowncastConversionApi + ): void => { + if ( !consumer( data.item, conversionApi.consumable, { preflight: true } ) ) { + return; + } + + const slotsMap = new Map(); + + conversionApi.writer._registerSlotFactory( createSlotFactory( data.item, slotsMap, conversionApi ) ); + + // View creation. + const viewElement = elementCreator( data.item, conversionApi, data ); + + conversionApi.writer._clearSlotFactory(); + + if ( !viewElement ) { + return; + } + + // Check if all children are covered by slots and there is no child that landed in multiple slots. + validateSlotsChildren( data.item, slotsMap, conversionApi ); + + // Consume an element insertion and all present attributes that are specified as a reconversion triggers. + consumer( data.item, conversionApi.consumable ); + + const viewPosition = conversionApi.mapper.toViewPosition( data.range.start ); + + conversionApi.mapper.bindElements( data.item, viewElement ); + conversionApi.writer.insert( viewPosition, viewElement ); + + // Convert attributes before converting children. + conversionApi.convertAttributes( data.item ); + + // Fill view slots with previous view elements or create new ones. + fillSlots( viewElement, slotsMap, conversionApi, { reconversion: data.reconversion } ); + }; +} + +/** + * Function factory that creates a converter which converts marker adding change to the + * {@link module:engine/view/uielement~UIElement view UI element}. + * + * The view UI element that will be added to the view depends on the passed parameter. See {@link ~insertElement}. + * In case of a non-collapsed range, the UI element will not wrap nodes but separate elements will be placed at the beginning + * and at the end of the range. + * + * This converter binds created UI elements with the marker name using {@link module:engine/conversion/mapper~Mapper#bindElementToMarker}. + * + * @protected + * @param {module:engine/view/uielement~UIElement|Function} elementCreator A view UI element or a function returning the view element + * that will be inserted. + * @returns {Function} Insert element event converter. + */ +export function insertUIElement( elementCreator: MarkerElementCreatorFunction ) { + return ( + evt: EventInfo, + data: { + markerRange: ModelRange; + markerName: string; + isOpening?: boolean; + }, + conversionApi: DowncastConversionApi + ): void => { + // Create two view elements. One will be inserted at the beginning of marker, one at the end. + // If marker is collapsed, only "opening" element will be inserted. + data.isOpening = true; + const viewStartElement = elementCreator( data, conversionApi ); + + data.isOpening = false; + const viewEndElement = elementCreator( data, conversionApi ); + + if ( !viewStartElement || !viewEndElement ) { + return; + } + + const markerRange = data.markerRange; + + // Marker that is collapsed has consumable build differently that non-collapsed one. + // For more information see `addMarker` event description. + // If marker's range is collapsed - check if it can be consumed. + if ( markerRange.isCollapsed && !conversionApi.consumable.consume( markerRange, evt.name ) ) { + return; + } + + // If marker's range is not collapsed - consume all items inside. + for ( const value of markerRange ) { + if ( !conversionApi.consumable.consume( value.item, evt.name ) ) { + return; + } + } + + const mapper = conversionApi.mapper; + const viewWriter = conversionApi.writer; + + // Add "opening" element. + viewWriter.insert( mapper.toViewPosition( markerRange.start ), viewStartElement ); + conversionApi.mapper.bindElementToMarker( viewStartElement, data.markerName ); + + // Add "closing" element only if range is not collapsed. + if ( !markerRange.isCollapsed ) { + viewWriter.insert( mapper.toViewPosition( markerRange.end ), viewEndElement ); + conversionApi.mapper.bindElementToMarker( viewEndElement, data.markerName ); + } + + evt.stop(); + }; +} + +// Function factory that returns a default downcast converter for removing a {@link module:engine/view/uielement~UIElement UI element} +// based on marker remove change. +// +// This converter unbinds elements from the marker name. +// +// @returns {Function} Removed UI element converter. +function removeUIElement() { + return ( + evt: EventInfo, + data: { markerName: string }, + conversionApi: DowncastConversionApi + ): void => { + const elements = conversionApi.mapper.markerNameToElements( data.markerName ); + + if ( !elements ) { + return; + } + + for ( const element of elements ) { + conversionApi.mapper.unbindElementFromMarkerName( element, data.markerName ); + conversionApi.writer.clear( conversionApi.writer.createRangeOn( element ), element ); + } + + conversionApi.writer.clearClonedElementsGroup( data.markerName ); + + evt.stop(); + }; +} + +// Function factory that creates a default converter for model markers. +// +// See {@link DowncastHelpers#markerToData} for more information what type of view is generated. +// +// This converter binds created UI elements and affected view elements with the marker name +// using {@link module:engine/conversion/mapper~Mapper#bindElementToMarker}. +// +// @returns {Function} Add marker converter. +function insertMarkerData( viewCreator: MarkerDataCreatorFunction ) { + return ( + evt: EventInfo, + data: { + markerName: string; + markerRange: ModelRange; + }, + conversionApi: DowncastConversionApi + ): void => { + const viewMarkerData = viewCreator( data.markerName, conversionApi ); + + if ( !viewMarkerData ) { + return; + } + + const markerRange = data.markerRange; + + if ( !conversionApi.consumable.consume( markerRange, evt.name ) ) { + return; + } + + // Adding closing data first to keep the proper order in the view. + handleMarkerBoundary( markerRange, false, conversionApi, data, viewMarkerData ); + handleMarkerBoundary( markerRange, true, conversionApi, data, viewMarkerData ); + + evt.stop(); + }; +} + +// Helper function for `insertMarkerData()` that marks a marker boundary at the beginning or end of given `range`. +function handleMarkerBoundary( + range: ModelRange, + isStart: boolean, + conversionApi: DowncastConversionApi, + data: { markerName: string }, + viewMarkerData: { name: string; group: string } +): void { + const modelPosition = isStart ? range.start : range.end; + const elementAfter = modelPosition.nodeAfter && modelPosition.nodeAfter.is( 'element' ) ? modelPosition.nodeAfter : null; + const elementBefore = modelPosition.nodeBefore && modelPosition.nodeBefore.is( 'element' ) ? modelPosition.nodeBefore : null; + + if ( elementAfter || elementBefore ) { + let modelElement; + let isBefore; + + // If possible, we want to add `data-group-start-before` and `data-group-end-after` attributes. + if ( isStart && elementAfter || !isStart && !elementBefore ) { + // [... -> ... + // ] -> + modelElement = elementAfter; + isBefore = true; + } else { + // ...] -> ... + // [ -> + modelElement = elementBefore; + isBefore = false; + } + + const viewElement = conversionApi.mapper.toViewElement( modelElement! ); + + // In rare circumstances, the model element may be not mapped to any view element and that would cause an error. + // One of those situations is a soft break inside code block. + if ( viewElement ) { + insertMarkerAsAttribute( viewElement, isStart, isBefore, conversionApi, data, viewMarkerData ); + + return; + } + } + + const viewPosition = conversionApi.mapper.toViewPosition( modelPosition ); + + insertMarkerAsElement( viewPosition, isStart, conversionApi, data, viewMarkerData ); +} + +// Helper function for `insertMarkerData()` that marks a marker boundary in the view as an attribute on a view element. +function insertMarkerAsAttribute( + viewElement: ViewElement, + isStart: boolean, + isBefore: boolean, + conversionApi: DowncastConversionApi, + data: { markerName: string }, + viewMarkerData: { name: string; group: string } +) { + const attributeName = `data-${ viewMarkerData.group }-${ isStart ? 'start' : 'end' }-${ isBefore ? 'before' : 'after' }`; + + const markerNames = viewElement.hasAttribute( attributeName ) ? viewElement.getAttribute( attributeName )!.split( ',' ) : []; + + // Adding marker name at the beginning to have the same order in the attribute as there is with marker elements. + markerNames.unshift( viewMarkerData.name ); + + conversionApi.writer.setAttribute( attributeName, markerNames.join( ',' ), viewElement ); + conversionApi.mapper.bindElementToMarker( viewElement, data.markerName ); +} + +// Helper function for `insertMarkerData()` that marks a marker boundary in the view as a separate view ui element. +function insertMarkerAsElement( + position: ViewPosition, + isStart: boolean, + conversionApi: DowncastConversionApi, + data: { markerName: string }, + viewMarkerData: { name: string; group: string } +) { + const viewElementName = `${ viewMarkerData.group }-${ isStart ? 'start' : 'end' }`; + + const attrs = viewMarkerData.name ? { 'name': viewMarkerData.name } : null; + const viewElement = conversionApi.writer.createUIElement( viewElementName, attrs ); + + conversionApi.writer.insert( position, viewElement ); + conversionApi.mapper.bindElementToMarker( viewElement, data.markerName ); +} + +// Function factory that creates a converter for removing a model marker data added by the {@link #insertMarkerData} converter. +// +// @returns {Function} Remove marker converter. +function removeMarkerData( viewCreator: MarkerDataCreatorFunction ) { + return ( + evt: EventInfo, + data: { markerName: string }, + conversionApi: DowncastConversionApi + ): void => { + const viewData = viewCreator( data.markerName, conversionApi ); + + if ( !viewData ) { + return; + } + + const elements = conversionApi.mapper.markerNameToElements( data.markerName ); + + if ( !elements ) { + return; + } + + for ( const element of elements ) { + conversionApi.mapper.unbindElementFromMarkerName( element, data.markerName ); + + if ( element.is( 'containerElement' ) ) { + removeMarkerFromAttribute( `data-${ viewData.group }-start-before`, element ); + removeMarkerFromAttribute( `data-${ viewData.group }-start-after`, element ); + removeMarkerFromAttribute( `data-${ viewData.group }-end-before`, element ); + removeMarkerFromAttribute( `data-${ viewData.group }-end-after`, element ); + } else { + conversionApi.writer.clear( conversionApi.writer.createRangeOn( element ), element ); + } + } + + conversionApi.writer.clearClonedElementsGroup( data.markerName ); + + evt.stop(); + + function removeMarkerFromAttribute( attributeName: string, element: ViewElement ): void { + if ( element.hasAttribute( attributeName ) ) { + const markerNames = new Set( element.getAttribute( attributeName )!.split( ',' ) ); + + markerNames.delete( viewData!.name ); + + if ( markerNames.size == 0 ) { + conversionApi.writer.removeAttribute( attributeName, element ); + } else { + conversionApi.writer.setAttribute( attributeName, Array.from( markerNames ).join( ',' ), element ); + } + } + } + }; +} + +// Function factory that creates a converter which converts the set/change/remove attribute changes from the model to the view. +// +// Attributes from the model are converted to the view element attributes in the view. You may provide a custom function to generate +// a key-value attribute pair to add/change/remove. If not provided, model attributes will be converted to view element +// attributes on a one-to-one basis. +// +// *Note:** The provided attribute creator should always return the same `key` for a given attribute from the model. +// +// The converter automatically consumes the corresponding value from the consumables list and stops the event (see +// {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}). +// +// modelDispatcher.on( 'attribute:customAttr:myElem', changeAttribute( ( value, data ) => { +// // Change attribute key from `customAttr` to `class` in the view. +// const key = 'class'; +// let value = data.attributeNewValue; +// +// // Force attribute value to 'empty' if the model element is empty. +// if ( data.item.childCount === 0 ) { +// value = 'empty'; +// } +// +// // Return the key-value pair. +// return { key, value }; +// } ) ); +// +// @param {Function} [attributeCreator] Function returning an object with two properties: `key` and `value`, which +// represent the attribute key and attribute value to be set on a {@link module:engine/view/element~Element view element}. +// The function is passed the model attribute value as the first parameter and additional data about the change as the second parameter. +// @returns {Function} Set/change attribute converter. +function changeAttribute( attributeCreator: AttributeCreatorFunction ) { + return ( + evt: EventInfo, + data: { + item: ModelElement; + range: ModelRange; + attributeKey: string; + attributeOldValue: unknown; + attributeNewValue: unknown; + }, + conversionApi: DowncastConversionApi + ): void => { + if ( !conversionApi.consumable.test( data.item, evt.name ) ) { + return; + } + + const oldAttribute = attributeCreator( data.attributeOldValue, conversionApi, data ); + const newAttribute = attributeCreator( data.attributeNewValue, conversionApi, data ); + + if ( !oldAttribute && !newAttribute ) { + return; + } + + conversionApi.consumable.consume( data.item, evt.name ); + + const viewElement = conversionApi.mapper.toViewElement( data.item ); + const viewWriter = conversionApi.writer; + + // If model item cannot be mapped to a view element, it means item is not an `Element` instance but a `TextProxy` node. + // Only elements can have attributes in a view so do not proceed for anything else (#1587). + if ( !viewElement ) { + /** + * This error occurs when a {@link module:engine/model/textproxy~TextProxy text node's} attribute is to be downcasted + * by an {@link module:engine/conversion/conversion~Conversion#attributeToAttribute `Attribute to Attribute converter`}. + * In most cases it is caused by converters misconfiguration when only "generic" converter is defined: + * + * editor.conversion.for( 'downcast' ).attributeToAttribute( { + * model: 'attribute-name', + * view: 'attribute-name' + * } ) ); + * + * and given attribute is used on text node, for example: + * + * model.change( writer => { + * writer.insertText( 'Foo', { 'attribute-name': 'bar' }, parent, 0 ); + * } ); + * + * In such cases, to convert the same attribute for both {@link module:engine/model/element~Element} + * and {@link module:engine/model/textproxy~TextProxy `Text`} nodes, text specific + * {@link module:engine/conversion/conversion~Conversion#attributeToElement `Attribute to Element converter`} + * with higher {@link module:utils/priorities~PriorityString priority} must also be defined: + * + * editor.conversion.for( 'downcast' ).attributeToElement( { + * model: { + * key: 'attribute-name', + * name: '$text' + * }, + * view: ( value, { writer } ) => { + * return writer.createAttributeElement( 'span', { 'attribute-name': value } ); + * }, + * converterPriority: 'high' + * } ) ); + * + * @error conversion-attribute-to-attribute-on-text + */ + throw new CKEditorError( 'conversion-attribute-to-attribute-on-text', conversionApi.dispatcher, data ); + } + + // First remove the old attribute if there was one. + if ( data.attributeOldValue !== null && oldAttribute ) { + if ( oldAttribute.key == 'class' ) { + const classes = toArray( oldAttribute.value ); + + for ( const className of classes ) { + viewWriter.removeClass( className, viewElement ); + } + } else if ( oldAttribute.key == 'style' ) { + const keys = Object.keys( oldAttribute.value ); + + for ( const key of keys ) { + viewWriter.removeStyle( key, viewElement ); + } + } else { + viewWriter.removeAttribute( oldAttribute.key, viewElement ); + } + } + + // Then set the new attribute. + if ( data.attributeNewValue !== null && newAttribute ) { + if ( newAttribute.key == 'class' ) { + const classes = toArray( newAttribute.value ); + + for ( const className of classes ) { + viewWriter.addClass( className, viewElement ); + } + } else if ( newAttribute.key == 'style' ) { + const keys = Object.keys( newAttribute.value ); + + for ( const key of keys ) { + viewWriter.setStyle( key, ( newAttribute.value as Record )[ key ], viewElement ); + } + } else { + viewWriter.setAttribute( newAttribute.key, newAttribute.value as string, viewElement ); + } + } + }; +} + +// Function factory that creates a converter which converts the text inside marker's range. The converter wraps the text with +// {@link module:engine/view/attributeelement~AttributeElement} created from the provided descriptor. +// See {link module:engine/conversion/downcasthelpers~createViewElementFromHighlightDescriptor}. +// +// It can also be used to convert the selection that is inside a marker. In that case, an empty attribute element will be +// created and the selection will be put inside it. +// +// If the highlight descriptor does not provide the `priority` property, `10` will be used. +// +// If the highlight descriptor does not provide the `id` property, the name of the marker will be used. +// +// This converter binds the created {@link module:engine/view/attributeelement~AttributeElement attribute elemens} with the marker name +// using the {@link module:engine/conversion/mapper~Mapper#bindElementToMarker} method. +// +// @param {module:engine/conversion/downcasthelpers~HighlightDescriptor|Function} highlightDescriptor +// @returns {Function} +function highlightText( highlightDescriptor: HighlightDescriptor | HighlightDescriptorCreatorFunction ) { + return ( + evt: EventInfo, + data: { + item?: ModelItem | ModelSelection | ModelDocumentSelection; + range?: ModelRange; + markerRange: ModelRange; + markerName: string; + }, + conversionApi: DowncastConversionApi + ): void => { + if ( !data.item ) { + return; + } + + if ( !( data.item instanceof ModelSelection || data.item instanceof ModelDocumentSelection ) && !data.item.is( '$textProxy' ) ) { + return; + } + + const descriptor = prepareDescriptor( highlightDescriptor, data, conversionApi ); + + if ( !descriptor ) { + return; + } + + if ( !conversionApi.consumable.consume( data.item, evt.name ) ) { + return; + } + + const viewWriter = conversionApi.writer; + const viewElement = createViewElementFromHighlightDescriptor( viewWriter, descriptor ); + const viewSelection = viewWriter.document.selection; + + if ( data.item instanceof ModelSelection || data.item instanceof ModelDocumentSelection ) { + viewWriter.wrap( viewSelection.getFirstRange()!, viewElement ); + } else { + const viewRange = conversionApi.mapper.toViewRange( data.range! ); + const rangeAfterWrap = viewWriter.wrap( viewRange, viewElement ); + + for ( const element of rangeAfterWrap.getItems() ) { + if ( element.is( 'attributeElement' ) && element.isSimilar( viewElement ) ) { + conversionApi.mapper.bindElementToMarker( element, data.markerName ); + + // One attribute element is enough, because all of them are bound together by the view writer. + // Mapper uses this binding to get all the elements no matter how many of them are registered in the mapper. + break; + } + } + } + }; +} + +// Converter function factory. It creates a function which applies the marker's highlight to an element inside the marker's range. +// +// The converter checks if an element has the `addHighlight` function stored as a +// {@link module:engine/view/element~Element#_setCustomProperty custom property} and, if so, uses it to apply the highlight. +// In such case the converter will consume all element's children, assuming that they were handled by the element itself. +// +// When the `addHighlight` custom property is not present, the element is not converted in any special way. +// This means that converters will proceed to convert the element's child nodes. +// +// If the highlight descriptor does not provide the `priority` property, `10` will be used. +// +// If the highlight descriptor does not provide the `id` property, the name of the marker will be used. +// +// This converter binds altered {@link module:engine/view/containerelement~ContainerElement container elements} with the marker name using +// the {@link module:engine/conversion/mapper~Mapper#bindElementToMarker} method. +// +// @param {module:engine/conversion/downcasthelpers~HighlightDescriptor|Function} highlightDescriptor +// @returns {Function} +function highlightElement( highlightDescriptor: HighlightDescriptor | HighlightDescriptorCreatorFunction ) { + return ( + evt: EventInfo, + data: { + item?: ModelItem | ModelSelection | ModelDocumentSelection; + markerName: string; + markerRange: ModelRange; + }, + conversionApi: DowncastConversionApi + ): void => { + if ( !data.item ) { + return; + } + + if ( !( data.item instanceof ModelElement ) ) { + return; + } + + const descriptor = prepareDescriptor( highlightDescriptor, data, conversionApi ); + + if ( !descriptor ) { + return; + } + + if ( !conversionApi.consumable.test( data.item, evt.name ) ) { + return; + } + + const viewElement = conversionApi.mapper.toViewElement( data.item ); + + if ( viewElement && viewElement.getCustomProperty( 'addHighlight' ) ) { + // Consume element itself. + conversionApi.consumable.consume( data.item, evt.name ); + + // Consume all children nodes. + for ( const value of ModelRange._createIn( data.item ) ) { + conversionApi.consumable.consume( value.item, evt.name ); + } + + const addHighlightCallback = viewElement.getCustomProperty( 'addHighlight' ) as AddHighlightCallback; + + addHighlightCallback( viewElement, descriptor, conversionApi.writer ); + + conversionApi.mapper.bindElementToMarker( viewElement, data.markerName ); + } + }; +} + +// Function factory that creates a converter which converts the removing model marker to the view. +// +// Both text nodes and elements are handled by this converter but they are handled a bit differently. +// +// Text nodes are unwrapped using the {@link module:engine/view/attributeelement~AttributeElement attribute element} created from the +// provided highlight descriptor. See {link module:engine/conversion/downcasthelpers~HighlightDescriptor}. +// +// For elements, the converter checks if an element has the `removeHighlight` function stored as a +// {@link module:engine/view/element~Element#_setCustomProperty custom property}. If so, it uses it to remove the highlight. +// In such case, the children of that element will not be converted. +// +// When `removeHighlight` is not present, the element is not converted in any special way. +// The converter will proceed to convert the element's child nodes instead. +// +// If the highlight descriptor does not provide the `priority` property, `10` will be used. +// +// If the highlight descriptor does not provide the `id` property, the name of the marker will be used. +// +// This converter unbinds elements from the marker name. +// +// @param {module:engine/conversion/downcasthelpers~HighlightDescriptor|Function} highlightDescriptor +// @returns {Function} +function removeHighlight( highlightDescriptor: HighlightDescriptor | HighlightDescriptorCreatorFunction ) { + return ( + evt: EventInfo, + data: { + markerName: string; + markerRange: ModelRange; + }, + conversionApi: DowncastConversionApi + ): void => { + // This conversion makes sense only for non-collapsed range. + if ( data.markerRange.isCollapsed ) { + return; + } + + const descriptor = prepareDescriptor( highlightDescriptor, data, conversionApi ); + + if ( !descriptor ) { + return; + } + + // View element that will be used to unwrap `AttributeElement`s. + const viewHighlightElement = createViewElementFromHighlightDescriptor( conversionApi.writer, descriptor ); + + // Get all elements bound with given marker name. + const elements = conversionApi.mapper.markerNameToElements( data.markerName ); + + if ( !elements ) { + return; + } + + for ( const element of elements ) { + conversionApi.mapper.unbindElementFromMarkerName( element, data.markerName ); + + if ( element.is( 'attributeElement' ) ) { + conversionApi.writer.unwrap( conversionApi.writer.createRangeOn( element ), viewHighlightElement ); + } else { + // if element.is( 'containerElement' ). + const removeHighlightCallback = element.getCustomProperty( 'removeHighlight' ) as RemoveHighlightCallback; + + removeHighlightCallback( element, descriptor.id!, conversionApi.writer ); + } + } + + conversionApi.writer.clearClonedElementsGroup( data.markerName ); + + evt.stop(); + }; +} + +// Model element to view element conversion helper. +// +// See {@link ~DowncastHelpers#elementToElement `.elementToElement()` downcast helper} for examples and config params description. +// +// @param {Object} config Conversion configuration. +// @param {String|Object} config.model The description or a name of the model element to convert. +// @param {String|Array.} [config.model.attributes] List of attributes triggering element reconversion. +// @param {Boolean} [config.model.children] Should reconvert element if the list of model child nodes changed. +// @param {module:engine/view/elementdefinition~ElementDefinition|module:engine/conversion/downcasthelpers~ElementCreatorFunction} +// config.view +// @returns {Function} Conversion helper. +function downcastElementToElement( config: { + model: string | { + name: string; + attributes?: string | string[]; + children?: boolean; + }; + view: ElementDefinition | ElementCreatorFunction; + converterPriority?: PriorityString | number; +} ) { + const model = normalizeModelElementConfig( config.model ); + const view = normalizeToElementConfig( config.view, 'container' ); + + // Trigger reconversion on children list change if element is a subject to any reconversion. + // This is required to be able to trigger Differ#refreshItem() on a direct child of the reconverted element. + if ( model.attributes.length ) { + model.children = true; + } + + return ( dispatcher: DowncastDispatcher ) => { + dispatcher.on>( + `insert:${ model.name }`, + insertElement( view, createConsumer( model ) ), + { priority: config.converterPriority || 'normal' } + ); + + if ( model.children || model.attributes.length ) { + dispatcher.on( 'reduceChanges', createChangeReducer( model ), { priority: 'low' } ); + } + }; +} + +// Model element to view structure conversion helper. +// +// See {@link ~DowncastHelpers#elementToStructure `.elementToStructure()` downcast helper} for examples and config params description. +// +// @param {Object} config Conversion configuration. +// @param {String|Object} config.model +// @param {String} [config.model.name] +// @param {Array.} [config.model.attributes] +// @param {module:engine/conversion/downcasthelpers~StructureCreatorFunction} config.view +// @returns {Function} Conversion helper. +function downcastElementToStructure( + config: { + model: string | { + name: string; + attributes?: string | string[]; + }; + view: StructureCreatorFunction; + converterPriority?: PriorityString | number; + } +) { + const model = normalizeModelElementConfig( config.model ); + const view = normalizeToElementConfig( config.view, 'container' ); + + // Trigger reconversion on children list change because it always needs to use slots to put children in proper places. + // This is required to be able to trigger Differ#refreshItem() on a direct child of the reconverted element. + model.children = true; + + return ( dispatcher: DowncastDispatcher ) => { + if ( dispatcher._conversionApi.schema.checkChild( model.name, '$text' ) ) { + /** + * This error occurs when a {@link module:engine/model/element~Element model element} is downcasted + * via {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToStructure} helper but the element was + * allowed to host `$text` by the {@link module:engine/model/schema~Schema model schema}. + * + * For instance, this may be the result of `myElement` allowing the content of + * {@glink framework/guides/deep-dive/schema#generic-items `$block`} in its schema definition: + * + * // Element definition in schema. + * schema.register( 'myElement', { + * allowContentOf: '$block', + * + * // ... + * } ); + * + * // ... + * + * // Conversion of myElement with the use of elementToStructure(). + * editor.conversion.for( 'downcast' ).elementToStructure( { + * model: 'myElement', + * view: ( modelElement, { writer } ) => { + * // ... + * } + * } ); + * + * In such case, {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement `elementToElement()`} helper + * can be used instead to get around this problem: + * + * editor.conversion.for( 'downcast' ).elementToElement( { + * model: 'myElement', + * view: ( modelElement, { writer } ) => { + * // ... + * } + * } ); + * + * @error conversion-element-to-structure-disallowed-text + * @param {String} elementName The name of the element the structure is to be created for. + */ + throw new CKEditorError( 'conversion-element-to-structure-disallowed-text', dispatcher, { elementName: model.name } ); + } + + dispatcher.on>( + `insert:${ model.name }`, + insertStructure( view, createConsumer( model ) ), + { priority: config.converterPriority || 'normal' } + ); + + dispatcher.on( 'reduceChanges', createChangeReducer( model ), { priority: 'low' } ); + }; +} + +// Model attribute to view element conversion helper. +// +// See {@link ~DowncastHelpers#attributeToElement `.attributeToElement()` downcast helper} for examples. +// +// @param {Object} config Conversion configuration. +// @param {String|Object} config.model The key of the attribute to convert from or a `{ key, values }` object. `values` is an array +// of `String`s with possible values if the model attribute is an enumerable. +// @param {module:engine/view/elementdefinition~ElementDefinition|module:engine/conversion/downcasthelpers~AttributeElementCreatorFunction| +// Object} config.view A view element definition or a function that takes the model attribute value and +// {@link module:engine/view/downcastwriter~DowncastWriter view downcast writer} as parameters and returns a view attribute element. +// If `config.model.values` is given, `config.view` should be an object assigning values from `config.model.values` to view element +// definitions or functions. +// @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority. +// @returns {Function} Conversion helper. +function downcastAttributeToElement( config: { + model: string | { + key: string; + name?: string; + values?: string[]; + }; + view: ElementDefinition | AttributeElementCreatorFunction | Record; + converterPriority?: PriorityString | number; +} ) { + config = cloneDeep( config ); + + let model = config.model; + + if ( typeof model == 'string' ) { + model = { key: model }; + } + + let eventName = `attribute:${ model.key }` as const; + + if ( model.name ) { + eventName += ':' + model.name; + } + + if ( model.values ) { + for ( const modelValue of model.values ) { + ( config.view as any )[ modelValue ] = normalizeToElementConfig( ( config.view as any )[ modelValue ], 'attribute' ); + } + } else { + config.view = normalizeToElementConfig( config.view as any, 'attribute' ); + } + + const elementCreator = getFromAttributeCreator( config ); + + return ( dispatcher: DowncastDispatcher ) => { + dispatcher.on( + eventName, + wrap( elementCreator ), + { priority: config.converterPriority || 'normal' } + ); + }; +} + +// Model attribute to view attribute conversion helper. +// +// See {@link ~DowncastHelpers#attributeToAttribute `.attributeToAttribute()` downcast helper} for examples. +// +// @param {Object} config Conversion configuration. +// @param {String|Object} config.model The key of the attribute to convert from or a `{ key, values, [ name ] }` object describing +// the attribute key, possible values and, optionally, an element name to convert from. +// @param {String|Object|module:engine/conversion/downcasthelpers~AttributeCreatorFunction} config.view A view attribute key, +// or a `{ key, value }` object or a function that takes the model attribute value and returns a `{ key, value }` object. +// If `key` is `'class'`, `value` can be a `String` or an array of `String`s. If `key` is `'style'`, `value` is an object with +// key-value pairs. In other cases, `value` is a `String`. +// If `config.model.values` is set, `config.view` should be an object assigning values from `config.model.values` to +// `{ key, value }` objects or a functions. +// @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority. +// @returns {Function} Conversion helper. +function downcastAttributeToAttribute( config: { + model: string | { + key: string; + name?: string; + values?: string[]; + }; + view: string | AttributeDescriptor | AttributeCreatorFunction | Record; + converterPriority?: PriorityString | number; +} ) { + config = cloneDeep( config ); + + let model = config.model; + + if ( typeof model == 'string' ) { + model = { key: model }; + } + + let eventName = `attribute:${ model.key }` as const; + + if ( model.name ) { + eventName += ':' + model.name; + } + + if ( model.values ) { + for ( const modelValue of model.values ) { + ( config.view as any )[ modelValue ] = normalizeToAttributeConfig( ( config.view as any )[ modelValue ] ); + } + } else { + config.view = normalizeToAttributeConfig( config.view ); + } + + const elementCreator = getFromAttributeCreator( config ); + + return ( dispatcher: DowncastDispatcher ) => { + dispatcher.on>( + eventName, + changeAttribute( elementCreator ), + { priority: config.converterPriority || 'normal' } + ); + }; +} + +// Model marker to view element conversion helper. +// +// See {@link ~DowncastHelpers#markerToElement `.markerToElement()` downcast helper} for examples. +// +// @param {Object} config Conversion configuration. +// @param {String} config.model The name of the model marker (or model marker group) to convert. +// @param {module:engine/view/elementdefinition~ElementDefinition|Function} config.view A view element definition or a function +// that takes the model marker data as a parameter and returns a view UI element. +// @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority. +// @returns {Function} Conversion helper. +function downcastMarkerToElement( config: { + model: string; + view: ElementDefinition | MarkerElementCreatorFunction; + converterPriority?: PriorityString | number; +} ) { + const view = normalizeToElementConfig( config.view, 'ui' ); + + return ( dispatcher: DowncastDispatcher ) => { + dispatcher.on( + `addMarker:${ config.model }`, + insertUIElement( view ), + { priority: config.converterPriority || 'normal' } + ); + dispatcher.on( + `removeMarker:${ config.model }`, + removeUIElement(), + { priority: config.converterPriority || 'normal' } + ); + }; +} + +// Model marker to view data conversion helper. +// +// See {@link ~DowncastHelpers#markerToData `markerToData()` downcast helper} to learn more. +// +// @param {Object} config +// @param {String} config.model +// @param {Function} [config.view] +// @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] +// @returns {Function} Conversion helper. +function downcastMarkerToData( config: { + model: string; + view?: MarkerDataCreatorFunction; + converterPriority?: PriorityString | number; +} ) { + config = cloneDeep( config ); + + const group = config.model; + let view = config.view; + + // Default conversion. + if ( !view ) { + view = markerName => ( { + group, + name: markerName.substr( config.model.length + 1 ) + } ); + } + + return ( dispatcher: DowncastDispatcher ) => { + dispatcher.on( + `addMarker:${ group }`, + insertMarkerData( view! ), + { priority: config.converterPriority || 'normal' } + ); + dispatcher.on( + `removeMarker:${ group }`, + removeMarkerData( view! ), + { priority: config.converterPriority || 'normal' } + ); + }; +} + +// Model marker to highlight conversion helper. +// +// See {@link ~DowncastHelpers#markerToElement `.markerToElement()` downcast helper} for examples. +// +// @param {Object} config Conversion configuration. +// @param {String} config.model The name of the model marker (or model marker group) to convert. +// @param {module:engine/conversion/downcasthelpers~HighlightDescriptor|Function} config.view A highlight descriptor +// that will be used for highlighting or a function that takes the model marker data as a parameter and returns a highlight descriptor. +// @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority. +// @returns {Function} Conversion helper. +function downcastMarkerToHighlight( config: { + model: string; + view: HighlightDescriptor | HighlightDescriptorCreatorFunction; + converterPriority?: PriorityString | number; +} ) { + return ( dispatcher: DowncastDispatcher ) => { + dispatcher.on( + `addMarker:${ config.model }`, + highlightText( config.view ), + { priority: config.converterPriority || 'normal' } + ); + dispatcher.on( + `addMarker:${ config.model }`, + highlightElement( config.view ), + { priority: config.converterPriority || 'normal' } + ); + dispatcher.on( + `removeMarker:${ config.model }`, + removeHighlight( config.view ), + { priority: config.converterPriority || 'normal' } + ); + }; +} + +// Takes `config.model`, and converts it to an object with normalized structure. +// +// @param {String|Object} model Model configuration or element name. +// @param {String} model.name +// @param {Array.} [model.attributes] +// @param {Boolean} [model.children] +// @returns {Object} +function normalizeModelElementConfig( model: string | { + name: string; + attributes?: string | string[]; + children?: boolean; +} ): NormalizedModelElementConfig { + if ( typeof model == 'string' ) { + model = { name: model }; + } + + // List of attributes that should trigger reconversion. + if ( !model.attributes ) { + model.attributes = []; + } else if ( !Array.isArray( model.attributes ) ) { + model.attributes = [ model.attributes ]; + } + + // Whether a children insertion/deletion should trigger reconversion. + model.children = !!model.children; + + return model as any; +} + +interface NormalizedModelElementConfig { + name: string; + attributes: string[]; + children: boolean; +} + +// Takes `config.view`, and if it is an {@link module:engine/view/elementdefinition~ElementDefinition}, converts it +// to a function (because lower level converters accept only element creator functions). +// +// @param {module:engine/view/elementdefinition~ElementDefinition|Function} view View configuration. +// @param {'container'|'attribute'|'ui'} viewElementType View element type to create. +// @returns {Function} Element creator function to use in lower level converters. +function normalizeToElementConfig( + view: ElementDefinition | T, + viewElementType: 'container' | 'attribute' | 'ui' +): T { + if ( typeof view == 'function' ) { + // If `view` is already a function, don't do anything. + return view as any; + } + + return ( ( modelData: unknown, conversionApi: DowncastConversionApi ) => + createViewElementFromDefinition( view, conversionApi, viewElementType ) ) as any; +} + +// Creates a view element instance from the provided {@link module:engine/view/elementdefinition~ElementDefinition} and class. +// +// @param {module:engine/view/elementdefinition~ElementDefinition} viewElementDefinition +// @param {module:engine/view/downcastwriter~DowncastWriter} viewWriter +// @param {'container'|'attribute'|'ui'} viewElementType +// @returns {module:engine/view/element~Element} +function createViewElementFromDefinition( + viewElementDefinition: ElementDefinition, + conversionApi: DowncastConversionApi, + viewElementType: 'container' | 'attribute' | 'ui' +): ViewElement { + if ( typeof viewElementDefinition == 'string' ) { + // If `viewElementDefinition` is given as a `String`, normalize it to an object with `name` property. + viewElementDefinition = { name: viewElementDefinition }; + } + + let element: ViewElement; + const viewWriter = conversionApi.writer; + const attributes = Object.assign( {}, viewElementDefinition.attributes ); + + if ( viewElementType == 'container' ) { + element = viewWriter.createContainerElement( viewElementDefinition.name, attributes ); + } else if ( viewElementType == 'attribute' ) { + const options = { + priority: viewElementDefinition.priority || ViewAttributeElement.DEFAULT_PRIORITY + }; + + element = viewWriter.createAttributeElement( viewElementDefinition.name, attributes, options ); + } else { + // 'ui'. + element = viewWriter.createUIElement( viewElementDefinition.name, attributes ); + } + + if ( viewElementDefinition.styles ) { + const keys = Object.keys( viewElementDefinition.styles ); + + for ( const key of keys ) { + viewWriter.setStyle( key, viewElementDefinition.styles[ key ], element ); + } + } + + if ( viewElementDefinition.classes ) { + const classes = viewElementDefinition.classes; + + if ( typeof classes == 'string' ) { + viewWriter.addClass( classes, element ); + } else { + for ( const className of classes ) { + viewWriter.addClass( className, element ); + } + } + } + + return element; +} + +function getFromAttributeCreator( config: any ): T { + if ( config.model.values ) { + return ( ( modelAttributeValue: any, conversionApi: DowncastConversionApi, data: any ) => { + const view = config.view[ modelAttributeValue ]; + + if ( view ) { + return view( modelAttributeValue, conversionApi, data ); + } + + return null; + } ) as any; + } else { + return config.view; + } +} + +// Takes the configuration, adds default parameters if they do not exist and normalizes other parameters to be used in downcast converters +// for generating a view attribute. +// +// @param {Object} view View configuration. +function normalizeToAttributeConfig( view: any ): AttributeCreatorFunction { + if ( typeof view == 'string' ) { + return modelAttributeValue => ( { key: view, value: modelAttributeValue as string } ); + } else if ( typeof view == 'object' ) { + // { key, value, ... } + if ( view.value ) { + return () => view; + } + // { key, ... } + else { + return modelAttributeValue => ( { key: view.key, value: modelAttributeValue as string } ); + } + } else { + // function. + return view; + } +} + +// Helper function for `highlight`. Prepares the actual descriptor object using value passed to the converter. +function prepareDescriptor( + highlightDescriptor: HighlightDescriptor | HighlightDescriptorCreatorFunction, + data: { + markerName: string; + markerRange: ModelRange; + }, + conversionApi: DowncastConversionApi +): HighlightDescriptor | null { + // If passed descriptor is a creator function, call it. If not, just use passed value. + const descriptor = typeof highlightDescriptor == 'function' ? + highlightDescriptor( data, conversionApi ) : + highlightDescriptor; + + if ( !descriptor ) { + return null; + } + + // Apply default descriptor priority. + if ( !descriptor.priority ) { + descriptor.priority = 10; + } + + // Default descriptor id is marker name. + if ( !descriptor.id ) { + descriptor.id = data.markerName; + } + + return descriptor; +} + +// Creates a function that checks a single differ diff item whether it should trigger reconversion. +// +// @param {Object} model A normalized `config.model` converter configuration. +// @param {String} model.name The name of element. +// @param {Array.} model.attributes The list of attribute names that should trigger reconversion. +// @param {Boolean} [model.children] Whether the child list change should trigger reconversion. +// @returns {Function} +function createChangeReducerCallback( model: NormalizedModelElementConfig ) { + return ( node: ModelNode, change: DiffItem | DiffItemReinsert ): boolean => { + if ( !node.is( 'element', model.name ) ) { + return false; + } + + if ( change.type == 'attribute' ) { + if ( model.attributes.includes( change.attributeKey ) ) { + return true; + } + } else { + /* istanbul ignore else: This is always true because otherwise it would not register a reducer callback. */ + if ( model.children ) { + return true; + } + } + + return false; + }; +} + +// Creates a `reduceChanges` event handler for reconversion. +// +// @param {Object} model A normalized `config.model` converter configuration. +// @param {String} model.name The name of element. +// @param {Array.} model.attributes The list of attribute names that should trigger reconversion. +// @param {Boolean} [model.children] Whether the child list change should trigger reconversion. +// @returns {Function} +function createChangeReducer( model: NormalizedModelElementConfig ) { + const shouldReplace = createChangeReducerCallback( model ); + + return ( + evt: unknown, + data: { changes: Iterable; reconvertedElements?: Set } + ) => { + const reducedChanges: ( DiffItem | DiffItemReinsert )[] = []; + + if ( !data.reconvertedElements ) { + data.reconvertedElements = new Set(); + } + + for ( const change of data.changes ) { + // For attribute use node affected by the change. + // For insert or remove use parent element because we need to check if it's added/removed child. + const node = change.type == 'attribute' ? change.range.start.nodeAfter : change.position.parent as ModelNode; + + if ( !node || !shouldReplace( node, change ) ) { + reducedChanges.push( change ); + + continue; + } + + // If it's already marked for reconversion, so skip this change, otherwise add the diff items. + if ( !data.reconvertedElements.has( node ) ) { + data.reconvertedElements.add( node ); + + const position = ModelPosition._createBefore( node ); + + reducedChanges.push( { + type: 'remove', + name: ( node as ModelElement ).name, + position, + length: 1 + } as any, { + type: 'reinsert', + name: ( node as ModelElement ).name, + position, + length: 1 + } ); + } + } + + data.changes = reducedChanges; + }; +} + +// Creates a function that checks if an element and its watched attributes can be consumed and consumes them. +// +// @param {Object} model A normalized `config.model` converter configuration. +// @param {String} model.name The name of element. +// @param {Array.} model.attributes The list of attribute names that should trigger reconversion. +// @param {Boolean} [model.children] Whether the child list change should trigger reconversion. +// @returns {module:engine/conversion/downcasthelpers~ConsumerFunction} +function createConsumer( model: NormalizedModelElementConfig ): ConsumerFunction { + return ( node, consumable, options = {} ) => { + const events = [ 'insert' ]; + + // Collect all set attributes that are triggering conversion. + for ( const attributeName of model.attributes ) { + if ( node.hasAttribute( attributeName ) ) { + events.push( `attribute:${ attributeName }` ); + } + } + + if ( !events.every( event => consumable.test( node, event ) ) ) { + return false; + } + + if ( !options.preflight ) { + events.forEach( event => consumable.consume( node, event ) ); + } + + return true; + }; +} + +// Creates a function that create view slots. +// +// @param {module:engine/model/element~Element} element +// @param {Map.>} slotsMap +// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi +// @returns {Function} Exposed by writer as createSlot(). +function createSlotFactory( element: ModelElement, slotsMap: Map, conversionApi: DowncastConversionApi ) { + return ( writer: DowncastWriter, modeOrFilter: string | SlotFilter = 'children' ) => { + const slot = writer.createContainerElement( '$slot' ); + + let children: ModelNode[] | null = null; + + if ( modeOrFilter === 'children' ) { + children = Array.from( element.getChildren() ); + } else if ( typeof modeOrFilter == 'function' ) { + children = Array.from( element.getChildren() ).filter( element => modeOrFilter( element ) ); + } else { + /** + * Unknown slot mode was provided to `writer.createSlot()` in downcast converter. + * + * @error conversion-slot-mode-unknown + */ + throw new CKEditorError( 'conversion-slot-mode-unknown', conversionApi.dispatcher, { modeOrFilter } ); + } + + slotsMap.set( slot, children ); + + return slot; + }; +} + +// Checks if all children are covered by slots and there is no child that landed in multiple slots. +// +// @param {module:engine/model/element~Element} +// @param {Map.>} slotsMap +// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi +function validateSlotsChildren( + element: ModelElement, + slotsMap: Map, + conversionApi: DowncastConversionApi +) { + const childrenInSlots = Array.from( slotsMap.values() ).flat(); + const uniqueChildrenInSlots = new Set( childrenInSlots ); + + if ( uniqueChildrenInSlots.size != childrenInSlots.length ) { + /** + * Filters provided to `writer.createSlot()` overlap (at least two filters accept the same child element). + * + * @error conversion-slot-filter-overlap + * @param {module:engine/model/element~Element} element The element of which children would not be properly + * allocated to multiple slots. + */ + throw new CKEditorError( 'conversion-slot-filter-overlap', conversionApi.dispatcher, { element } ); + } + + if ( uniqueChildrenInSlots.size != element.childCount ) { + /** + * Filters provided to `writer.createSlot()` are incomplete and exclude at least one children element (one of + * the children elements would not be assigned to any of the slots). + * + * @error conversion-slot-filter-incomplete + * @param {module:engine/model/element~Element} element The element of which children would not be properly + * allocated to multiple slots. + */ + throw new CKEditorError( 'conversion-slot-filter-incomplete', conversionApi.dispatcher, { element } ); + } +} + +// Fill slots with appropriate view elements. +// +// @param {module:engine/view/element~Element} viewElement +// @param {Map.>} slotsMap +// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi +// @param {Object} options +// @param {Boolean} [options.reconversion] +function fillSlots( + viewElement: ViewElement, + slotsMap: Map, + conversionApi: DowncastConversionApi, + options: { reconversion?: boolean } +): void { + // Set temporary position mapping to redirect child view elements into a proper slots. + conversionApi.mapper.on( 'modelToViewPosition', toViewPositionMapping, { priority: 'highest' } ); + + let currentSlot: ViewElement | null = null; + let currentSlotNodes: ModelNode[] | null = null; + + // Fill slots with nested view nodes. + for ( [ currentSlot, currentSlotNodes ] of slotsMap ) { + reinsertOrConvertNodes( viewElement, currentSlotNodes, conversionApi, options ); + + conversionApi.writer.move( + conversionApi.writer.createRangeIn( currentSlot ), + conversionApi.writer.createPositionBefore( currentSlot ) + ); + conversionApi.writer.remove( currentSlot ); + } + + conversionApi.mapper.off( 'modelToViewPosition', toViewPositionMapping ); + + function toViewPositionMapping( evt: unknown, data: { + mapper: Mapper; + modelPosition: ModelPosition; + viewPosition?: ViewPosition; + isPhantom?: boolean; + } ) { + const element = data.modelPosition.nodeAfter!; + + // Find the proper offset within the slot. + const index = currentSlotNodes!.indexOf( element ); + + if ( index < 0 ) { + return; + } + + data.viewPosition = data.mapper.findPositionIn( currentSlot!, index ); + } +} + +// Inserts view representation of `nodes` into the `viewElement` either by bringing back just removed view nodes +// or by triggering conversion for them. +// +// @param {module:engine/view/element~Element} viewElement +// @param {Iterable.} modelNodes +// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi +// @param {Object} options +// @param {Boolean} [options.reconversion] +function reinsertOrConvertNodes( + viewElement: ViewElement, + modelNodes: Iterable, + conversionApi: DowncastConversionApi, + options: { reconversion?: boolean } +) { + // Fill with nested view nodes. + for ( const modelChildNode of modelNodes ) { + // Try reinserting the view node for the specified model node... + if ( !reinsertNode( viewElement.root, modelChildNode, conversionApi, options ) ) { + // ...or else convert the model element to the view. + conversionApi.convertItem( modelChildNode ); + } + } +} + +// Checks if the view for the given model element could be reused and reinserts it to the view. +// +// @param {module:engine/view/node~Node|module:engine/view/documentfragment~DocumentFragment} viewRoot +// @param {module:engine/model/element~Element} modelElement +// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi +// @param {Object} options +// @param {Boolean} [options.reconversion] +// @returns {Boolean} `false` if view element can't be reused. +function reinsertNode( + viewRoot: ViewElement | ViewDocumentFragment, + modelNode: ModelNode, + conversionApi: DowncastConversionApi, + options: { reconversion?: boolean } +): boolean { + const { writer, mapper } = conversionApi; + + // Don't reinsert if this is not a reconversion... + if ( !options.reconversion ) { + return false; + } + + const viewChildNode = mapper.toViewElement( modelNode as ModelElement ); + + // ...or there is no view to reinsert or it was already inserted to the view structure... + if ( !viewChildNode || viewChildNode.root == viewRoot ) { + return false; + } + + // ...or it was strictly marked as not to be reused. + if ( !conversionApi.canReuseView( viewChildNode ) ) { + return false; + } + + // Otherwise reinsert the view node. + writer.move( + writer.createRangeOn( viewChildNode ), + mapper.toViewPosition( ModelPosition._createBefore( modelNode ) ) + ); + + return true; +} + +// The default consumer for insert events. +// @param {module:engine/model/item~Item} item Model item. +// @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable The model consumable. +// @param {Object} [options] +// @param {Boolean} [options.preflight=false] Whether should consume or just check if can be consumed. +// @returns {Boolean} +function defaultConsumer( + item: ModelItem, + consumable: ModelConsumable, + { preflight }: { preflight?: boolean } = {} +): boolean | null { + if ( preflight ) { + return consumable.test( item, 'insert' ); + } else { + return consumable.consume( item, 'insert' ); + } +} + +/** + * An object describing how the marker highlight should be represented in the view. + * + * Each text node contained in a highlighted range will be wrapped in a `` + * {@link module:engine/view/attributeelement~AttributeElement view attribute element} with CSS class(es), attributes and a priority + * described by this object. + * + * Additionally, each {@link module:engine/view/containerelement~ContainerElement container element} can handle displaying the highlight + * separately by providing the `addHighlight` and `removeHighlight` custom properties. In this case: + * + * * The `HighlightDescriptor` object is passed to the `addHighlight` function upon conversion and should be used to apply the highlight to + * the element. + * * The descriptor `id` is passed to the `removeHighlight` function upon conversion and should be used to remove the highlight with the + * given ID from the element. + * + * @typedef {Object} module:engine/conversion/downcasthelpers~HighlightDescriptor + * + * @property {String|Array.} classes A CSS class or an array of classes to set. If the descriptor is used to + * create an {@link module:engine/view/attributeelement~AttributeElement attribute element} over text nodes, these classes will be set + * on that attribute element. If the descriptor is applied to an element, usually these classes will be set on that element, however, + * this depends on how the element converts the descriptor. + * + * @property {String} [id] Descriptor identifier. If not provided, it defaults to the converted marker's name. + * + * @property {Number} [priority] Descriptor priority. If not provided, it defaults to `10`. If the descriptor is used to create + * an {@link module:engine/view/attributeelement~AttributeElement attribute element}, it will be that element's + * {@link module:engine/view/attributeelement~AttributeElement#priority priority}. If the descriptor is applied to an element, + * the priority will be used to determine which descriptor is more important. + * + * @property {Object} [attributes] Attributes to set. If the descriptor is used to create + * an {@link module:engine/view/attributeelement~AttributeElement attribute element} over text nodes, these attributes will be set on that + * attribute element. If the descriptor is applied to an element, usually these attributes will be set on that element, however, + * this depends on how the element converts the descriptor. + */ +export interface HighlightDescriptor { + classes: string | string[]; + id?: string; + priority?: number; + attributes?: Record; +} + +/** + * A filtering function used to choose model child nodes to be downcasted into the specific view + * {@link module:engine/view/downcastwriter~DowncastWriter#createSlot "slot"} while executing the + * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToStructure `elementToStructure()`} converter. + * + * @callback module:engine/conversion/downcasthelpers~SlotFilter + * + * @param {module:engine/model/node~Node} node A model node. + * @returns {Boolean} Whether the provided model node should be downcasted into this slot. + * + * @see module:engine/view/downcastwriter~DowncastWriter#createSlot + * @see module:engine/conversion/downcasthelpers~DowncastHelpers#elementToStructure + * @see module:engine/conversion/downcasthelpers~insertStructure + */ +export type SlotFilter = ( node: ModelNode ) => boolean; + +/** + * A view element creator function that takes the model element and {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi + * downcast conversion API} as parameters and returns a view container element. + * + * @callback module:engine/conversion/downcasthelpers~ElementCreatorFunction + * @param {module:engine/model/element~Element} element The model element to be converted to the view structure. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion interface. + * @param {Object} data Additional information about the change (same as for + * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert `insert`} event). + * @param {module:engine/model/item~Item} data.item Inserted item. + * @param {module:engine/model/range~Range} data.range Range spanning over inserted item. + * @returns {module:engine/view/element~Element} The view element. + * + * @see module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement + * @see module:engine/conversion/downcasthelpers~insertElement + */ +export type ElementCreatorFunction = ( + element: ModelElement, + conversionApi: DowncastConversionApi, + data: { + item: ModelItem; + range: ModelRange; + } +) => ViewElement | null; + +/** + * A function that takes the model element and {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast + * conversion API} as parameters and returns a view container element with slots for model child nodes to be converted into. + * + * @callback module:engine/conversion/downcasthelpers~StructureCreatorFunction + * @param {module:engine/model/element~Element} element The model element to be converted to the view structure. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion interface. + * @param {Object} data Additional information about the change (same as for + * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert `insert`} event). + * @param {module:engine/model/item~Item} data.item Inserted item. + * @param {module:engine/model/range~Range} data.range Range spanning over inserted item. + * @returns {module:engine/view/element~Element} The view structure with slots for model child nodes. + * + * @see module:engine/conversion/downcasthelpers~DowncastHelpers#elementToStructure + * @see module:engine/conversion/downcasthelpers~insertStructure + */ +export type StructureCreatorFunction = ElementCreatorFunction; + +/** + * A view element creator function that takes the model attribute value and + * {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API} as parameters and returns a view + * attribute element. + * + * @callback module:engine/conversion/downcasthelpers~AttributeElementCreatorFunction + * @param {*} attributeValue The model attribute value to be converted to the view attribute element. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion interface. + * @param {Object} data Additional information about the change (same as for + * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute `attribute`} event). + * @param {module:engine/model/item~Item|module:engine/model/documentselection~DocumentSelection} data.item Changed item + * or converted selection. + * @param {module:engine/model/range~Range} data.range Range spanning over changed item or selection range. + * @param {String} data.attributeKey Attribute key. + * @param {*} data.attributeOldValue Attribute value before the change. This is `null` when selection attribute is converted. + * @param {*} data.attributeNewValue New attribute value. + * @returns {module:engine/view/attributeelement~AttributeElement} The view attribute element. + * + * @see module:engine/conversion/downcasthelpers~DowncastHelpers#attributeToElement + * @see module:engine/conversion/downcasthelpers~wrap + */ +export type AttributeElementCreatorFunction = ( + attributeValue: any, + conversionApi: DowncastConversionApi, + data: { + item: ModelItem | ModelSelection | ModelDocumentSelection; + range: ModelRange; + attributeKey: string; + attributeOldValue: unknown; + attributeNewValue: unknown; + } +) => ViewAttributeElement | null; + +/** + * A function that takes the model attribute value and + * {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API} + * as parameters. + * + * @callback module:engine/conversion/downcasthelpers~AttributeCreatorFunction + * @param {*} attributeValue The model attribute value to be converted to the view attribute element. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion interface. + * @param {Object} data Additional information about the change (same as for + * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute `attribute`} event). + * @param {module:engine/model/item~Item|module:engine/model/documentselection~DocumentSelection} data.item Changed item + * or converted selection. + * @param {module:engine/model/range~Range} data.range Range spanning over changed item or selection range. + * @param {String} data.attributeKey Attribute key. + * @param {*} data.attributeOldValue Attribute value before the change. This is `null` when selection attribute is converted. + * @param {*} data.attributeNewValue New attribute value. + * @returns {Object|null} A `{ key, value }` object. If `key` is `'class'`, `value` can be a `String` or an + * array of `String`s. If `key` is `'style'`, `value` is an object with key-value pairs. In other cases, `value` is a `String`. + * + * @see module:engine/conversion/downcasthelpers~DowncastHelpers#attributeToAttribute + */ +export type AttributeCreatorFunction = ( + attributeValue: unknown, + conversionApi: DowncastConversionApi, + data: { + item: ModelItem; + range: ModelRange; + attributeKey: string; + attributeOldValue: unknown; + attributeNewValue: unknown; + } +) => AttributeDescriptor | null; + +export type AttributeDescriptor = { + key: 'class'; + value: string | string[]; +} | { + key: 'style'; + value: Record; +} | { + key: Exclude; + value: string; +}; + +export type MarkerElementCreatorFunction = ( + data: { + markerRange: ModelRange; + markerName: string; + isOpening?: boolean; + }, + conversionApi: DowncastConversionApi +) => UIElement | null; + +export type HighlightDescriptorCreatorFunction = ( + data: { + markerRange: ModelRange; + markerName: string; + }, + conversionApi: DowncastConversionApi +) => HighlightDescriptor; + +export type AddHighlightCallback = ( + viewElement: ViewElement, + descriptor: HighlightDescriptor, + writer: DowncastWriter +) => void; + +export type RemoveHighlightCallback = ( + viewElement: ViewElement, + id: string, + writer: DowncastWriter +) => void; + +export type MarkerDataCreatorFunction = ( + markerName: string, + conversionApi: DowncastConversionApi +) => { name: string; group: string } | null; + +/** + * A function that is expected to consume all the consumables that were used by the element creator. + * + * @callback module:engine/conversion/downcasthelpers~ConsumerFunction + * @param {module:engine/model/element~Element} element The model element to be converted to the view structure. + * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable The `ModelConsumable` same as in + * {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi#consumable `DowncastConversionApi.consumable`}. + * @param {Object} [options] + * @param {Boolean} [options.preflight=false] Whether should consume or just check if can be consumed. + * @returns {Boolean} `true` if all consumable values were available and were consumed, `false` otherwise. + * + * @see module:engine/conversion/downcasthelpers~insertStructure + */ +export type ConsumerFunction = ( + element: ModelElement, + consumable: ModelConsumable, + options?: { preflight?: boolean } +) => boolean | null; diff --git a/packages/ckeditor5-engine/src/conversion/mapper.ts b/packages/ckeditor5-engine/src/conversion/mapper.ts new file mode 100644 index 00000000000..b3fbfd094a5 --- /dev/null +++ b/packages/ckeditor5-engine/src/conversion/mapper.ts @@ -0,0 +1,793 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module engine/conversion/mapper + */ + +import ModelPosition from '../model/position'; +import ModelRange from '../model/range'; + +import ViewPosition from '../view/position'; +import ViewRange from '../view/range'; +import ViewText from '../view/text'; + +import { Emitter } from '@ckeditor/ckeditor5-utils/src/emittermixin'; +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; + +import type ViewDocumentFragment from '../view/documentfragment'; +import type ViewElement from '../view/element'; +import type ModelElement from '../model/element'; +import type ModelDocumentFragment from '../model/documentfragment'; +import type ViewNode from '../view/node'; + +/** + * Maps elements, positions and markers between the {@link module:engine/view/document~Document view} and + * the {@link module:engine/model/model model}. + * + * The instance of the Mapper used for the editing pipeline is available in + * {@link module:engine/controller/editingcontroller~EditingController#mapper `editor.editing.mapper`}. + * + * Mapper uses bound elements to find corresponding elements and positions, so, to get proper results, + * all model elements should be {@link module:engine/conversion/mapper~Mapper#bindElements bound}. + * + * To map the complex model to/from view relations, you may provide custom callbacks for the + * {@link module:engine/conversion/mapper~Mapper#event:modelToViewPosition modelToViewPosition event} and + * {@link module:engine/conversion/mapper~Mapper#event:viewToModelPosition viewToModelPosition event} that are fired whenever + * a position mapping request occurs. + * Those events are fired by the {@link module:engine/conversion/mapper~Mapper#toViewPosition toViewPosition} + * and {@link module:engine/conversion/mapper~Mapper#toModelPosition toModelPosition} methods. `Mapper` adds its own default callbacks + * with `'lowest'` priority. To override default `Mapper` mapping, add custom callback with higher priority and + * stop the event. + * @mixes module:utils/emittermixin~EmitterMixin + */ +export default class Mapper extends Emitter { + private _modelToViewMapping: WeakMap; + private _viewToModelMapping: WeakMap; + private _viewToModelLengthCallbacks: Map number>; + private _markerNameToElements: Map>; + private _elementToMarkerNames: Map>; + private _deferredBindingRemovals: Map; + private _unboundMarkerNames: Set; + + /** + * Creates an instance of the mapper. + */ + constructor() { + super(); + + /** + * Model element to view element mapping. + * + * @private + * @member {WeakMap} + */ + this._modelToViewMapping = new WeakMap(); + + /** + * View element to model element mapping. + * + * @private + * @member {WeakMap} + */ + this._viewToModelMapping = new WeakMap(); + + /** + * A map containing callbacks between view element names and functions evaluating length of view elements + * in model. + * + * @private + * @member {Map} + */ + this._viewToModelLengthCallbacks = new Map(); + + /** + * Model marker name to view elements mapping. + * + * Keys are `String`s while values are `Set`s with {@link module:engine/view/element~Element view elements}. + * One marker (name) can be mapped to multiple elements. + * + * @private + * @member {Map} + */ + this._markerNameToElements = new Map(); + + /** + * View element to model marker names mapping. + * + * This is reverse to {@link ~Mapper#_markerNameToElements} map. + * + * @private + * @member {Map} + */ + this._elementToMarkerNames = new Map(); + + /** + * The map of removed view elements with their current root (used for deferred unbinding). + * + * @private + * @member {Map.} + */ + this._deferredBindingRemovals = new Map(); + + /** + * Stores marker names of markers which have changed due to unbinding a view element (so it is assumed that the view element + * has been removed, moved or renamed). + * + * @private + * @member {Set.} + */ + this._unboundMarkerNames = new Set(); + + // Default mapper algorithm for mapping model position to view position. + this.on( 'modelToViewPosition', ( evt, data ) => { + if ( data.viewPosition ) { + return; + } + + const viewContainer = this._modelToViewMapping.get( data.modelPosition.parent as ModelElement ); + + if ( !viewContainer ) { + /** + * A model position could not be mapped to the view because the parent of the model position + * does not have a mapped view element (might have not been converted yet or it has no converter). + * + * Make sure that the model element is correctly converted to the view. + * + * @error mapping-model-position-view-parent-not-found + */ + throw new CKEditorError( 'mapping-model-position-view-parent-not-found', this, { modelPosition: data.modelPosition } ); + } + + data.viewPosition = this.findPositionIn( viewContainer, data.modelPosition.offset ); + }, { priority: 'low' } ); + + // Default mapper algorithm for mapping view position to model position. + this.on( 'viewToModelPosition', ( evt, data ) => { + if ( data.modelPosition ) { + return; + } + + const viewBlock = this.findMappedViewAncestor( data.viewPosition ); + const modelParent = this._viewToModelMapping.get( viewBlock ); + const modelOffset = this._toModelOffset( data.viewPosition.parent as ViewElement, data.viewPosition.offset, viewBlock ); + + data.modelPosition = ModelPosition._createAt( modelParent!, modelOffset ); + }, { priority: 'low' } ); + } + + /** + * Marks model and view elements as corresponding. Corresponding elements can be retrieved by using + * the {@link module:engine/conversion/mapper~Mapper#toModelElement toModelElement} and + * {@link module:engine/conversion/mapper~Mapper#toViewElement toViewElement} methods. + * The information that elements are bound is also used to translate positions. + * + * @param {module:engine/model/element~Element} modelElement Model element. + * @param {module:engine/view/element~Element} viewElement View element. + */ + public bindElements( + modelElement: ModelElement | ModelDocumentFragment, + viewElement: ViewElement | ViewDocumentFragment + ): void { + this._modelToViewMapping.set( modelElement, viewElement ); + this._viewToModelMapping.set( viewElement, modelElement ); + } + + /** + * Unbinds the given {@link module:engine/view/element~Element view element} from the map. + * + * **Note:** view-to-model binding will be removed, if it existed. However, corresponding model-to-view binding + * will be removed only if model element is still bound to the passed `viewElement`. + * + * This behavior allows for re-binding model element to another view element without fear of losing the new binding + * when the previously bound view element is unbound. + * + * @param {module:engine/view/element~Element} viewElement View element to unbind. + * @param {Object} [options={}] The options object. + * @param {Boolean} [options.defer=false] Controls whether the binding should be removed immediately or deferred until a + * {@link #flushDeferredBindings `flushDeferredBindings()`} call. + */ + public unbindViewElement( + viewElement: ViewElement, + options: { defer?: boolean } = {} + ): void { + const modelElement = this.toModelElement( viewElement )!; + + if ( this._elementToMarkerNames.has( viewElement ) ) { + for ( const markerName of this._elementToMarkerNames.get( viewElement )! ) { + this._unboundMarkerNames.add( markerName ); + } + } + + if ( options.defer ) { + this._deferredBindingRemovals.set( viewElement, viewElement.root ); + } else { + this._viewToModelMapping.delete( viewElement ); + + if ( this._modelToViewMapping.get( modelElement ) == viewElement ) { + this._modelToViewMapping.delete( modelElement ); + } + } + } + + /** + * Unbinds the given {@link module:engine/model/element~Element model element} from the map. + * + * **Note:** the model-to-view binding will be removed, if it existed. However, the corresponding view-to-model binding + * will be removed only if the view element is still bound to the passed `modelElement`. + * + * This behavior lets for re-binding view element to another model element without fear of losing the new binding + * when the previously bound model element is unbound. + * + * @param {module:engine/model/element~Element} modelElement Model element to unbind. + */ + public unbindModelElement( modelElement: ModelElement ): void { + const viewElement = this.toViewElement( modelElement )!; + + this._modelToViewMapping.delete( modelElement ); + + if ( this._viewToModelMapping.get( viewElement ) == modelElement ) { + this._viewToModelMapping.delete( viewElement ); + } + } + + /** + * Binds the given marker name with the given {@link module:engine/view/element~Element view element}. The element + * will be added to the current set of elements bound with the given marker name. + * + * @param {module:engine/view/element~Element} element Element to bind. + * @param {String} name Marker name. + */ + public bindElementToMarker( element: ViewElement, name: string ): void { + const elements = this._markerNameToElements.get( name ) || new Set(); + elements.add( element ); + + const names = this._elementToMarkerNames.get( element ) || new Set(); + names.add( name ); + + this._markerNameToElements.set( name, elements ); + this._elementToMarkerNames.set( element, names ); + } + + /** + * Unbinds an element from given marker name. + * + * @param {module:engine/view/element~Element} element Element to unbind. + * @param {String} name Marker name. + */ + public unbindElementFromMarkerName( element: ViewElement, name: string ): void { + const nameToElements = this._markerNameToElements.get( name ); + + if ( nameToElements ) { + nameToElements.delete( element ); + + if ( nameToElements.size == 0 ) { + this._markerNameToElements.delete( name ); + } + } + + const elementToNames = this._elementToMarkerNames.get( element ); + + if ( elementToNames ) { + elementToNames.delete( name ); + + if ( elementToNames.size == 0 ) { + this._elementToMarkerNames.delete( element ); + } + } + } + + /** + * Returns all marker names of markers which have changed due to unbinding a view element (so it is assumed that the view element + * has been removed, moved or renamed) since the last flush. After returning, the marker names list is cleared. + * + * @returns {Array.} + */ + public flushUnboundMarkerNames(): string[] { + const markerNames = Array.from( this._unboundMarkerNames ); + + this._unboundMarkerNames.clear(); + + return markerNames; + } + + /** + * Unbinds all deferred binding removals of view elements that in the meantime were not re-attached to some root or document fragment. + * + * See: {@link #unbindViewElement `unbindViewElement()`}. + */ + public flushDeferredBindings(): void { + for ( const [ viewElement, root ] of this._deferredBindingRemovals ) { + // Unbind it only if it wasn't re-attached to some root or document fragment. + if ( viewElement.root == root ) { + this.unbindViewElement( viewElement ); + } + } + + this._deferredBindingRemovals = new Map(); + } + + /** + * Removes all model to view and view to model bindings. + */ + public clearBindings(): void { + this._modelToViewMapping = new WeakMap(); + this._viewToModelMapping = new WeakMap(); + this._markerNameToElements = new Map(); + this._elementToMarkerNames = new Map(); + this._unboundMarkerNames = new Set(); + this._deferredBindingRemovals = new Map(); + } + + /** + * Gets the corresponding model element. + * + * **Note:** {@link module:engine/view/uielement~UIElement} does not have corresponding element in model. + * + * @param {module:engine/view/element~Element} viewElement View element. + * @returns {module:engine/model/element~Element|undefined} Corresponding model element or `undefined` if not found. + */ + public toModelElement( viewElement: ViewElement ): ModelElement | undefined; + public toModelElement( viewDocumentFragment: ViewDocumentFragment ): ModelDocumentFragment | undefined; + public toModelElement( viewElement: ViewElement | ViewDocumentFragment ): ModelElement | ModelDocumentFragment | undefined { + return this._viewToModelMapping.get( viewElement ); + } + + /** + * Gets the corresponding view element. + * + * @param {module:engine/model/element~Element} modelElement Model element. + * @returns {module:engine/view/element~Element|undefined} Corresponding view element or `undefined` if not found. + */ + public toViewElement( modelElement: ModelElement ): ViewElement | undefined; + public toViewElement( modelDocumentFragment: ModelDocumentFragment ): ViewDocumentFragment | undefined; + public toViewElement( modelElement: ModelElement | ModelDocumentFragment ): ViewElement | ViewDocumentFragment | undefined { + return this._modelToViewMapping.get( modelElement ); + } + + /** + * Gets the corresponding model range. + * + * @param {module:engine/view/range~Range} viewRange View range. + * @returns {module:engine/model/range~Range} Corresponding model range. + */ + public toModelRange( viewRange: ViewRange ): ModelRange { + return new ModelRange( this.toModelPosition( viewRange.start ), this.toModelPosition( viewRange.end ) ); + } + + /** + * Gets the corresponding view range. + * + * @param {module:engine/model/range~Range} modelRange Model range. + * @returns {module:engine/view/range~Range} Corresponding view range. + */ + public toViewRange( modelRange: ModelRange ): ViewRange { + return new ViewRange( this.toViewPosition( modelRange.start ), this.toViewPosition( modelRange.end ) ); + } + + /** + * Gets the corresponding model position. + * + * @fires viewToModelPosition + * @param {module:engine/view/position~Position} viewPosition View position. + * @returns {module:engine/model/position~Position} Corresponding model position. + */ + public toModelPosition( viewPosition: ViewPosition ): ModelPosition { + const data: ViewToModelPositionEvent[ 'args' ][ 0 ] = { + viewPosition, + mapper: this + }; + + this.fire( 'viewToModelPosition', data ); + + return data.modelPosition!; + } + + /** + * Gets the corresponding view position. + * + * @fires modelToViewPosition + * @param {module:engine/model/position~Position} modelPosition Model position. + * @param {Object} [options] Additional options for position mapping process. + * @param {Boolean} [options.isPhantom=false] Should be set to `true` if the model position to map is pointing to a place + * in model tree which no longer exists. For example, it could be an end of a removed model range. + * @returns {module:engine/view/position~Position} Corresponding view position. + */ + public toViewPosition( + modelPosition: ModelPosition, + options: { isPhantom?: boolean } = {} + ): ViewPosition { + const data: ModelToViewPositionEvent[ 'args' ][ 0 ] = { + modelPosition, + mapper: this, + isPhantom: options.isPhantom + }; + + this.fire( 'modelToViewPosition', data ); + + return data.viewPosition!; + } + + /** + * Gets all view elements bound to the given marker name. + * + * @param {String} name Marker name. + * @returns {Set.|null} View elements bound with the given marker name or `null` + * if no elements are bound to the given marker name. + */ + public markerNameToElements( name: string ): Set | null { + const boundElements = this._markerNameToElements.get( name ); + + if ( !boundElements ) { + return null; + } + + const elements = new Set(); + + for ( const element of boundElements ) { + if ( element.is( 'attributeElement' ) ) { + for ( const clone of element.getElementsWithSameId() ) { + elements.add( clone ); + } + } else { + elements.add( element ); + } + } + + return elements; + } + + /** + * Registers a callback that evaluates the length in the model of a view element with the given name. + * + * The callback is fired with one argument, which is a view element instance. The callback is expected to return + * a number representing the length of the view element in the model. + * + * // List item in view may contain nested list, which have other list items. In model though, + * // the lists are represented by flat structure. Because of those differences, length of list view element + * // may be greater than one. In the callback it's checked how many nested list items are in evaluated list item. + * + * function getViewListItemLength( element ) { + * let length = 1; + * + * for ( let child of element.getChildren() ) { + * if ( child.name == 'ul' || child.name == 'ol' ) { + * for ( let item of child.getChildren() ) { + * length += getViewListItemLength( item ); + * } + * } + * } + * + * return length; + * } + * + * mapper.registerViewToModelLength( 'li', getViewListItemLength ); + * + * @param {String} viewElementName Name of view element for which callback is registered. + * @param {Function} lengthCallback Function return a length of view element instance in model. + */ + public registerViewToModelLength( + viewElementName: string, + lengthCallback: ( element: ViewElement ) => number + ): void { + this._viewToModelLengthCallbacks.set( viewElementName, lengthCallback ); + } + + /** + * For the given `viewPosition`, finds and returns the closest ancestor of this position that has a mapping to + * the model. + * + * @param {module:engine/view/position~Position} viewPosition Position for which a mapped ancestor should be found. + * @returns {module:engine/view/element~Element} + */ + public findMappedViewAncestor( viewPosition: ViewPosition ): ViewElement { + let parent: any = viewPosition.parent; + + while ( !this._viewToModelMapping.has( parent ) ) { + parent = parent.parent; + } + + return parent; + } + + /** + * Calculates model offset based on the view position and the block element. + * + * Example: + * + *

fooba|r

// _toModelOffset( b, 2, p ) -> 5 + * + * Is a sum of: + * + *

foo|bar

// _toModelOffset( p, 3, p ) -> 3 + *

fooba|r

// _toModelOffset( b, 2, b ) -> 2 + * + * @private + * @param {module:engine/view/element~Element} viewParent Position parent. + * @param {Number} viewOffset Position offset. + * @param {module:engine/view/element~Element} viewBlock Block used as a base to calculate offset. + * @returns {Number} Offset in the model. + */ + private _toModelOffset( + viewParent: ViewElement, + viewOffset: number, + viewBlock: ViewElement + ): number { + if ( viewBlock != viewParent ) { + // See example. + const offsetToParentStart = this._toModelOffset( viewParent.parent as any, viewParent.index!, viewBlock ); + const offsetInParent = this._toModelOffset( viewParent, viewOffset, viewParent ); + + return offsetToParentStart + offsetInParent; + } + + // viewBlock == viewParent, so we need to calculate the offset in the parent element. + + // If the position is a text it is simple ("ba|r" -> 2). + if ( viewParent.is( '$text' ) ) { + return viewOffset; + } + + // If the position is in an element we need to sum lengths of siblings ( bar foo | -> 3 + 3 = 6 ). + let modelOffset = 0; + + for ( let i = 0; i < viewOffset; i++ ) { + modelOffset += this.getModelLength( viewParent.getChild( i ) as any ); + } + + return modelOffset; + } + + /** + * Gets the length of the view element in the model. + * + * The length is calculated as follows: + * * if a {@link #registerViewToModelLength length mapping callback} is provided for the given `viewNode`, it is used to + * evaluate the model length (`viewNode` is used as first and only parameter passed to the callback), + * * length of a {@link module:engine/view/text~Text text node} is equal to the length of its + * {@link module:engine/view/text~Text#data data}, + * * length of a {@link module:engine/view/uielement~UIElement ui element} is equal to 0, + * * length of a mapped {@link module:engine/view/element~Element element} is equal to 1, + * * length of a non-mapped {@link module:engine/view/element~Element element} is equal to the length of its children. + * + * Examples: + * + * foo -> 3 // Text length is equal to its data length. + *

foo

-> 1 // Length of an element which is mapped is by default equal to 1. + * foo -> 3 // Length of an element which is not mapped is a length of its children. + *

x

y

-> 2 // Assuming that
is not mapped and

are mapped. + * + * @param {module:engine/view/element~Element} viewNode View node. + * @returns {Number} Length of the node in the tree model. + */ + public getModelLength( viewNode: ViewNode ): number { + if ( this._viewToModelLengthCallbacks.get( ( viewNode as any ).name ) ) { + const callback = this._viewToModelLengthCallbacks.get( ( viewNode as any ).name )!; + + return callback( viewNode as ViewElement ); + } else if ( this._viewToModelMapping.has( viewNode as ViewElement ) ) { + return 1; + } else if ( viewNode.is( '$text' ) ) { + return viewNode.data.length; + } else if ( viewNode.is( 'uiElement' ) ) { + return 0; + } else { + let len = 0; + + for ( const child of ( viewNode as ViewElement ).getChildren() ) { + len += this.getModelLength( child ); + } + + return len; + } + } + + /** + * Finds the position in the view node (or in its children) with the expected model offset. + * + * Example: + * + *

fobarbom

-> expected offset: 4 + * + * findPositionIn( p, 4 ): + *

|fobarbom

-> expected offset: 4, actual offset: 0 + *

fo|barbom

-> expected offset: 4, actual offset: 2 + *

fobar|bom

-> expected offset: 4, actual offset: 5 -> we are too far + * + * findPositionIn( b, 4 - ( 5 - 3 ) ): + *

fo|barbom

-> expected offset: 2, actual offset: 0 + *

fobar|bom

-> expected offset: 2, actual offset: 3 -> we are too far + * + * findPositionIn( bar, 2 - ( 3 - 3 ) ): + * We are in the text node so we can simple find the offset. + *

foba|rbom

-> expected offset: 2, actual offset: 2 -> position found + * + * @param {module:engine/view/element~Element} viewParent Tree view element in which we are looking for the position. + * @param {Number} expectedOffset Expected offset. + * @returns {module:engine/view/position~Position} Found position. + */ + public findPositionIn( viewParent: ViewNode | ViewDocumentFragment, expectedOffset: number ): ViewPosition { + // Last scanned view node. + let viewNode: ViewNode; + // Length of the last scanned view node. + let lastLength = 0; + + let modelOffset = 0; + let viewOffset = 0; + + // In the text node it is simple: the offset in the model equals the offset in the text. + if ( viewParent.is( '$text' ) ) { + return new ViewPosition( viewParent, expectedOffset ); + } + + // In other cases we add lengths of child nodes to find the proper offset. + + // If it is smaller we add the length. + while ( modelOffset < expectedOffset ) { + viewNode = ( viewParent as ViewElement | ViewDocumentFragment ).getChild( viewOffset )!; + lastLength = this.getModelLength( viewNode ); + modelOffset += lastLength; + viewOffset++; + } + + // If it equals we found the position. + if ( modelOffset == expectedOffset ) { + return this._moveViewPositionToTextNode( new ViewPosition( viewParent, viewOffset ) ); + } + // If it is higher we need to enter last child. + else { + // ( modelOffset - lastLength ) is the offset to the child we enter, + // so we subtract it from the expected offset to fine the offset in the child. + return this.findPositionIn( viewNode!, expectedOffset - ( modelOffset - lastLength ) ); + } + } + + /** + * Because we prefer positions in the text nodes over positions next to text nodes, if the view position was next to a text node, + * it moves it into the text node instead. + * + *

[]foo

->

[]foo

// do not touch if position is not directly next to text + *

foo[]foo

->

foo{}foo

// move to text node + *

[]foo

->

{}foo

// move to text node + * + * @private + * @param {module:engine/view/position~Position} viewPosition Position potentially next to the text node. + * @returns {module:engine/view/position~Position} Position in the text node if possible. + */ + private _moveViewPositionToTextNode( viewPosition: ViewPosition ): ViewPosition { + // If the position is just after a text node, put it at the end of that text node. + // If the position is just before a text node, put it at the beginning of that text node. + const nodeBefore = viewPosition.nodeBefore; + const nodeAfter = viewPosition.nodeAfter; + + if ( nodeBefore instanceof ViewText ) { + return new ViewPosition( nodeBefore, nodeBefore.data.length ); + } else if ( nodeAfter instanceof ViewText ) { + return new ViewPosition( nodeAfter, 0 ); + } + + // Otherwise, just return the given position. + return viewPosition; + } + + /** + * Fired for each model-to-view position mapping request. The purpose of this event is to enable custom model-to-view position + * mapping. Callbacks added to this event take {@link module:engine/model/position~Position model position} and are expected to + * calculate the {@link module:engine/view/position~Position view position}. The calculated view position should be added as + * a `viewPosition` value in the `data` object that is passed as one of parameters to the event callback. + * + * // Assume that "captionedImage" model element is converted to and following elements in view, + * // and the model element is bound to element. Force mapping model positions inside "captionedImage" to that + * // element. + * mapper.on( 'modelToViewPosition', ( evt, data ) => { + * const positionParent = modelPosition.parent; + * + * if ( positionParent.name == 'captionedImage' ) { + * const viewImg = data.mapper.toViewElement( positionParent ); + * const viewCaption = viewImg.nextSibling; // The element. + * + * data.viewPosition = new ViewPosition( viewCaption, modelPosition.offset ); + * + * // Stop the event if other callbacks should not modify calculated value. + * evt.stop(); + * } + * } ); + * + * **Note:** keep in mind that sometimes a "phantom" model position is being converted. A "phantom" model position is + * a position that points to a nonexistent place in model. Such a position might still be valid for conversion, though + * (it would point to a correct place in the view when converted). One example of such a situation is when a range is + * removed from the model, there may be a need to map the range's end (which is no longer a valid model position). To + * handle such situations, check the `data.isPhantom` flag: + * + * // Assume that there is a "customElement" model element and whenever the position is before it, + * // we want to move it to the inside of the view element bound to "customElement". + * mapper.on( 'modelToViewPosition', ( evt, data ) => { + * if ( data.isPhantom ) { + * return; + * } + * + * // Below line might crash for phantom position that does not exist in model. + * const sibling = data.modelPosition.nodeBefore; + * + * // Check if this is the element we are interested in. + * if ( !sibling.is( 'element', 'customElement' ) ) { + * return; + * } + * + * const viewElement = data.mapper.toViewElement( sibling ); + * + * data.viewPosition = new ViewPosition( sibling, 0 ); + * + * evt.stop(); + * } ); + * + * **Note:** the default mapping callback is provided with a `low` priority setting and does not cancel the event, so it is possible to + * attach a custom callback after a default callback and also use `data.viewPosition` calculated by the default callback + * (for example to fix it). + * + * **Note:** the default mapping callback will not fire if `data.viewPosition` is already set. + * + * **Note:** these callbacks are called **very often**. For efficiency reasons, it is advised to use them only when position + * mapping between the given model and view elements is unsolvable by using just elements mapping and default algorithm. + * Also, the condition that checks if a special case scenario happened should be as simple as possible. + * + * @event modelToViewPosition + * @param {Object} data Data pipeline object that can store and pass data between callbacks. The callback should add + * the `viewPosition` value to that object with calculated the {@link module:engine/view/position~Position view position}. + * @param {module:engine/conversion/mapper~Mapper} data.mapper Mapper instance that fired the event. + */ + + /** + * Fired for each view-to-model position mapping request. See {@link module:engine/conversion/mapper~Mapper#event:modelToViewPosition}. + * + * // See example in `modelToViewPosition` event description. + * // This custom mapping will map positions from element next to to the "captionedImage" element. + * mapper.on( 'viewToModelPosition', ( evt, data ) => { + * const positionParent = viewPosition.parent; + * + * if ( positionParent.hasClass( 'image-caption' ) ) { + * const viewImg = positionParent.previousSibling; + * const modelImg = data.mapper.toModelElement( viewImg ); + * + * data.modelPosition = new ModelPosition( modelImg, viewPosition.offset ); + * evt.stop(); + * } + * } ); + * + * **Note:** the default mapping callback is provided with a `low` priority setting and does not cancel the event, so it is possible to + * attach a custom callback after the default callback and also use `data.modelPosition` calculated by the default callback + * (for example to fix it). + * + * **Note:** the default mapping callback will not fire if `data.modelPosition` is already set. + * + * **Note:** these callbacks are called **very often**. For efficiency reasons, it is advised to use them only when position + * mapping between the given model and view elements is unsolvable by using just elements mapping and default algorithm. + * Also, the condition that checks if special case scenario happened should be as simple as possible. + * + * @event viewToModelPosition + * @param {Object} data Data pipeline object that can store and pass data between callbacks. The callback should add + * `modelPosition` value to that object with calculated {@link module:engine/model/position~Position model position}. + * @param {module:engine/conversion/mapper~Mapper} data.mapper Mapper instance that fired the event. + */ +} + +export type ModelToViewPositionEvent = { + name: 'modelToViewPosition'; + args: [ { + mapper: Mapper; + modelPosition: ModelPosition; + viewPosition?: ViewPosition; + isPhantom?: boolean; + } ]; +}; + +export type ViewToModelPositionEvent = { + name: 'viewToModelPosition'; + args: [ { + mapper: Mapper; + modelPosition?: ModelPosition; + viewPosition: ViewPosition; + } ]; +}; diff --git a/packages/ckeditor5-engine/src/conversion/modelconsumable.ts b/packages/ckeditor5-engine/src/conversion/modelconsumable.ts new file mode 100644 index 00000000000..b4ad617d99c --- /dev/null +++ b/packages/ckeditor5-engine/src/conversion/modelconsumable.ts @@ -0,0 +1,401 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module engine/conversion/modelconsumable + */ + +import TextProxy from '../model/textproxy'; +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; + +import type Item from '../model/item'; +import type Selection from '../model/selection'; +import type DocumentSelection from '../model/documentselection'; +import type Range from '../model/range'; + +/** + * Manages a list of consumable values for the {@link module:engine/model/item~Item model items}. + * + * Consumables are various aspects of the model. A model item can be broken down into separate, single properties that might be + * taken into consideration when converting that item. + * + * `ModelConsumable` is used by {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher} while analyzing the changed + * parts of {@link module:engine/model/document~Document the document}. The added / changed / removed model items are broken down + * into singular properties (the item itself and its attributes). All those parts are saved in `ModelConsumable`. Then, + * during conversion, when the given part of a model item is converted (i.e. the view element has been inserted into the view, + * but without attributes), the consumable value is removed from `ModelConsumable`. + * + * For model items, `ModelConsumable` stores consumable values of one of following types: `insert`, `addattribute:`, + * `changeattributes:`, `removeattributes:`. + * + * In most cases, it is enough to let th {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher} + * gather consumable values, so there is no need to use + * the {@link module:engine/conversion/modelconsumable~ModelConsumable#add add method} directly. + * However, it is important to understand how consumable values can be + * {@link module:engine/conversion/modelconsumable~ModelConsumable#consume consumed}. + * See {@link module:engine/conversion/downcasthelpers default downcast converters} for more information. + * + * Keep in mind that one conversion event may have multiple callbacks (converters) attached to it. Each of those is + * able to convert one or more parts of the model. However, when one of those callbacks actually converts + * something, the others should not, because they would duplicate the results. Using `ModelConsumable` helps to avoid + * this situation, because callbacks should only convert these values that were not yet consumed from `ModelConsumable`. + * + * Consuming multiple values in a single callback: + * + * // Converter for custom `imageBlock` element that might have a `caption` element inside which changes + * // how the image is displayed in the view: + * // + * // Model: + * // + * // [imageBlock] + * // └─ [caption] + * // └─ foo + * // + * // View: + * // + * //
+ * // ├─ + * // └─
+ * // └─ foo + * modelConversionDispatcher.on( 'insert:imageBlock', ( evt, data, conversionApi ) => { + * // First, consume the `imageBlock` element. + * conversionApi.consumable.consume( data.item, 'insert' ); + * + * // Just create normal image element for the view. + * // Maybe it will be "decorated" later. + * const viewImage = new ViewElement( 'img' ); + * const insertPosition = conversionApi.mapper.toViewPosition( data.range.start ); + * const viewWriter = conversionApi.writer; + * + * // Check if the `imageBlock` element has children. + * if ( data.item.childCount > 0 ) { + * const modelCaption = data.item.getChild( 0 ); + * + * // `modelCaption` insertion change is consumed from consumable values. + * // It will not be converted by other converters, but it's children (probably some text) will be. + * // Through mapping, converters for text will know where to insert contents of `modelCaption`. + * if ( conversionApi.consumable.consume( modelCaption, 'insert' ) ) { + * const viewCaption = new ViewElement( 'figcaption' ); + * + * const viewImageHolder = new ViewElement( 'figure', null, [ viewImage, viewCaption ] ); + * + * conversionApi.mapper.bindElements( modelCaption, viewCaption ); + * conversionApi.mapper.bindElements( data.item, viewImageHolder ); + * viewWriter.insert( insertPosition, viewImageHolder ); + * } + * } else { + * conversionApi.mapper.bindElements( data.item, viewImage ); + * viewWriter.insert( insertPosition, viewImage ); + * } + * + * evt.stop(); + * } ); + */ +export default class ModelConsumable { + private _consumable: Map>; + private _textProxyRegistry: Map>>; + + /** + * Creates an empty consumables list. + */ + constructor() { + /** + * Contains list of consumable values. + * + * @private + * @member {Map} module:engine/conversion/modelconsumable~ModelConsumable#_consumable + */ + this._consumable = new Map(); + + /** + * For each {@link module:engine/model/textproxy~TextProxy} added to `ModelConsumable`, this registry holds a parent + * of that `TextProxy` and the start and end indices of that `TextProxy`. This allows identification of the `TextProxy` + * instances that point to the same part of the model but are different instances. Each distinct `TextProxy` + * is given a unique `Symbol` which is then registered as consumable. This process is transparent for the `ModelConsumable` + * API user because whenever `TextProxy` is added, tested, consumed or reverted, the internal mechanisms of + * `ModelConsumable` translate `TextProxy` to that unique `Symbol`. + * + * @private + * @member {Map} module:engine/conversion/modelconsumable~ModelConsumable#_textProxyRegistry + */ + this._textProxyRegistry = new Map(); + } + + /** + * Adds a consumable value to the consumables list and links it with a given model item. + * + * modelConsumable.add( modelElement, 'insert' ); // Add `modelElement` insertion change to consumable values. + * modelConsumable.add( modelElement, 'addAttribute:bold' ); // Add `bold` attribute insertion on `modelElement` change. + * modelConsumable.add( modelElement, 'removeAttribute:bold' ); // Add `bold` attribute removal on `modelElement` change. + * modelConsumable.add( modelSelection, 'selection' ); // Add `modelSelection` to consumable values. + * modelConsumable.add( modelRange, 'range' ); // Add `modelRange` to consumable values. + * + * @param {module:engine/model/item~Item|module:engine/model/selection~Selection|module:engine/model/range~Range} item + * Model item, range or selection that has the consumable. + * @param {String} type Consumable type. Will be normalized to a proper form, that is either `` or `:`. + * Second colon and everything after will be cut. Passing event name is a safe and good practice. + */ + public add( + item: Item | Selection | DocumentSelection | Range, + type: string + ): void { + type = _normalizeConsumableType( type ); + + if ( item instanceof TextProxy ) { + item = this._getSymbolForTextProxy( item ) as any; + } + + if ( !this._consumable.has( item ) ) { + this._consumable.set( item, new Map() ); + } + + this._consumable.get( item )!.set( type, true ); + } + + /** + * Removes a given consumable value from a given model item. + * + * modelConsumable.consume( modelElement, 'insert' ); // Remove `modelElement` insertion change from consumable values. + * modelConsumable.consume( modelElement, 'addAttribute:bold' ); // Remove `bold` attribute insertion on `modelElement` change. + * modelConsumable.consume( modelElement, 'removeAttribute:bold' ); // Remove `bold` attribute removal on `modelElement` change. + * modelConsumable.consume( modelSelection, 'selection' ); // Remove `modelSelection` from consumable values. + * modelConsumable.consume( modelRange, 'range' ); // Remove 'modelRange' from consumable values. + * + * @param {module:engine/model/item~Item|module:engine/model/selection~Selection|module:engine/model/range~Range} item + * Model item, range or selection from which consumable will be consumed. + * @param {String} type Consumable type. Will be normalized to a proper form, that is either `` or `:`. + * Second colon and everything after will be cut. Passing event name is a safe and good practice. + * @returns {Boolean} `true` if consumable value was available and was consumed, `false` otherwise. + */ + public consume( + item: Item | Selection | DocumentSelection | Range, + type: string + ): boolean { + type = _normalizeConsumableType( type ); + + if ( item instanceof TextProxy ) { + item = this._getSymbolForTextProxy( item ) as any; + } + + if ( this.test( item, type ) ) { + this._consumable.get( item )!.set( type, false ); + + return true; + } else { + return false; + } + } + + /** + * Tests whether there is a consumable value of a given type connected with a given model item. + * + * modelConsumable.test( modelElement, 'insert' ); // Check for `modelElement` insertion change. + * modelConsumable.test( modelElement, 'addAttribute:bold' ); // Check for `bold` attribute insertion on `modelElement` change. + * modelConsumable.test( modelElement, 'removeAttribute:bold' ); // Check for `bold` attribute removal on `modelElement` change. + * modelConsumable.test( modelSelection, 'selection' ); // Check if `modelSelection` is consumable. + * modelConsumable.test( modelRange, 'range' ); // Check if `modelRange` is consumable. + * + * @param {module:engine/model/item~Item|module:engine/model/selection~Selection|module:engine/model/range~Range} item + * Model item, range or selection to be tested. + * @param {String} type Consumable type. Will be normalized to a proper form, that is either `` or `:`. + * Second colon and everything after will be cut. Passing event name is a safe and good practice. + * @returns {null|Boolean} `null` if such consumable was never added, `false` if the consumable values was + * already consumed or `true` if it was added and not consumed yet. + */ + public test( + item: Item | Selection | DocumentSelection | Range, + type: string + ): boolean | null { + type = _normalizeConsumableType( type ); + + if ( item instanceof TextProxy ) { + item = this._getSymbolForTextProxy( item ) as any; + } + + const itemConsumables = this._consumable.get( item ); + + if ( itemConsumables === undefined ) { + return null; + } + + const value = itemConsumables.get( type ); + + if ( value === undefined ) { + return null; + } + + return value; + } + + /** + * Reverts consuming of a consumable value. + * + * modelConsumable.revert( modelElement, 'insert' ); // Revert consuming `modelElement` insertion change. + * modelConsumable.revert( modelElement, 'addAttribute:bold' ); // Revert consuming `bold` attribute insert from `modelElement`. + * modelConsumable.revert( modelElement, 'removeAttribute:bold' ); // Revert consuming `bold` attribute remove from `modelElement`. + * modelConsumable.revert( modelSelection, 'selection' ); // Revert consuming `modelSelection`. + * modelConsumable.revert( modelRange, 'range' ); // Revert consuming `modelRange`. + * + * @param {module:engine/model/item~Item|module:engine/model/selection~Selection|module:engine/model/range~Range} item + * Model item, range or selection to be reverted. + * @param {String} type Consumable type. + * @returns {null|Boolean} `true` if consumable has been reversed, `false` otherwise. `null` if the consumable has + * never been added. + */ + public revert( + item: Item | Selection | DocumentSelection | Range, + type: string + ): boolean | null { + type = _normalizeConsumableType( type ); + + if ( item instanceof TextProxy ) { + item = this._getSymbolForTextProxy( item ) as any; + } + + const test = this.test( item, type ); + + if ( test === false ) { + this._consumable.get( item )!.set( type, true ); + + return true; + } else if ( test === true ) { + return false; + } + + return null; + } + + /** + * Verifies if all events from the specified group were consumed. + * + * @param {String} eventGroup The events group to verify. + */ + public verifyAllConsumed( eventGroup: string ): void { + const items = []; + + for ( const [ item, consumables ] of this._consumable ) { + for ( const [ event, canConsume ] of consumables ) { + const eventPrefix = event.split( ':' )[ 0 ]; + + if ( canConsume && eventGroup == eventPrefix ) { + items.push( { + event, + item: item.name || item.description + } ); + } + } + } + + if ( items.length ) { + /** + * Some of the {@link module:engine/model/item~Item model items} were not consumed while downcasting the model to view. + * + * This might be the effect of: + * + * * A missing converter for some model elements. Make sure that you registered downcast converters for all model elements. + * * A custom converter that does not consume converted items. Make sure that you + * {@link module:engine/conversion/modelconsumable~ModelConsumable#consume consumed} all model elements that you converted + * from the model to the view. + * * A custom converter that called `event.stop()`. When providing a custom converter, keep in mind that you should not stop + * the event. If you stop it then the default converter at the `lowest` priority will not trigger the conversion of this node's + * attributes and child nodes. + * + * @error conversion-model-consumable-not-consumed + * @param {Array.} items Items that were not consumed. + */ + throw new CKEditorError( 'conversion-model-consumable-not-consumed', null, { items } ); + } + } + + /** + * Gets a unique symbol for the passed {@link module:engine/model/textproxy~TextProxy} instance. All `TextProxy` instances that + * have same parent, same start index and same end index will get the same symbol. + * + * Used internally to correctly consume `TextProxy` instances. + * + * @internal + * @protected + * @param {module:engine/model/textproxy~TextProxy} textProxy `TextProxy` instance to get a symbol for. + * @returns {Symbol} Symbol representing all equal instances of `TextProxy`. + */ + public _getSymbolForTextProxy( textProxy: TextProxy ): symbol { + let symbol = null; + + const startMap = this._textProxyRegistry.get( textProxy.startOffset ); + + if ( startMap ) { + const endMap = startMap.get( textProxy.endOffset ); + + if ( endMap ) { + symbol = endMap.get( textProxy.parent ); + } + } + + if ( !symbol ) { + symbol = this._addSymbolForTextProxy( textProxy ); + } + + return symbol; + } + + /** + * Adds a symbol for the given {@link module:engine/model/textproxy~TextProxy} instance. + * + * Used internally to correctly consume `TextProxy` instances. + * + * @private + * @param {module:engine/model/textproxy~TextProxy} textProxy Text proxy instance. + * @returns {Symbol} Symbol generated for given `TextProxy`. + */ + private _addSymbolForTextProxy( textProxy: TextProxy ): symbol { + const start = textProxy.startOffset; + const end = textProxy.endOffset; + const parent = textProxy.parent; + + const symbol = Symbol( '$textProxy:' + textProxy.data ); + let startMap: Map> | undefined; + let endMap: Map | undefined; + + startMap = this._textProxyRegistry.get( start ); + + if ( !startMap ) { + startMap = new Map(); + this._textProxyRegistry.set( start, startMap ); + } + + endMap = startMap.get( end ); + + if ( !endMap ) { + endMap = new Map(); + startMap.set( end, endMap ); + } + + endMap.set( parent, symbol ); + + return symbol; + } +} + +// Returns a normalized consumable type name from the given string. A normalized consumable type name is a string that has +// at most one colon, for example: `insert` or `addMarker:highlight`. If a string to normalize has more "parts" (more colons), +// the further parts are dropped, for example: `addattribute:bold:$text` -> `addattributes:bold`. +// +// @param {String} type Consumable type. +// @returns {String} Normalized consumable type. +function _normalizeConsumableType( type: string ) { + const parts = type.split( ':' ); + + // For inserts allow passing event name, it's stored in the context of a specified element so the element name is not needed. + if ( parts[ 0 ] == 'insert' ) { + return parts[ 0 ]; + } + + // Markers are identified by the whole name (otherwise we would consume the whole markers group). + if ( parts[ 0 ] == 'addMarker' || parts[ 0 ] == 'removeMarker' ) { + return type; + } + + return parts.length > 1 ? parts[ 0 ] + ':' + parts[ 1 ] : parts[ 0 ]; +} diff --git a/packages/ckeditor5-engine/src/conversion/upcastdispatcher.ts b/packages/ckeditor5-engine/src/conversion/upcastdispatcher.ts new file mode 100644 index 00000000000..b869814e1e4 --- /dev/null +++ b/packages/ckeditor5-engine/src/conversion/upcastdispatcher.ts @@ -0,0 +1,928 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module engine/conversion/upcastdispatcher + */ + +import ViewConsumable from './viewconsumable'; +import ModelRange from '../model/range'; +import ModelPosition from '../model/position'; +import type ModelElement from '../model/element'; +import type ViewElement from '../view/element'; +import type ViewText from '../view/text'; +import type ViewDocumentFragment from '../view/documentfragment'; +import type ModelDocumentFragment from '../model/documentfragment'; +import type { default as Schema, SchemaContextDefinition } from '../model/schema'; +import { SchemaContext } from '../model/schema'; // eslint-disable-line no-duplicate-imports +import type ModelWriter from '../model/writer'; +import { isParagraphable, wrapInParagraph } from '../model/utils/autoparagraphing'; + +import type ViewItem from '../view/item'; + +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; +import { Emitter } from '@ckeditor/ckeditor5-utils/src/emittermixin'; + +/** + * Upcast dispatcher is a central point of the view-to-model conversion, which is a process of + * converting a given {@link module:engine/view/documentfragment~DocumentFragment view document fragment} or + * {@link module:engine/view/element~Element view element} into a correct model structure. + * + * During the conversion process, the dispatcher fires events for all {@link module:engine/view/node~Node view nodes} + * from the converted view document fragment. + * Special callbacks called "converters" should listen to these events in order to convert the view nodes. + * + * The second parameter of the callback is the `data` object with the following properties: + * + * * `data.viewItem` contains a {@link module:engine/view/node~Node view node} or a + * {@link module:engine/view/documentfragment~DocumentFragment view document fragment} + * that is converted at the moment and might be handled by the callback. + * * `data.modelRange` is used to point to the result + * of the current conversion (e.g. the element that is being inserted) + * and is always a {@link module:engine/model/range~Range} when the conversion succeeds. + * * `data.modelCursor` is a {@link module:engine/model/position~Position position} on which the converter should insert + * the newly created items. + * + * The third parameter of the callback is an instance of {@link module:engine/conversion/upcastdispatcher~UpcastConversionApi} + * which provides additional tools for converters. + * + * You can read more about conversion in the {@glink framework/guides/deep-dive/conversion/upcast Upcast conversion} guide. + * + * Examples of event-based converters: + * + * // A converter for links (). + * editor.data.upcastDispatcher.on( 'element:a', ( evt, data, conversionApi ) => { + * if ( conversionApi.consumable.consume( data.viewItem, { name: true, attributes: [ 'href' ] } ) ) { + * // The element is inline and is represented by an attribute in the model. + * // This is why you need to convert only children. + * const { modelRange } = conversionApi.convertChildren( data.viewItem, data.modelCursor ); + * + * for ( let item of modelRange.getItems() ) { + * if ( conversionApi.schema.checkAttribute( item, 'linkHref' ) ) { + * conversionApi.writer.setAttribute( 'linkHref', data.viewItem.getAttribute( 'href' ), item ); + * } + * } + * } + * } ); + * + * // Convert

element's font-size style. + * // Note: You should use a low-priority observer in order to ensure that + * // it is executed after the element-to-element converter. + * editor.data.upcastDispatcher.on( 'element:p', ( evt, data, conversionApi ) => { + * const { consumable, schema, writer } = conversionApi; + * + * if ( !consumable.consume( data.viewItem, { style: 'font-size' } ) ) { + * return; + * } + * + * const fontSize = data.viewItem.getStyle( 'font-size' ); + * + * // Do not go for the model element after data.modelCursor because it might happen + * // that a single view element was converted to multiple model elements. Get all of them. + * for ( const item of data.modelRange.getItems( { shallow: true } ) ) { + * if ( schema.checkAttribute( item, 'fontSize' ) ) { + * writer.setAttribute( 'fontSize', fontSize, item ); + * } + * } + * }, { priority: 'low' } ); + * + * // Convert all elements which have no custom converter into a paragraph (autoparagraphing). + * editor.data.upcastDispatcher.on( 'element', ( evt, data, conversionApi ) => { + * // Check if an element can be converted. + * if ( !conversionApi.consumable.test( data.viewItem, { name: data.viewItem.name } ) ) { + * // When an element is already consumed by higher priority converters, do nothing. + * return; + * } + * + * const paragraph = conversionApi.writer.createElement( 'paragraph' ); + * + * // Try to safely insert a paragraph at the model cursor - it will find an allowed parent for the current element. + * if ( !conversionApi.safeInsert( paragraph, data.modelCursor ) ) { + * // When an element was not inserted, it means that you cannot insert a paragraph at this position. + * return; + * } + * + * // Consume the inserted element. + * conversionApi.consumable.consume( data.viewItem, { name: data.viewItem.name } ) ); + * + * // Convert the children to a paragraph. + * const { modelRange } = conversionApi.convertChildren( data.viewItem, paragraph ) ); + * + * // Update `modelRange` and `modelCursor` in the `data` as a conversion result. + * conversionApi.updateConversionResult( paragraph, data ); + * }, { priority: 'low' } ); + * + * @mixes module:utils/emittermixin~EmitterMixin + * @fires viewCleanup + * @fires element + * @fires text + * @fires documentFragment + */ +export default class UpcastDispatcher extends Emitter { + public conversionApi: UpcastConversionApi; + + private _splitParts: Map; + private _cursorParents: Map; + private _modelCursor: ModelPosition | null; + private _emptyElementsToKeep: Set; + + /** + * Creates an upcast dispatcher that operates using the passed API. + * + * @see module:engine/conversion/upcastdispatcher~UpcastConversionApi + * @param {Object} [conversionApi] Additional properties for an interface that will be passed to events fired + * by the upcast dispatcher. + */ + constructor( conversionApi: Pick ) { + super(); + + /** + * The list of elements that were created during splitting. + * + * After the conversion process, the list is cleared. + * + * @private + * @type {Map.>} + */ + this._splitParts = new Map(); + + /** + * The list of cursor parent elements that were created during splitting. + * + * After the conversion process the list is cleared. + * + * @private + * @type {Map.>} + */ + this._cursorParents = new Map(); + + /** + * The position in the temporary structure where the converted content is inserted. The structure reflects the context of + * the target position where the content will be inserted. This property is built based on the context parameter of the + * convert method. + * + * @private + * @type {module:engine/model/position~Position|null} + */ + this._modelCursor = null; + + /** + * The list of elements that were created during the splitting but should not get removed on conversion end even if they are empty. + * + * The list is cleared after the conversion process. + * + * @private + * @type {Set.} + */ + this._emptyElementsToKeep = new Set(); + + /** + * An interface passed by the dispatcher to the event callbacks. + * + * @member {module:engine/conversion/upcastdispatcher~UpcastConversionApi} + */ + this.conversionApi = { + ...conversionApi, + consumable: null as any, + writer: null as any, + store: null, + convertItem: ( viewItem, modelCursor ) => this._convertItem( viewItem, modelCursor ), + convertChildren: ( viewElement, positionOrElement ) => this._convertChildren( viewElement, positionOrElement ), + safeInsert: ( modelElement, position ) => this._safeInsert( modelElement, position ), + updateConversionResult: ( modelElement, data ) => this._updateConversionResult( modelElement, data ), + // Advanced API - use only if custom position handling is needed. + splitToAllowedParent: ( modelElement, modelCursor ) => this._splitToAllowedParent( modelElement, modelCursor ), + getSplitParts: modelElement => this._getSplitParts( modelElement ), + keepEmptyElement: modelElement => this._keepEmptyElement( modelElement ) + }; + } + + /** + * Starts the conversion process. The entry point for the conversion. + * + * @fires element + * @fires text + * @fires documentFragment + * @param {module:engine/view/documentfragment~DocumentFragment|module:engine/view/element~Element} viewElement + * The part of the view to be converted. + * @param {module:engine/model/writer~Writer} writer An instance of the model writer. + * @param {module:engine/model/schema~SchemaContextDefinition} [context=['$root']] Elements will be converted according to this context. + * @returns {module:engine/model/documentfragment~DocumentFragment} Model data that is the result of the conversion process + * wrapped in `DocumentFragment`. Converted marker elements will be set as the document fragment's + * {@link module:engine/model/documentfragment~DocumentFragment#markers static markers map}. + */ + public convert( + viewElement: ViewElement | ViewDocumentFragment, + writer: ModelWriter, + context: SchemaContextDefinition = [ '$root' ] + ): ModelDocumentFragment { + this.fire( 'viewCleanup', viewElement ); + + // Create context tree and set position in the top element. + // Items will be converted according to this position. + this._modelCursor = createContextTree( context, writer )!; + + // Store writer in conversion as a conversion API + // to be sure that conversion process will use the same batch. + this.conversionApi.writer = writer; + + // Create consumable values list for conversion process. + this.conversionApi.consumable = ViewConsumable.createFrom( viewElement ); + + // Custom data stored by converter for conversion process. + this.conversionApi.store = {}; + + // Do the conversion. + const { modelRange } = this._convertItem( viewElement, this._modelCursor ); + + // Conversion result is always a document fragment so let's create it. + const documentFragment = writer.createDocumentFragment(); + + // When there is a conversion result. + if ( modelRange ) { + // Remove all empty elements that were create while splitting. + this._removeEmptyElements(); + + // Move all items that were converted in context tree to the document fragment. + for ( const item of Array.from( this._modelCursor.parent.getChildren() ) ) { + writer.append( item, documentFragment ); + } + + // Extract temporary markers elements from model and set as static markers collection. + ( documentFragment as any ).markers = extractMarkersFromModelFragment( documentFragment, writer ); + } + + // Clear context position. + this._modelCursor = null; + + // Clear split elements & parents lists. + this._splitParts.clear(); + this._cursorParents.clear(); + this._emptyElementsToKeep.clear(); + + // Clear conversion API. + ( this.conversionApi as any ).writer = null; + this.conversionApi.store = null; + + // Return fragment as conversion result. + return documentFragment; + } + + /** + * @private + * @see module:engine/conversion/upcastdispatcher~UpcastConversionApi#convertItem + */ + private _convertItem( viewItem: ViewItem | ViewDocumentFragment, modelCursor: ModelPosition ): { + modelRange: ModelRange | null; + modelCursor: ModelPosition; + } { + const data: UpcastConversionData = { viewItem, modelCursor, modelRange: null }; + + if ( viewItem.is( 'element' ) ) { + this.fire( + `element:${ viewItem.name }`, + data as UpcastConversionData, + this.conversionApi + ); + } else if ( viewItem.is( '$text' ) ) { + this.fire( + 'text', + data as UpcastConversionData, + this.conversionApi + ); + } else { + this.fire( + 'documentFragment', + data as UpcastConversionData, + this.conversionApi + ); + } + + // Handle incorrect conversion result. + if ( data.modelRange && !( data.modelRange instanceof ModelRange ) ) { + /** + * Incorrect conversion result was dropped. + * + * {@link module:engine/model/range~Range Model range} should be a conversion result. + * + * @error view-conversion-dispatcher-incorrect-result + */ + throw new CKEditorError( 'view-conversion-dispatcher-incorrect-result', this ); + } + + return { modelRange: data.modelRange, modelCursor: data.modelCursor }; + } + + /** + * @private + * @see module:engine/conversion/upcastdispatcher~UpcastConversionApi#convertChildren + */ + private _convertChildren( + viewItem: ViewElement | ViewDocumentFragment, + elementOrModelCursor: ModelPosition | ModelElement + ): { + modelRange: ModelRange; + modelCursor: ModelPosition; + } { + let nextModelCursor = elementOrModelCursor.is( 'position' ) ? + elementOrModelCursor : ModelPosition._createAt( elementOrModelCursor, 0 ); + + const modelRange = new ModelRange( nextModelCursor ); + + for ( const viewChild of Array.from( viewItem.getChildren() ) ) { + const result = this._convertItem( viewChild, nextModelCursor ); + + if ( result.modelRange instanceof ModelRange ) { + ( modelRange as any ).end = result.modelRange.end; + nextModelCursor = result.modelCursor; + } + } + + return { modelRange, modelCursor: nextModelCursor }; + } + + /** + * @private + * @see module:engine/conversion/upcastdispatcher~UpcastConversionApi#safeInsert + */ + private _safeInsert( + modelElement: ModelElement, + position: ModelPosition + ): boolean { + // Find allowed parent for element that we are going to insert. + // If current parent does not allow to insert element but one of the ancestors does + // then split nodes to allowed parent. + const splitResult = this._splitToAllowedParent( modelElement, position ); + + // When there is no split result it means that we can't insert element to model tree, so let's skip it. + if ( !splitResult ) { + return false; + } + + // Insert element on allowed position. + this.conversionApi.writer!.insert( modelElement, splitResult.position ); + + return true; + } + + /** + * @private + * @see module:engine/conversion/upcastdispatcher~UpcastConversionApi#updateConversionResult + */ + private _updateConversionResult( modelElement: ModelElement, data: UpcastConversionData ): void { + const parts = this._getSplitParts( modelElement ); + + const writer = this.conversionApi.writer!; + + // Set conversion result range - only if not set already. + if ( !data.modelRange ) { + data.modelRange = writer.createRange( + writer.createPositionBefore( modelElement ), + writer.createPositionAfter( parts[ parts.length - 1 ] ) + ); + } + + const savedCursorParent = this._cursorParents.get( modelElement ); + + // Now we need to check where the `modelCursor` should be. + if ( savedCursorParent ) { + // If we split parent to insert our element then we want to continue conversion in the new part of the split parent. + // + // before: foo[] + // after: foo [] + + data.modelCursor = writer.createPositionAt( savedCursorParent, 0 ); + } else { + // Otherwise just continue after inserted element. + + data.modelCursor = data.modelRange.end; + } + } + + /** + * @private + * @see module:engine/conversion/upcastdispatcher~UpcastConversionApi#splitToAllowedParent + */ + private _splitToAllowedParent( node: ModelElement, modelCursor: ModelPosition ): { + position: ModelPosition; + cursorParent?: ModelElement | ModelDocumentFragment; + } | null { + const { schema, writer } = this.conversionApi; + + // Try to find allowed parent. + let allowedParent = schema.findAllowedParent( modelCursor, node ); + + if ( allowedParent ) { + // When current position parent allows to insert node then return this position. + if ( allowedParent === modelCursor.parent ) { + return { position: modelCursor }; + } + + // When allowed parent is in context tree (it's outside the converted tree). + if ( this._modelCursor!.parent.getAncestors().includes( allowedParent ) ) { + allowedParent = null; + } + } + + if ( !allowedParent ) { + // Check if the node wrapped with a paragraph would be accepted by the schema. + if ( !isParagraphable( modelCursor, node, schema ) ) { + return null; + } + + return { + position: wrapInParagraph( modelCursor, writer! ) + }; + } + + // Split element to allowed parent. + const splitResult = this.conversionApi.writer!.split( modelCursor, allowedParent ); + + // Using the range returned by `model.Writer#split`, we will pair original elements with their split parts. + // + // The range returned from the writer spans "over the split" or, precisely saying, from the end of the original element (the one + // that got split) to the beginning of the other part of that element: + // + // X[]Y -> + // X[]Y + // + // After the split there cannot be any full node between the positions in `splitRange`. The positions are touching. + // Also, because of how splitting works, it is easy to notice, that "closing tags" are in the reverse order than "opening tags". + // Also, since we split all those elements, each of them has to have the other part. + // + // With those observations in mind, we will pair the original elements with their split parts by saving "closing tags" and matching + // them with "opening tags" in the reverse order. For that we can use a stack. + const stack: ModelElement[] = []; + + for ( const treeWalkerValue of splitResult.range.getWalker() ) { + if ( treeWalkerValue.type == 'elementEnd' ) { + stack.push( treeWalkerValue.item as ModelElement ); + } else { + // There should not be any text nodes after the element is split, so the only other value is `elementStart`. + const originalPart = stack.pop(); + const splitPart = treeWalkerValue.item as ModelElement; + + this._registerSplitPair( originalPart!, splitPart ); + } + } + + const cursorParent = splitResult.range.end.parent; + this._cursorParents.set( node, cursorParent ); + + return { + position: splitResult.position, + cursorParent + }; + } + + /** + * Registers that a `splitPart` element is a split part of the `originalPart` element. + * + * The data set by this method is used by {@link #_getSplitParts} and {@link #_removeEmptyElements}. + * + * @private + * @param {module:engine/model/element~Element} originalPart + * @param {module:engine/model/element~Element} splitPart + */ + private _registerSplitPair( originalPart: ModelElement, splitPart: ModelElement ): void { + if ( !this._splitParts.has( originalPart ) ) { + this._splitParts.set( originalPart, [ originalPart ] ); + } + + const list = this._splitParts.get( originalPart )!; + + this._splitParts.set( splitPart, list ); + list.push( splitPart ); + } + + /** + * @private + * @see module:engine/conversion/upcastdispatcher~UpcastConversionApi#getSplitParts + */ + private _getSplitParts( element: ModelElement ): ModelElement[] { + let parts: ModelElement[]; + + if ( !this._splitParts.has( element ) ) { + parts = [ element ]; + } else { + parts = this._splitParts.get( element )!; + } + + return parts; + } + + /** + * Mark an element that were created during the splitting to not get removed on conversion end even if it is empty. + * + * @private + */ + private _keepEmptyElement( element: ModelElement ): void { + this._emptyElementsToKeep.add( element ); + } + + /** + * Checks if there are any empty elements created while splitting and removes them. + * + * This method works recursively to re-check empty elements again after at least one element was removed in the initial call, + * as some elements might have become empty after other empty elements were removed from them. + * + * @private + */ + private _removeEmptyElements(): void { + let anyRemoved = false; + + for ( const element of this._splitParts.keys() ) { + if ( element.isEmpty && !this._emptyElementsToKeep.has( element ) ) { + this.conversionApi.writer!.remove( element ); + this._splitParts.delete( element ); + + anyRemoved = true; + } + } + + if ( anyRemoved ) { + this._removeEmptyElements(); + } + } + + /** + * Fired before the first conversion event, at the beginning of the upcast (view-to-model conversion) process. + * + * @event viewCleanup + * @param {module:engine/view/documentfragment~DocumentFragment|module:engine/view/element~Element} + * viewItem A part of the view to be converted. + */ + + /** + * Fired when an {@link module:engine/view/element~Element} is converted. + * + * `element` is a namespace event for a class of events. Names of actually called events follow the pattern of + * `element:` where `elementName` is the name of the converted element. This way listeners may listen to + * a conversion of all or just specific elements. + * + * @event element + * @param {module:engine/conversion/upcastdispatcher~UpcastConversionData} data The conversion data. Keep in mind that this object is + * shared by reference between all callbacks that will be called. This means that callbacks can override values if needed, and these + * values will be available in other callbacks. + * @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi Conversion utilities to be used by the + * callback. + */ + + /** + * Fired when a {@link module:engine/view/text~Text} is converted. + * + * @event text + * @see #event:element + */ + + /** + * Fired when a {@link module:engine/view/documentfragment~DocumentFragment} is converted. + * + * @event documentFragment + * @see #event:element + */ +} + +export type ViewCleanupEvent = { + name: 'viewCleanup'; + args: [ ViewElement | ViewDocumentFragment ]; +}; + +type UpcastEvent = { + name: TName | `${ TName }:${ string }`; + args: [ data: UpcastConversionData, conversionApi: UpcastConversionApi ]; +}; +export type UpcastElementEvent = UpcastEvent<'element', ViewElement>; + +export type UpcastTextEvent = UpcastEvent<'text', ViewText>; + +export type UpcastDocumentFragmentEvent = UpcastEvent<'documentFragment', ViewDocumentFragment>; + +// Traverses given model item and searches elements which marks marker range. Found element is removed from +// DocumentFragment but path of this element is stored in a Map which is then returned. +// +// @param {module:engine/view/documentfragment~DocumentFragment|module:engine/view/node~Node} modelItem Fragment of model. +// @returns {Map} List of static markers. +function extractMarkersFromModelFragment( modelItem: ModelDocumentFragment, writer: ModelWriter ): Map { + const markerElements = new Set(); + const markers = new Map(); + + // Create ModelTreeWalker. + const range = ModelRange._createIn( modelItem ).getItems(); + + // Walk through DocumentFragment and collect marker elements. + for ( const item of range ) { + // Check if current element is a marker. + if ( item.is( 'element', '$marker' ) ) { + markerElements.add( item ); + } + } + + // Walk through collected marker elements store its path and remove its from the DocumentFragment. + for ( const markerElement of markerElements ) { + const markerName = markerElement.getAttribute( 'data-name' ) as string; + const currentPosition = writer.createPositionBefore( markerElement ); + + // When marker of given name is not stored it means that we have found the beginning of the range. + if ( !markers.has( markerName ) ) { + markers.set( markerName, new ModelRange( currentPosition.clone() ) ); + // Otherwise is means that we have found end of the marker range. + } else { + ( markers.get( markerName ) as any ).end = currentPosition.clone(); + } + + // Remove marker element from DocumentFragment. + writer.remove( markerElement ); + } + + return markers; +} + +// Creates model fragment according to given context and returns position in the bottom (the deepest) element. +function createContextTree( + contextDefinition: SchemaContextDefinition, + writer: ModelWriter +): ModelPosition | undefined { + let position: ModelPosition | undefined; + + for ( const item of new SchemaContext( contextDefinition ) ) { + const attributes: Record = {}; + + for ( const key of item.getAttributeKeys() ) { + attributes[ key ] = item.getAttribute( key ); + } + + const current = writer.createElement( item.name, attributes ); + + if ( position ) { + writer.insert( current, position ); + } + + position = ModelPosition._createAt( current, 0 ); + } + + return position; +} + +/** + * A set of conversion utilities available as the third parameter of the + * {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher upcast dispatcher}'s events. + * + * @interface module:engine/conversion/upcastdispatcher~UpcastConversionApi + */ +export interface UpcastConversionApi { + consumable: ViewConsumable; + schema: Schema; + writer: ModelWriter; + store: unknown; + + convertItem( viewItem: ViewItem, modelCursor: ModelPosition ): { + modelRange: ModelRange | null; + modelCursor: ModelPosition; + }; + convertChildren( viewElement: ViewElement | ViewDocumentFragment, positionOrElement: ModelPosition | ModelElement ): { + modelRange: ModelRange | null; + modelCursor: ModelPosition; + }; + safeInsert( modelElement: ModelElement, position: ModelPosition ): boolean; + updateConversionResult( modelElement: ModelElement, data: UpcastConversionData ): void; + splitToAllowedParent( modelElement: ModelElement, modelCursor: ModelPosition ): { + position: ModelPosition; + cursorParent?: ModelElement | ModelDocumentFragment; + } | null; + getSplitParts( modelElement: ModelElement ): ModelElement[]; + keepEmptyElement( modelElement: ModelElement ): void; +} + +/** + * Starts the conversion of a given item by firing an appropriate event. + * + * Every fired event is passed (as the first parameter) an object with the `modelRange` property. Every event may set and/or + * modify that property. When all callbacks are done, the final value of the `modelRange` property is returned by this method. + * The `modelRange` must be a {@link module:engine/model/range~Range model range} or `null` (as set by default). + * + * @method #convertItem + * @fires module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element + * @fires module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:text + * @fires module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:documentFragment + * @param {module:engine/view/item~Item} viewItem Item to convert. + * @param {module:engine/model/position~Position} modelCursor The conversion position. + * @returns {Object} result The conversion result. + * @returns {module:engine/model/range~Range|null} result.modelRange The model range containing the result of the item conversion, + * created and modified by callbacks attached to the fired event, or `null` if the conversion result was incorrect. + * @returns {module:engine/model/position~Position} result.modelCursor The position where the conversion should be continued. + */ + +/** + * Starts the conversion of all children of a given item by firing appropriate events for all the children. + * + * @method #convertChildren + * @fires module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element + * @fires module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:text + * @fires module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:documentFragment + * @param {module:engine/view/item~Item} viewItem An element whose children should be converted. + * @param {module:engine/model/position~Position|module:engine/model/element~Element} positionOrElement A position or an element of + * the conversion. + * @returns {Object} result The conversion result. + * @returns {module:engine/model/range~Range} result.modelRange The model range containing the results of the conversion of all children + * of the given item. When no child was converted, the range is collapsed. + * @returns {module:engine/model/position~Position} result.modelCursor The position where the conversion should be continued. + */ + +/** + * Safely inserts an element to the document, checking the {@link module:engine/model/schema~Schema schema} to find an allowed parent for + * an element that you are going to insert, starting from the given position. If the current parent does not allow to insert the element + * but one of the ancestors does, then splits the nodes to allowed parent. + * + * If the schema allows to insert the node in a given position, nothing is split. + * + * If it was not possible to find an allowed parent, `false` is returned and nothing is split. + * + * Otherwise, ancestors are split. + * + * For instance, if `` is not allowed in `` but is allowed in `$root`: + * + * foo[]bar + * + * -> safe insert for `` will split -> + * + * foo[]bar + * + * Example usage: + * + * const myElement = conversionApi.writer.createElement( 'myElement' ); + * + * if ( !conversionApi.safeInsert( myElement, data.modelCursor ) ) { + * return; + * } + * + * The split result is saved and {@link #updateConversionResult} should be used to update the + * {@link module:engine/conversion/upcastdispatcher~UpcastConversionData conversion data}. + * + * @method #safeInsert + * @param {module:engine/model/node~Node} node The node to insert. + * @param {module:engine/model/position~Position} position The position where an element is going to be inserted. + * @returns {Boolean} The split result. If it was not possible to find an allowed position, `false` is returned. + */ + +/** + * Updates the conversion result and sets a proper {@link module:engine/conversion/upcastdispatcher~UpcastConversionData#modelRange} and + * the next {@link module:engine/conversion/upcastdispatcher~UpcastConversionData#modelCursor} after the conversion. + * Used together with {@link #safeInsert}, it enables you to easily convert elements without worrying if the node was split + * during the conversion of its children. + * + * A usage example in converter code: + * + * const myElement = conversionApi.writer.createElement( 'myElement' ); + * + * if ( !conversionApi.safeInsert( myElement, data.modelCursor ) ) { + * return; + * } + * + * // Children conversion may split `myElement`. + * conversionApi.convertChildren( data.viewItem, myElement ); + * + * conversionApi.updateConversionResult( myElement, data ); + * + * @method #updateConversionResult + * @param {module:engine/model/element~Element} element + * @param {module:engine/conversion/upcastdispatcher~UpcastConversionData} data Conversion data. + * @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi Conversion utilities to be used by the callback. + */ + +/** + * Checks the {@link module:engine/model/schema~Schema schema} to find an allowed parent for an element that is going to be inserted + * starting from the given position. If the current parent does not allow inserting an element but one of the ancestors does, the method + * splits nodes to allowed parent. + * + * If the schema allows inserting the node in the given position, nothing is split and an object with that position is returned. + * + * If it was not possible to find an allowed parent, `null` is returned and nothing is split. + * + * Otherwise, ancestors are split and an object with a position and the copy of the split element is returned. + * + * For instance, if `` is not allowed in `` but is allowed in `$root`: + * + * foo[]bar + * + * -> split for `` -> + * + * foo[]bar + * + * In the example above, the position between `` elements will be returned as `position` and the second `paragraph` + * as `cursorParent`. + * + * **Note:** This is an advanced method. For most cases {@link #safeInsert} and {@link #updateConversionResult} should be used. + * + * @method #splitToAllowedParent + * @param {module:engine/model/position~Position} position The position where the element is going to be inserted. + * @param {module:engine/model/node~Node} node The node to insert. + * @returns {Object|null} The split result. If it was not possible to find an allowed position, `null` is returned. + * @returns {module:engine/model/position~Position} The position between split elements. + * @returns {module:engine/model/element~Element} [cursorParent] The element inside which the cursor should be placed to + * continue the conversion. When the element is not defined it means that there was no split. + */ + +/** + * Returns all the split parts of the given `element` that were created during upcasting through using {@link #splitToAllowedParent}. + * It enables you to easily track these elements and continue processing them after they are split during the conversion of their children. + * + * Foobarbaz -> + * Foobarbaz + * + * For a reference to any of above paragraphs, the function will return all three paragraphs (the original element included), + * sorted in the order of their creation (the original element is the first one). + * + * If the given `element` was not split, an array with a single element is returned. + * + * A usage example in the converter code: + * + * const myElement = conversionApi.writer.createElement( 'myElement' ); + * + * // Children conversion may split `myElement`. + * conversionApi.convertChildren( data.viewItem, data.modelCursor ); + * + * const splitParts = conversionApi.getSplitParts( myElement ); + * const lastSplitPart = splitParts[ splitParts.length - 1 ]; + * + * // Setting `data.modelRange` basing on split parts: + * data.modelRange = conversionApi.writer.createRange( + * conversionApi.writer.createPositionBefore( myElement ), + * conversionApi.writer.createPositionAfter( lastSplitPart ) + * ); + * + * // Setting `data.modelCursor` to continue after the last split element: + * data.modelCursor = conversionApi.writer.createPositionAfter( lastSplitPart ); + * + * **Tip:** If you are unable to get a reference to the original element (for example because the code is split into multiple converters + * or even classes) but it has already been converted, you may want to check the first element in `data.modelRange`. This is a common + * situation if an attribute converter is separated from an element converter. + * + * **Note:** This is an advanced method. For most cases {@link #safeInsert} and {@link #updateConversionResult} should be used. + * + * @method #getSplitParts + * @param {module:engine/model/element~Element} element + * @returns {Array.} + */ + +/** + * Mark an element that was created during splitting to not get removed on conversion end even if it is empty. + * + * **Note:** This is an advanced method. For most cases you will not need to keep the split empty element. + * + * @method #keepEmptyElement + * @param {module:engine/model/element~Element} element + */ + +/** + * Stores information about what parts of the processed view item are still waiting to be handled. After a piece of view item + * was converted, an appropriate consumable value should be + * {@link module:engine/conversion/viewconsumable~ViewConsumable#consume consumed}. + * + * @member {module:engine/conversion/viewconsumable~ViewConsumable} #consumable + */ + +/** + * Custom data stored by converters for the conversion process. Custom properties of this object can be defined and use to + * pass parameters between converters. + * + * The difference between this property and the `data` parameter of + * {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element} is that the `data` parameters allow you + * to pass parameters within a single event and `store` within the whole conversion. + * + * @member {Object} #store + */ + +/** + * The model's schema instance. + * + * @member {module:engine/model/schema~Schema} #schema + */ + +/** + * The {@link module:engine/model/writer~Writer} instance used to manipulate the data during conversion. + * + * @member {module:engine/model/writer~Writer} #writer + */ + +/** + * Conversion data. + * + * **Note:** Keep in mind that this object is shared by reference between all conversion callbacks that will be called. + * This means that callbacks can override values if needed, and these values will be available in other callbacks. + * + * @typedef {Object} module:engine/conversion/upcastdispatcher~UpcastConversionData + * + * @property {module:engine/view/item~Item} viewItem The converted item. + * @property {module:engine/model/position~Position} modelCursor The position where the converter should start changes. + * Change this value for the next converter to tell where the conversion should continue. + * @property {module:engine/model/range~Range} [modelRange] The current state of conversion result. Every change to + * the converted element should be reflected by setting or modifying this property. + */ +export type UpcastConversionData = { + viewItem: TItem; + modelCursor: ModelPosition; + modelRange: ModelRange | null; +}; diff --git a/packages/ckeditor5-engine/src/conversion/upcasthelpers.ts b/packages/ckeditor5-engine/src/conversion/upcasthelpers.ts new file mode 100644 index 00000000000..998f80b0fdd --- /dev/null +++ b/packages/ckeditor5-engine/src/conversion/upcasthelpers.ts @@ -0,0 +1,1194 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import Matcher, { type ClassPatterns, type MatcherPattern, type PropertyPatterns } from '../view/matcher'; +import ConversionHelpers from './conversionhelpers'; + +import type { default as UpcastDispatcher, UpcastElementEvent, UpcastConversionApi, UpcastConversionData } from './upcastdispatcher'; +import type ModelElement from '../model/element'; +import type ModelRange from '../model/range'; +import type ModelPosition from '../model/position'; +import type EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; +import type { ViewDocumentFragment, ViewElement, ViewText } from '../index'; +import type Mapper from './mapper'; +import type Model from '../model/model'; +import type ViewSelection from '../view/selection'; +import type ViewDocumentSelection from '../view/documentselection'; + +import { cloneDeep } from 'lodash-es'; + +import priorities, { type PriorityString } from '@ckeditor/ckeditor5-utils/src/priorities'; +import { isParagraphable, wrapInParagraph } from '../model/utils/autoparagraphing'; + +/** + * Contains the {@link module:engine/view/view view} to {@link module:engine/model/model model} converters for + * {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher}. + * + * @module engine/conversion/upcasthelpers + */ + +/** + * Upcast conversion helper functions. + * + * Learn more about {@glink framework/guides/deep-dive/conversion/upcast upcast helpers}. + * + * @extends module:engine/conversion/conversionhelpers~ConversionHelpers + */ +export default class UpcastHelpers extends ConversionHelpers { + /** + * View element to model element conversion helper. + * + * This conversion results in creating a model element. For example, + * view `

Foo

` becomes `Foo` in the model. + * + * Keep in mind that the element will be inserted only if it is allowed + * by {@link module:engine/model/schema~Schema schema} configuration. + * + * editor.conversion.for( 'upcast' ).elementToElement( { + * view: 'p', + * model: 'paragraph' + * } ); + * + * editor.conversion.for( 'upcast' ).elementToElement( { + * view: 'p', + * model: 'paragraph', + * converterPriority: 'high' + * } ); + * + * editor.conversion.for( 'upcast' ).elementToElement( { + * view: { + * name: 'p', + * classes: 'fancy' + * }, + * model: 'fancyParagraph' + * } ); + * + * editor.conversion.for( 'upcast' ).elementToElement( { + * view: { + * name: 'p', + * classes: 'heading' + * }, + * model: ( viewElement, conversionApi ) => { + * const modelWriter = conversionApi.writer; + * + * return modelWriter.createElement( 'heading', { level: viewElement.getAttribute( 'data-level' ) } ); + * } + * } ); + * + * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter + * to the conversion process. + * + * @method #elementToElement + * @param {Object} config Conversion configuration. + * @param {module:engine/view/matcher~MatcherPattern} [config.view] Pattern matching all view elements which should be converted. If not + * set, the converter will fire for every view element. + * @param {String|module:engine/model/element~Element|Function} config.model Name of the model element, a model element instance or a + * function that takes a view element and {@link module:engine/conversion/upcastdispatcher~UpcastConversionApi upcast conversion API} + * and returns a model element. The model element will be inserted in the model. + * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority. + * @returns {module:engine/conversion/upcasthelpers~UpcastHelpers} + */ + public elementToElement( config: { + view: MatcherPattern; + model: string | ElementCreatorFunction; + converterPriority?: PriorityString | number; + } ): this { + return this.add( upcastElementToElement( config ) ); + } + + /** + * View element to model attribute conversion helper. + * + * This conversion results in setting an attribute on a model node. For example, view `Foo` becomes + * `Foo` {@link module:engine/model/text~Text model text node} with `bold` attribute set to `true`. + * + * This helper is meant to set a model attribute on all the elements that are inside the converted element: + * + * Foo -->

Foo

--> <$text bold="true">Foo + * + * Above is a sample of HTML code, that goes through autoparagraphing (first step) and then is converted (second step). + * Even though `` is over `

` element, `bold="true"` was added to the text. See + * {@link module:engine/conversion/upcasthelpers~UpcastHelpers#attributeToAttribute} for comparison. + * + * Keep in mind that the attribute will be set only if it is allowed by {@link module:engine/model/schema~Schema schema} configuration. + * + * editor.conversion.for( 'upcast' ).elementToAttribute( { + * view: 'strong', + * model: 'bold' + * } ); + * + * editor.conversion.for( 'upcast' ).elementToAttribute( { + * view: 'strong', + * model: 'bold', + * converterPriority: 'high' + * } ); + * + * editor.conversion.for( 'upcast' ).elementToAttribute( { + * view: { + * name: 'span', + * classes: 'bold' + * }, + * model: 'bold' + * } ); + * + * editor.conversion.for( 'upcast' ).elementToAttribute( { + * view: { + * name: 'span', + * classes: [ 'styled', 'styled-dark' ] + * }, + * model: { + * key: 'styled', + * value: 'dark' + * } + * } ); + * + * editor.conversion.for( 'upcast' ).elementToAttribute( { + * view: { + * name: 'span', + * styles: { + * 'font-size': /[\s\S]+/ + * } + * }, + * model: { + * key: 'fontSize', + * value: ( viewElement, conversionApi ) => { + * const fontSize = viewElement.getStyle( 'font-size' ); + * const value = fontSize.substr( 0, fontSize.length - 2 ); + * + * if ( value <= 10 ) { + * return 'small'; + * } else if ( value > 12 ) { + * return 'big'; + * } + * + * return null; + * } + * } + * } ); + * + * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter + * to the conversion process. + * + * @method #elementToAttribute + * @param {Object} config Conversion configuration. + * @param {module:engine/view/matcher~MatcherPattern} config.view Pattern matching all view elements which should be converted. + * @param {String|Object} config.model Model attribute key or an object with `key` and `value` properties, describing + * the model attribute. `value` property may be set as a function that takes a view element and + * {@link module:engine/conversion/upcastdispatcher~UpcastConversionApi upcast conversion API} and returns the value. + * If `String` is given, the model attribute value will be set to `true`. + * @param {module:utils/priorities~PriorityString} [config.converterPriority='low'] Converter priority. + * @returns {module:engine/conversion/upcasthelpers~UpcastHelpers} + */ + public elementToAttribute( config: { + view: MatcherPattern; + model: string | { + key: string; + value: unknown; + }; + converterPriority?: PriorityString | number; + } ): this { + return this.add( upcastElementToAttribute( config ) ); + } + + /** + * View attribute to model attribute conversion helper. + * + * This conversion results in setting an attribute on a model node. For example, view `` becomes + * `` in the model. + * + * This helper is meant to convert view attributes from view elements which got converted to the model, so the view attribute + * is set only on the corresponding model node: + * + *

foo
-->
foo
+ * + * Above, `class="dark"` attribute is added only to the `
` elements that has it. This is in contrary to + * {@link module:engine/conversion/upcasthelpers~UpcastHelpers#elementToAttribute} which sets attributes for + * all the children in the model: + * + * Foo -->

Foo

--> <$text bold="true">Foo + * + * Above is a sample of HTML code, that goes through autoparagraphing (first step) and then is converted (second step). + * Even though `` is over `

` element, `bold="true"` was added to the text. + * + * Keep in mind that the attribute will be set only if it is allowed by {@link module:engine/model/schema~Schema schema} configuration. + * + * editor.conversion.for( 'upcast' ).attributeToAttribute( { + * view: 'src', + * model: 'source' + * } ); + * + * editor.conversion.for( 'upcast' ).attributeToAttribute( { + * view: { key: 'src' }, + * model: 'source' + * } ); + * + * editor.conversion.for( 'upcast' ).attributeToAttribute( { + * view: { key: 'src' }, + * model: 'source', + * converterPriority: 'normal' + * } ); + * + * editor.conversion.for( 'upcast' ).attributeToAttribute( { + * view: { + * key: 'data-style', + * value: /[\s\S]+/ + * }, + * model: 'styled' + * } ); + * + * editor.conversion.for( 'upcast' ).attributeToAttribute( { + * view: { + * name: 'img', + * key: 'class', + * value: 'styled-dark' + * }, + * model: { + * key: 'styled', + * value: 'dark' + * } + * } ); + * + * editor.conversion.for( 'upcast' ).attributeToAttribute( { + * view: { + * key: 'class', + * value: /styled-[\S]+/ + * }, + * model: { + * key: 'styled' + * value: ( viewElement, conversionApi ) => { + * const regexp = /styled-([\S]+)/; + * const match = viewElement.getAttribute( 'class' ).match( regexp ); + * + * return match[ 1 ]; + * } + * } + * } ); + * + * Converting styles works a bit differently as it requires `view.styles` to be an object and by default + * a model attribute will be set to `true` by such a converter. You can set the model attribute to any value by providing the `value` + * callback that returns the desired value. + * + * // Default conversion of font-weight style will result in setting bold attribute to true. + * editor.conversion.for( 'upcast' ).attributeToAttribute( { + * view: { + * styles: { + * 'font-weight': 'bold' + * } + * }, + * model: 'bold' + * } ); + * + * // This converter will pass any style value to the `lineHeight` model attribute. + * editor.conversion.for( 'upcast' ).attributeToAttribute( { + * view: { + * styles: { + * 'line-height': /[\s\S]+/ + * } + * }, + * model: { + * key: 'lineHeight', + * value: ( viewElement, conversionApi ) => viewElement.getStyle( 'line-height' ) + * } + * } ); + * + * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter + * to the conversion process. + * + * @method #attributeToAttribute + * @param {Object} config Conversion configuration. + * @param {String|Object} config.view Specifies which view attribute will be converted. If a `String` is passed, + * attributes with given key will be converted. If an `Object` is passed, it must have a required `key` property, + * specifying view attribute key, and may have an optional `value` property, specifying view attribute value and optional `name` + * property specifying a view element name from/on which the attribute should be converted. `value` can be given as a `String`, + * a `RegExp` or a function callback, that takes view attribute value as the only parameter and returns `Boolean`. + * @param {String|Object} config.model Model attribute key or an object with `key` and `value` properties, describing + * the model attribute. `value` property may be set as a function that takes a view element and + * {@link module:engine/conversion/upcastdispatcher~UpcastConversionApi upcast conversion API} and returns the value. + * If `String` is given, the model attribute value will be same as view attribute value. + * @param {module:utils/priorities~PriorityString} [config.converterPriority='low'] Converter priority. + * @returns {module:engine/conversion/upcasthelpers~UpcastHelpers} + */ + public attributeToAttribute( config: { + view: string | { + key: string; + value?: string | RegExp | ( ( value: unknown ) => boolean ); + name?: string; + } | { + name?: string; + styles?: PropertyPatterns; + classes?: ClassPatterns; + attributes?: PropertyPatterns; + }; + model: string | { + key: string; + value: unknown | ( ( viewElement: ViewElement, conversionApi: UpcastConversionApi ) => unknown ); + }; + converterPriority?: PriorityString | number; + } ): this { + return this.add( upcastAttributeToAttribute( config ) ); + } + + /** + * View element to model marker conversion helper. + * + * This conversion results in creating a model marker. For example, if the marker was stored in a view as an element: + * `

Foo

Bar

`, + * after the conversion is done, the marker will be available in + * {@link module:engine/model/model~Model#markers model document markers}. + * + * **Note**: When this helper is used in the data upcast in combination with + * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToData `#markerToData()`} in the data downcast, + * then invalid HTML code (e.g. a span between table cells) may be produced by the latter converter. + * + * In most of the cases, the {@link #dataToMarker} should be used instead. + * + * editor.conversion.for( 'upcast' ).elementToMarker( { + * view: 'marker-search', + * model: 'search' + * } ); + * + * editor.conversion.for( 'upcast' ).elementToMarker( { + * view: 'marker-search', + * model: 'search', + * converterPriority: 'high' + * } ); + * + * editor.conversion.for( 'upcast' ).elementToMarker( { + * view: 'marker-search', + * model: ( viewElement, conversionApi ) => 'comment:' + viewElement.getAttribute( 'data-comment-id' ) + * } ); + * + * editor.conversion.for( 'upcast' ).elementToMarker( { + * view: { + * name: 'span', + * attributes: { + * 'data-marker': 'search' + * } + * }, + * model: 'search' + * } ); + * + * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter + * to the conversion process. + * + * @method #elementToMarker + * @param {Object} config Conversion configuration. + * @param {module:engine/view/matcher~MatcherPattern} config.view Pattern matching all view elements which should be converted. + * @param {String|Function} config.model Name of the model marker, or a function that takes a view element and returns + * a model marker name. + * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority. + * @returns {module:engine/conversion/upcasthelpers~UpcastHelpers} + */ + public elementToMarker( config: { + view: MatcherPattern; + model: string | MarkerFromElementCreatorFunction; + converterPriority?: PriorityString | number; + } ): this { + return this.add( upcastElementToMarker( config ) ); + } + + /** + * View-to-model marker conversion helper. + * + * Converts view data created by {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToData `#markerToData()`} + * back to a model marker. + * + * This converter looks for specific view elements and view attributes that mark marker boundaries. See + * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToData `#markerToData()`} to learn what view data + * is expected by this converter. + * + * The `config.view` property is equal to the marker group name to convert. + * + * By default, this converter creates markers with the `group:name` name convention (to match the default `markerToData` conversion). + * + * The conversion configuration can take a function that will generate a marker name. + * If such function is set as the `config.model` parameter, it is passed the `name` part from the view element or attribute and it is + * expected to return a string with the marker name. + * + * Basic usage: + * + * // Using the default conversion. + * // In this case, all markers from the `comment` group will be converted. + * // The conversion will look for `` and `` tags and + * // `data-comment-start-before`, `data-comment-start-after`, + * // `data-comment-end-before` and `data-comment-end-after` attributes. + * editor.conversion.for( 'upcast' ).dataToMarker( { + * view: 'comment' + * } ); + * + * An example of a model that may be generated by this conversion: + * + * // View: + *

Foobar

+ *
+ * + * // Model: + * Foo[bar + * ] + * + * Where `[]` are boundaries of a marker that will receive the `comment:commentId:uid` name. + * + * Other examples of usage: + * + * // Using a custom function which is the same as the default conversion: + * editor.conversion.for( 'upcast' ).dataToMarker( { + * view: 'comment', + * model: ( name, conversionApi ) => 'comment:' + name, + * } ); + * + * // Using the converter priority: + * editor.conversion.for( 'upcast' ).dataToMarker( { + * view: 'comment', + * model: ( name, conversionApi ) => 'comment:' + name, + * converterPriority: 'high' + * } ); + * + * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter + * to the conversion process. + * + * @method #dataToMarker + * @param {Object} config Conversion configuration. + * @param {String} config.view The marker group name to convert. + * @param {Function} [config.model] A function that takes the `name` part from the view element or attribute and + * {@link module:engine/conversion/upcastdispatcher~UpcastConversionApi upcast conversion API} and returns the marker name. + * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority. + * @returns {module:engine/conversion/upcasthelpers~UpcastHelpers} + */ + public dataToMarker( config: { + view: string; + model?: MarkerFromAttributeCreatorFunction; + converterPriority?: PriorityString | number; + } ): this { + return this.add( upcastDataToMarker( config ) ); + } +} + +/** + * Function factory, creates a converter that converts {@link module:engine/view/documentfragment~DocumentFragment view document fragment} + * or all children of {@link module:engine/view/element~Element} into + * {@link module:engine/model/documentfragment~DocumentFragment model document fragment}. + * This is the "entry-point" converter for upcast (view to model conversion). This converter starts the conversion of all children + * of passed view document fragment. Those children {@link module:engine/view/node~Node view nodes} are then handled by other converters. + * + * This also a "default", last resort converter for all view elements that has not been converted by other converters. + * When a view element is being converted to the model but it does not have converter specified, that view element + * will be converted to {@link module:engine/model/documentfragment~DocumentFragment model document fragment} and returned. + * + * @returns {Function} Universal converter for view {@link module:engine/view/documentfragment~DocumentFragment fragments} and + * {@link module:engine/view/element~Element elements} that returns + * {@link module:engine/model/documentfragment~DocumentFragment model fragment} with children of converted view item. + */ +export function convertToModelFragment() { + return ( + evt: EventInfo, + data: UpcastConversionData, + conversionApi: UpcastConversionApi + ): void => { + // Second argument in `consumable.consume` is discarded for ViewDocumentFragment but is needed for ViewElement. + if ( !data.modelRange && conversionApi.consumable.consume( data.viewItem, { name: true } ) ) { + const { modelRange, modelCursor } = conversionApi.convertChildren( data.viewItem, data.modelCursor ); + + data.modelRange = modelRange; + data.modelCursor = modelCursor; + } + }; +} + +/** + * Function factory, creates a converter that converts {@link module:engine/view/text~Text} to {@link module:engine/model/text~Text}. + * + * @returns {Function} {@link module:engine/view/text~Text View text} converter. + */ +export function convertText() { + return ( + evt: EventInfo, + data: UpcastConversionData, + { schema, consumable, writer }: UpcastConversionApi + ): void => { + let position = data.modelCursor; + + // When node is already converted then do nothing. + if ( !consumable.test( data.viewItem ) ) { + return; + } + + if ( !schema.checkChild( position, '$text' ) ) { + if ( !isParagraphable( position, '$text', schema ) ) { + return; + } + + // Do not auto-paragraph whitespaces. + if ( data.viewItem.data.trim().length == 0 ) { + return; + } + + position = wrapInParagraph( position, writer ); + } + + consumable.consume( data.viewItem ); + + const text = writer.createText( data.viewItem.data ); + + writer.insert( text, position ); + + data.modelRange = writer.createRange( + position, + position.getShiftedBy( text.offsetSize ) + ); + data.modelCursor = data.modelRange.end; + }; +} + +/** + * Function factory, creates a callback function which converts a {@link module:engine/view/selection~Selection + * view selection} taken from the {@link module:engine/view/document~Document#event:selectionChange} event + * and sets in on the {@link module:engine/model/document~Document#selection model}. + * + * **Note**: because there is no view selection change dispatcher nor any other advanced view selection to model + * conversion mechanism, the callback should be set directly on view document. + * + * view.document.on( 'selectionChange', convertSelectionChange( modelDocument, mapper ) ); + * + * @param {module:engine/model/model~Model} model Data model. + * @param {module:engine/conversion/mapper~Mapper} mapper Conversion mapper. + * @returns {Function} {@link module:engine/view/document~Document#event:selectionChange} callback function. + */ +export function convertSelectionChange( model: Model, mapper: Mapper ) { + return ( + evt: EventInfo, + data: { newSelection: ViewSelection | ViewDocumentSelection } + ): void => { + const viewSelection = data.newSelection; + + const ranges: ModelRange[] = []; + + for ( const viewRange of viewSelection.getRanges() ) { + ranges.push( mapper.toModelRange( viewRange ) ); + } + + const modelSelection = model.createSelection( ranges, { backward: viewSelection.isBackward } ); + + if ( !modelSelection.isEqual( model.document.selection ) ) { + model.change( writer => { + writer.setSelection( modelSelection ); + } ); + } + }; +} + +// View element to model element conversion helper. +// +// See {@link ~UpcastHelpers#elementToElement `.elementToElement()` upcast helper} for examples. +// +// @param {Object} config Conversion configuration. +// @param {module:engine/view/matcher~MatcherPattern} [config.view] Pattern matching all view elements which should be converted. If not +// set, the converter will fire for every view element. +// @param {String|module:engine/model/element~Element|Function} config.model Name of the model element, a model element +// instance or a function that takes a view element and returns a model element. The model element will be inserted in the model. +// @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority. +// @returns {Function} Conversion helper. +function upcastElementToElement( config: { + view: MatcherPattern; + model: string | ElementCreatorFunction; + converterPriority?: PriorityString | number; +} ) { + config = cloneDeep( config ); + + const converter = prepareToElementConverter( config ); + + const elementName = getViewElementNameFromConfig( config.view ); + const eventName = elementName ? `element:${ elementName }` as const : 'element'; + + return ( dispatcher: UpcastDispatcher ) => { + dispatcher.on( eventName, converter, { priority: config.converterPriority || 'normal' } ); + }; +} + +// View element to model attribute conversion helper. +// +// See {@link ~UpcastHelpers#elementToAttribute `.elementToAttribute()` upcast helper} for examples. +// +// @param {Object} config Conversion configuration. +// @param {module:engine/view/matcher~MatcherPattern} config.view Pattern matching all view elements which should be converted. +// @param {String|Object} config.model Model attribute key or an object with `key` and `value` properties, describing +// the model attribute. `value` property may be set as a function that takes a view element and returns the value. +// If `String` is given, the model attribute value will be set to `true`. +// @param {module:utils/priorities~PriorityString} [config.converterPriority='low'] Converter priority. +// @returns {Function} Conversion helper. +function upcastElementToAttribute( config: { + view: MatcherPattern; + model: string | { + key: string; + value: unknown | AttributeCreatorFunction; + }; + converterPriority?: PriorityString | number; +} ) { + config = cloneDeep( config ); + + normalizeModelAttributeConfig( config ); + + const converter = prepareToAttributeConverter( config as any, false ); + + const elementName = getViewElementNameFromConfig( config.view ); + const eventName = elementName ? `element:${ elementName }` as const : 'element'; + + return ( dispatcher: UpcastDispatcher ) => { + dispatcher.on( eventName, converter, { priority: config.converterPriority || 'low' } ); + }; +} + +// View attribute to model attribute conversion helper. +// +// See {@link ~UpcastHelpers#attributeToAttribute `.attributeToAttribute()` upcast helper} for examples. +// +// @param {Object} config Conversion configuration. +// @param {String|Object} config.view Specifies which view attribute will be converted. If a `String` is passed, +// attributes with given key will be converted. If an `Object` is passed, it must have a required `key` property, +// specifying view attribute key, and may have an optional `value` property, specifying view attribute value and optional `name` +// property specifying a view element name from/on which the attribute should be converted. `value` can be given as a `String`, +// a `RegExp` or a function callback, that takes view attribute value as the only parameter and returns `Boolean`. +// @param {String|Object} config.model Model attribute key or an object with `key` and `value` properties, describing +// the model attribute. `value` property may be set as a function that takes a view element and returns the value. +// If `String` is given, the model attribute value will be same as view attribute value. +// @param {module:utils/priorities~PriorityString} [config.converterPriority='low'] Converter priority. +// @returns {Function} Conversion helper. +function upcastAttributeToAttribute( config: { + view: string | { + key?: string; + value?: string | RegExp | ( ( value: unknown ) => boolean ); + name?: string; + styles?: PropertyPatterns; + classes?: ClassPatterns; + attributes?: PropertyPatterns; + }; + model: string | { + key: string; + value: unknown | ( ( viewElement: ViewElement, conversionApi: UpcastConversionApi ) => unknown ); + }; + converterPriority?: PriorityString | number; +} ) { + config = cloneDeep( config ); + + let viewKey: string | null = null; + + if ( typeof config.view == 'string' || config.view.key ) { + viewKey = normalizeViewAttributeKeyValueConfig( config ); + } + + normalizeModelAttributeConfig( config, viewKey ); + + const converter = prepareToAttributeConverter( config as any, true ); + + return ( dispatcher: UpcastDispatcher ) => { + dispatcher.on( 'element', converter, { priority: config.converterPriority || 'low' } ); + }; +} + +// View element to model marker conversion helper. +// +// See {@link ~UpcastHelpers#elementToMarker `.elementToMarker()` upcast helper} for examples. +// +// @param {Object} config Conversion configuration. +// @param {module:engine/view/matcher~MatcherPattern} config.view Pattern matching all view elements which should be converted. +// @param {String|Function} config.model Name of the model marker, or a function that takes a view element and returns +// a model marker name. +// @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority. +// @returns {Function} Conversion helper. +function upcastElementToMarker( config: { + view: MatcherPattern; + model: string | MarkerFromElementCreatorFunction; + converterPriority?: PriorityString | number; +} ) { + const model = normalizeElementToMarkerModelConfig( config.model ); + + return upcastElementToElement( { ...config, model } ); +} + +// View data to model marker conversion helper. +// +// See {@link ~UpcastHelpers#dataToMarker} to learn more. +// +// @param {Object} config +// @param {String} config.view +// @param {Function} [config.model] +// @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] +// @returns {Function} Conversion helper. +function upcastDataToMarker( config: { + view: string; + model?: MarkerFromAttributeCreatorFunction; + converterPriority?: PriorityString | number; +} ) { + config = cloneDeep( config ); + + // Default conversion. + if ( !config.model ) { + config.model = name => { + return name ? config.view + ':' + name : config.view; + }; + } + + const normalizedConfig = { + view: config.view, + model: config.model! + }; + + const converterStart = prepareToElementConverter( normalizeDataToMarkerConfig( normalizedConfig, 'start' ) ); + const converterEnd = prepareToElementConverter( normalizeDataToMarkerConfig( normalizedConfig, 'end' ) ); + + return ( dispatcher: UpcastDispatcher ): void => { + dispatcher.on( + `element:${ config.view }-start`, + converterStart, + { priority: config.converterPriority || 'normal' } + ); + dispatcher.on( + `element:${ config.view }-end`, + converterEnd, + { priority: config.converterPriority || 'normal' } + ); + + // Below is a hack that is needed to properly handle `converterPriority` for both elements and attributes. + // Attribute conversion needs to be performed *after* element conversion. + // This converter handles both element conversion and attribute conversion, which means that if a single + // `config.converterPriority` is used, it will lead to problems. For example, if the `'high'` priority is used, + // the attribute conversion will be performed before a lot of element upcast converters. + // On the other hand, we want to support `config.converterPriority` and converter overwriting. + // + // To make it work, we need to do some extra processing for priority for attribute converter. + // Priority `'low'` value should be the base value and then we will change it depending on `config.converterPriority` value. + // + // This hack probably would not be needed if attributes are upcasted separately. + // + const basePriority = priorities.get( 'low' ); + const maxPriority = priorities.get( 'highest' ); + const priorityFactor = priorities.get( config.converterPriority ) / maxPriority; // Number in range [ -1, 1 ]. + + dispatcher.on( + 'element', + upcastAttributeToMarker( normalizedConfig ), + { priority: basePriority + priorityFactor } + ); + }; +} + +// Function factory, returns a callback function which converts view attributes to a model marker. +// +// The converter looks for elements with `data-group-start-before`, `data-group-start-after`, `data-group-end-before` +// and `data-group-end-after` attributes and inserts `$marker` model elements before/after those elements. +// `group` part is specified in `config.view`. +// +// @param {Object} config +// @param {String} config.view +// @param {Function} [config.model] +// @returns {Function} Marker converter. +function upcastAttributeToMarker( config: { + view: string; + model: MarkerFromAttributeCreatorFunction; +} ) { + return ( + evt: EventInfo, + data: UpcastConversionData, + conversionApi: UpcastConversionApi + ) => { + const attrName = `data-${ config.view }`; + + // Check if any attribute for the given view item can be consumed before changing the conversion data + // and consuming view items with these attributes. + if ( + !conversionApi.consumable.test( data.viewItem, { attributes: attrName + '-end-after' } ) && + !conversionApi.consumable.test( data.viewItem, { attributes: attrName + '-start-after' } ) && + !conversionApi.consumable.test( data.viewItem, { attributes: attrName + '-end-before' } ) && + !conversionApi.consumable.test( data.viewItem, { attributes: attrName + '-start-before' } ) + ) { + return; + } + + // This converter wants to add a model element, marking a marker, before/after an element (or maybe even group of elements). + // To do that, we can use `data.modelRange` which is set on an element (or a group of elements) that has been upcasted. + // But, if the processed view element has not been upcasted yet (it does not have been converted), we need to + // fire conversion for its children first, then we will have `data.modelRange` available. + if ( !data.modelRange ) { + Object.assign( data, conversionApi.convertChildren( data.viewItem, data.modelCursor ) ); + } + + if ( conversionApi.consumable.consume( data.viewItem, { attributes: attrName + '-end-after' } ) ) { + addMarkerElements( data.modelRange!.end, data.viewItem.getAttribute( attrName + '-end-after' )!.split( ',' ) ); + } + + if ( conversionApi.consumable.consume( data.viewItem, { attributes: attrName + '-start-after' } ) ) { + addMarkerElements( data.modelRange!.end, data.viewItem.getAttribute( attrName + '-start-after' )!.split( ',' ) ); + } + + if ( conversionApi.consumable.consume( data.viewItem, { attributes: attrName + '-end-before' } ) ) { + addMarkerElements( data.modelRange!.start, data.viewItem.getAttribute( attrName + '-end-before' )!.split( ',' ) ); + } + + if ( conversionApi.consumable.consume( data.viewItem, { attributes: attrName + '-start-before' } ) ) { + addMarkerElements( data.modelRange!.start, data.viewItem.getAttribute( attrName + '-start-before' )!.split( ',' ) ); + } + + function addMarkerElements( position: ModelPosition, markerViewNames: string[] ): void { + for ( const markerViewName of markerViewNames ) { + const markerName = config.model( markerViewName, conversionApi ); + const element = conversionApi.writer.createElement( '$marker', { 'data-name': markerName } ); + + conversionApi.writer.insert( element, position ); + + if ( data.modelCursor.isEqual( position ) ) { + data.modelCursor = data.modelCursor.getShiftedBy( 1 ); + } else { + data.modelCursor = data.modelCursor._getTransformedByInsertion( position, 1 ); + } + + data.modelRange = data.modelRange!._getTransformedByInsertion( position, 1 )[ 0 ]; + } + } + }; +} + +// Helper function for from-view-element conversion. Checks if `config.view` directly specifies converted view element's name +// and if so, returns it. +// +// @param {Object} config Conversion view config. +// @returns {String|null} View element name or `null` if name is not directly set. +function getViewElementNameFromConfig( viewConfig: any ): string | null { + if ( typeof viewConfig == 'string' ) { + return viewConfig; + } + + if ( typeof viewConfig == 'object' && typeof viewConfig.name == 'string' ) { + return viewConfig.name; + } + + return null; +} + +// Helper for to-model-element conversion. Takes a config object and returns a proper converter function. +// +// @param {Object} config Conversion configuration. +// @returns {Function} View to model converter. +function prepareToElementConverter( config: { + view: MatcherPattern; + model: string | ElementCreatorFunction; +} ) { + const matcher = new Matcher( config.view ); + + return ( + evt: EventInfo, + data: UpcastConversionData, + conversionApi: UpcastConversionApi + ): void => { + const matcherResult = matcher.match( data.viewItem ); + + if ( !matcherResult ) { + return; + } + + const match = matcherResult.match; + + // Force consuming element's name. + match.name = true; + + if ( !conversionApi.consumable.test( data.viewItem, match ) ) { + return; + } + + const modelElement = getModelElement( config.model, data.viewItem, conversionApi ); + + if ( !modelElement ) { + return; + } + + if ( !conversionApi.safeInsert( modelElement, data.modelCursor ) ) { + return; + } + + conversionApi.consumable.consume( data.viewItem, match ); + conversionApi.convertChildren( data.viewItem, modelElement ); + conversionApi.updateConversionResult( modelElement, data ); + }; +} + +// Helper function for upcasting-to-element converter. Takes the model configuration, the converted view element +// and a writer instance and returns a model element instance to be inserted in the model. +// +// @param {String|Function|module:engine/model/element~Element} model Model conversion configuration. +// @param {module:engine/view/node~Node} input The converted view node. +// @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi The upcast conversion API. +function getModelElement( + model: string | ElementCreatorFunction, + input: ViewElement, + conversionApi: UpcastConversionApi +): ModelElement | null { + if ( model instanceof Function ) { + return model( input, conversionApi ); + } else { + return conversionApi.writer.createElement( model ); + } +} + +// Helper function view-attribute-to-model-attribute helper. Normalizes `config.view` which was set as `String` or +// as an `Object` with `key`, `value` and `name` properties. Normalized `config.view` has is compatible with +// {@link module:engine/view/matcher~MatcherPattern}. +// +// @param {Object} config Conversion config. +// @returns {String} Key of the converted view attribute. +function normalizeViewAttributeKeyValueConfig( config: any ) { + if ( typeof config.view == 'string' ) { + config.view = { key: config.view }; + } + + const key: string = config.view.key; + let normalized: MatcherPattern; + + if ( key == 'class' || key == 'style' ) { + const keyName = key == 'class' ? 'classes' : 'styles'; + + normalized = { + [ keyName ]: config.view.value + }; + } else { + const value = typeof config.view.value == 'undefined' ? /[\s\S]*/ : config.view.value; + + normalized = { + attributes: { + [ key ]: value + } + }; + } + + if ( config.view.name ) { + normalized.name = config.view.name; + } + + config.view = normalized; + + return key; +} + +// Helper function that normalizes `config.model` in from-model-attribute conversion. `config.model` can be set +// as a `String`, an `Object` with only `key` property or an `Object` with `key` and `value` properties. Normalized +// `config.model` is an `Object` with `key` and `value` properties. +// +// @param {Object} config Conversion config. +// @param {String} viewAttributeKeyToCopy Key of the converted view attribute. If it is set, model attribute value +// will be equal to view attribute value. +function normalizeModelAttributeConfig( config: any, viewAttributeKeyToCopy: string | null = null ) { + const defaultModelValue = viewAttributeKeyToCopy === null ? true : + ( viewElement: ViewElement ) => viewElement.getAttribute( viewAttributeKeyToCopy ); + + const key = typeof config.model != 'object' ? config.model : config.model.key; + const value = typeof config.model != 'object' || typeof config.model.value == 'undefined' ? defaultModelValue : config.model.value; + + config.model = { key, value }; +} + +// Helper for to-model-attribute conversion. Takes the model attribute name and conversion configuration and returns +// a proper converter function. +// +// @param {String} modelAttributeKey The key of the model attribute to set on a model node. +// @param {Object|Array.} config Conversion configuration. It is possible to provide multiple configurations in an array. +// @param {Boolean} shallow If set to `true` the attribute will be set only on top-level nodes. Otherwise, it will be set +// on all elements in the range. +function prepareToAttributeConverter( + config: { + view: MatcherPattern; + model: { + key: string; + value: AttributeCreatorFunction | unknown; + }; + }, + shallow: boolean +) { + const matcher = new Matcher( config.view ); + + return ( + evt: EventInfo, + data: UpcastConversionData, + conversionApi: UpcastConversionApi + ): void => { + // Converting an attribute of an element that has not been converted to anything does not make sense + // because there will be nowhere to set that attribute on. At this stage, the element should've already + // been converted (https://github.com/ckeditor/ckeditor5/issues/11000). + if ( !data.modelRange && shallow ) { + return; + } + + const match = matcher.match( data.viewItem ); + + // If there is no match, this callback should not do anything. + if ( !match ) { + return; + } + + if ( onlyViewNameIsDefined( config.view, data.viewItem ) ) { + match.match.name = true; + } else { + // Do not test `name` consumable because it could get consumed already while upcasting some other attribute + // on the same element (for example foo). + delete match.match.name; + } + + // Try to consume appropriate values from consumable values list. + if ( !conversionApi.consumable.test( data.viewItem, match.match ) ) { + return; + } + + const modelKey = config.model.key; + const modelValue: unknown = typeof config.model.value == 'function' ? + config.model.value( data.viewItem, conversionApi ) : config.model.value; + + // Do not convert if attribute building function returned falsy value. + if ( modelValue === null ) { + return; + } + + // Since we are converting to attribute we need a range on which we will set the attribute. + // If the range is not created yet, let's create it by converting children of the current node first. + if ( !data.modelRange ) { + // Convert children and set conversion result as a current data. + Object.assign( data, conversionApi.convertChildren( data.viewItem, data.modelCursor ) ); + } + + // Set attribute on current `output`. `Schema` is checked inside this helper function. + const attributeWasSet = setAttributeOn( data.modelRange!, { key: modelKey, value: modelValue }, shallow, conversionApi ); + + // It may happen that a converter will try to set an attribute that is not allowed in the given context. + // In such a situation we cannot consume the attribute. See: https://github.com/ckeditor/ckeditor5/pull/9249#issuecomment-815658459. + if ( attributeWasSet ) { + // Verify if the element itself wasn't consumed yet. It could be consumed already while upcasting some other attribute + // on the same element (for example foo). + // We need to consume it so other features (especially GHS) won't try to convert it. + // Note that it's not tested by the other element-to-attribute converters whether an element was consumed before + // (in case of converters that the element itself is just a context and not the primary information to convert). + if ( conversionApi.consumable.test( data.viewItem, { name: true } ) ) { + match.match.name = true; + } + + conversionApi.consumable.consume( data.viewItem, match.match ); + } + }; +} + +// Helper function that checks if element name should be consumed in attribute converters. +// +// @param {Object} config Conversion view config. +// @returns {Boolean} +function onlyViewNameIsDefined( viewConfig: any, viewItem: ViewElement ): boolean { + // https://github.com/ckeditor/ckeditor5-engine/issues/1786 + const configToTest = typeof viewConfig == 'function' ? viewConfig( viewItem ) : viewConfig; + + if ( typeof configToTest == 'object' && !getViewElementNameFromConfig( configToTest ) ) { + return false; + } + + return !configToTest.classes && !configToTest.attributes && !configToTest.styles; +} + +// Helper function for to-model-attribute converter. Sets model attribute on given range. Checks {@link module:engine/model/schema~Schema} +// to ensure proper model structure. +// +// If any node on the given range has already defined an attribute with the same name, its value will not be updated. +// +// @param {module:engine/model/range~Range} modelRange Model range on which attribute should be set. +// @param {Object} modelAttribute Model attribute to set. +// @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi Conversion API. +// @param {Boolean} shallow If set to `true` the attribute will be set only on top-level nodes. Otherwise, it will be set +// on all elements in the range. +// @returns {Boolean} `true` if attribute was set on at least one node from given `modelRange`. +function setAttributeOn( + modelRange: ModelRange, + modelAttribute: { + key: string; + value: unknown; + }, + shallow: boolean, + conversionApi: UpcastConversionApi +): boolean { + let result = false; + + // Set attribute on each item in range according to Schema. + for ( const node of Array.from( modelRange.getItems( { shallow } ) ) ) { + // Skip if not allowed. + if ( !conversionApi.schema.checkAttribute( node, modelAttribute.key ) ) { + continue; + } + + // Mark the node as consumed even if the attribute will not be updated because it's in a valid context (schema) + // and would be converted if the attribute wouldn't be present. See #8921. + result = true; + + // Do not override the attribute if it's already present. + if ( node.hasAttribute( modelAttribute.key ) ) { + continue; + } + + conversionApi.writer.setAttribute( modelAttribute.key, modelAttribute.value, node ); + } + + return result; +} + +// Helper function for upcasting-to-marker conversion. Takes the config in a format requested by `upcastElementToMarker()` +// function and converts it to a format that is supported by `upcastElementToElement()` function. +// +// @param {Object} config Conversion configuration. +function normalizeElementToMarkerModelConfig( model: string | MarkerFromElementCreatorFunction ): ElementCreatorFunction { + return ( viewElement, conversionApi ) => { + const markerName = typeof model == 'string' ? model : model( viewElement, conversionApi ); + + return conversionApi.writer.createElement( '$marker', { 'data-name': markerName } ); + }; +} + +// Helper function for upcasting-to-marker conversion. Takes the config in a format requested by `upcastDataToMarker()` +// function and converts it to a format that is supported by `upcastElementToElement()` function. +// +// @param {Object} config Conversion configuration. +function normalizeDataToMarkerConfig( + config: { + view: string; + model: MarkerFromAttributeCreatorFunction; + }, + type: string +) { + const elementCreatorFunction: ElementCreatorFunction = ( viewElement, conversionApi ) => { + const viewName = viewElement.getAttribute( 'name' )!; + const markerName = config.model( viewName, conversionApi ); + + return conversionApi.writer.createElement( '$marker', { 'data-name': markerName } ); + }; + + return { + // Upcast and elements. + view: `${ config.view }-${ type }`, + model: elementCreatorFunction + }; +} + +export type ElementCreatorFunction = ( + viewElement: ViewElement, + conversionApi: UpcastConversionApi +) => ModelElement | null; + +export type AttributeCreatorFunction = ( + modelElement: ModelElement, + conversionApi: UpcastConversionApi +) => unknown; + +export type MarkerFromElementCreatorFunction = ( + viewElement: ViewElement, + conversionApi: UpcastConversionApi +) => string; + +export type MarkerFromAttributeCreatorFunction = ( + attributeValue: string, + conversionApi: UpcastConversionApi +) => string; + +export type ModelAttributeDefinition = { + key: string; + value: TValue; +}; diff --git a/packages/ckeditor5-engine/src/conversion/viewconsumable.ts b/packages/ckeditor5-engine/src/conversion/viewconsumable.ts new file mode 100644 index 00000000000..77a72cef5d6 --- /dev/null +++ b/packages/ckeditor5-engine/src/conversion/viewconsumable.ts @@ -0,0 +1,657 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module engine/conversion/viewconsumable + */ + +import { isArray } from 'lodash-es'; +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; + +import type Element from '../view/element'; +import type Node from '../view/node'; +import type Text from '../view/text'; +import type DocumentFragment from '../view/documentfragment'; +import { type Match } from '../view/matcher'; + +/** + * Class used for handling consumption of view {@link module:engine/view/element~Element elements}, + * {@link module:engine/view/text~Text text nodes} and {@link module:engine/view/documentfragment~DocumentFragment document fragments}. + * Element's name and its parts (attributes, classes and styles) can be consumed separately. Consuming an element's name + * does not consume its attributes, classes and styles. + * To add items for consumption use {@link module:engine/conversion/viewconsumable~ViewConsumable#add add method}. + * To test items use {@link module:engine/conversion/viewconsumable~ViewConsumable#test test method}. + * To consume items use {@link module:engine/conversion/viewconsumable~ViewConsumable#consume consume method}. + * To revert already consumed items use {@link module:engine/conversion/viewconsumable~ViewConsumable#revert revert method}. + * + * viewConsumable.add( element, { name: true } ); // Adds element's name as ready to be consumed. + * viewConsumable.add( textNode ); // Adds text node for consumption. + * viewConsumable.add( docFragment ); // Adds document fragment for consumption. + * viewConsumable.test( element, { name: true } ); // Tests if element's name can be consumed. + * viewConsumable.test( textNode ); // Tests if text node can be consumed. + * viewConsumable.test( docFragment ); // Tests if document fragment can be consumed. + * viewConsumable.consume( element, { name: true } ); // Consume element's name. + * viewConsumable.consume( textNode ); // Consume text node. + * viewConsumable.consume( docFragment ); // Consume document fragment. + * viewConsumable.revert( element, { name: true } ); // Revert already consumed element's name. + * viewConsumable.revert( textNode ); // Revert already consumed text node. + * viewConsumable.revert( docFragment ); // Revert already consumed document fragment. + */ +export default class ViewConsumable { + private _consumables: Map; + + /** + * Creates new ViewConsumable. + */ + constructor() { + /** + * Map of consumable elements. If {@link module:engine/view/element~Element element} is used as a key, + * {@link module:engine/conversion/viewconsumable~ViewElementConsumables ViewElementConsumables} instance is stored as value. + * For {@link module:engine/view/text~Text text nodes} and + * {@link module:engine/view/documentfragment~DocumentFragment document fragments} boolean value is stored as value. + * + * @protected + * @member {Map.} + */ + this._consumables = new Map(); + } + + public add( + element: Text | DocumentFragment + ): void; + public add( + element: Element, + consumables: Consumables + ): void; + + /** + * Adds {@link module:engine/view/element~Element view element}, {@link module:engine/view/text~Text text node} or + * {@link module:engine/view/documentfragment~DocumentFragment document fragment} as ready to be consumed. + * + * viewConsumable.add( p, { name: true } ); // Adds element's name to consume. + * viewConsumable.add( p, { attributes: 'name' } ); // Adds element's attribute. + * viewConsumable.add( p, { classes: 'foobar' } ); // Adds element's class. + * viewConsumable.add( p, { styles: 'color' } ); // Adds element's style + * viewConsumable.add( p, { attributes: 'name', styles: 'color' } ); // Adds attribute and style. + * viewConsumable.add( p, { classes: [ 'baz', 'bar' ] } ); // Multiple consumables can be provided. + * viewConsumable.add( textNode ); // Adds text node to consume. + * viewConsumable.add( docFragment ); // Adds document fragment to consume. + * + * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `viewconsumable-invalid-attribute` when `class` or `style` + * attribute is provided - it should be handled separately by providing actual style/class. + * + * viewConsumable.add( p, { attributes: 'style' } ); // This call will throw an exception. + * viewConsumable.add( p, { styles: 'color' } ); // This is properly handled style. + * + * @param {module:engine/view/element~Element|module:engine/view/text~Text|module:engine/view/documentfragment~DocumentFragment} element + * @param {Object} [consumables] Used only if first parameter is {@link module:engine/view/element~Element view element} instance. + * @param {Boolean} consumables.name If set to true element's name will be included. + * @param {String|Array.} consumables.attributes Attribute name or array of attribute names. + * @param {String|Array.} consumables.classes Class name or array of class names. + * @param {String|Array.} consumables.styles Style name or array of style names. + */ + public add( + element: Node | DocumentFragment, + consumables?: Consumables + ): void { + let elementConsumables: ViewElementConsumables; + + // For text nodes and document fragments just mark them as consumable. + if ( element.is( '$text' ) || element.is( 'documentFragment' ) ) { + this._consumables.set( element, true ); + + return; + } + + // For elements create new ViewElementConsumables or update already existing one. + if ( !this._consumables.has( element ) ) { + elementConsumables = new ViewElementConsumables( element ); + this._consumables.set( element, elementConsumables ); + } else { + elementConsumables = this._consumables.get( element ) as any; + } + + elementConsumables.add( consumables! ); + } + + /** + * Tests if {@link module:engine/view/element~Element view element}, {@link module:engine/view/text~Text text node} or + * {@link module:engine/view/documentfragment~DocumentFragment document fragment} can be consumed. + * It returns `true` when all items included in method's call can be consumed. Returns `false` when + * first already consumed item is found and `null` when first non-consumable item is found. + * + * viewConsumable.test( p, { name: true } ); // Tests element's name. + * viewConsumable.test( p, { attributes: 'name' } ); // Tests attribute. + * viewConsumable.test( p, { classes: 'foobar' } ); // Tests class. + * viewConsumable.test( p, { styles: 'color' } ); // Tests style. + * viewConsumable.test( p, { attributes: 'name', styles: 'color' } ); // Tests attribute and style. + * viewConsumable.test( p, { classes: [ 'baz', 'bar' ] } ); // Multiple consumables can be tested. + * viewConsumable.test( textNode ); // Tests text node. + * viewConsumable.test( docFragment ); // Tests document fragment. + * + * Testing classes and styles as attribute will test if all added classes/styles can be consumed. + * + * viewConsumable.test( p, { attributes: 'class' } ); // Tests if all added classes can be consumed. + * viewConsumable.test( p, { attributes: 'style' } ); // Tests if all added styles can be consumed. + * + * @param {module:engine/view/element~Element|module:engine/view/text~Text|module:engine/view/documentfragment~DocumentFragment} element + * @param {Object} [consumables] Used only if first parameter is {@link module:engine/view/element~Element view element} instance. + * @param {Boolean} consumables.name If set to true element's name will be included. + * @param {String|Array.} consumables.attributes Attribute name or array of attribute names. + * @param {String|Array.} consumables.classes Class name or array of class names. + * @param {String|Array.} consumables.styles Style name or array of style names. + * @returns {Boolean|null} Returns `true` when all items included in method's call can be consumed. Returns `false` + * when first already consumed item is found and `null` when first non-consumable item is found. + */ + public test( element: Node | DocumentFragment, consumables?: Consumables | Match ): boolean | null { + const elementConsumables = this._consumables.get( element ); + + if ( elementConsumables === undefined ) { + return null; + } + + // For text nodes and document fragments return stored boolean value. + if ( element.is( '$text' ) || element.is( 'documentFragment' ) ) { + return elementConsumables as boolean; + } + + // For elements test consumables object. + return ( elementConsumables as ViewElementConsumables ).test( consumables! ); + } + + /** + * Consumes {@link module:engine/view/element~Element view element}, {@link module:engine/view/text~Text text node} or + * {@link module:engine/view/documentfragment~DocumentFragment document fragment}. + * It returns `true` when all items included in method's call can be consumed, otherwise returns `false`. + * + * viewConsumable.consume( p, { name: true } ); // Consumes element's name. + * viewConsumable.consume( p, { attributes: 'name' } ); // Consumes element's attribute. + * viewConsumable.consume( p, { classes: 'foobar' } ); // Consumes element's class. + * viewConsumable.consume( p, { styles: 'color' } ); // Consumes element's style. + * viewConsumable.consume( p, { attributes: 'name', styles: 'color' } ); // Consumes attribute and style. + * viewConsumable.consume( p, { classes: [ 'baz', 'bar' ] } ); // Multiple consumables can be consumed. + * viewConsumable.consume( textNode ); // Consumes text node. + * viewConsumable.consume( docFragment ); // Consumes document fragment. + * + * Consuming classes and styles as attribute will test if all added classes/styles can be consumed. + * + * viewConsumable.consume( p, { attributes: 'class' } ); // Consume only if all added classes can be consumed. + * viewConsumable.consume( p, { attributes: 'style' } ); // Consume only if all added styles can be consumed. + * + * @param {module:engine/view/element~Element|module:engine/view/text~Text|module:engine/view/documentfragment~DocumentFragment} element + * @param {Object} [consumables] Used only if first parameter is {@link module:engine/view/element~Element view element} instance. + * @param {Boolean} consumables.name If set to true element's name will be included. + * @param {String|Array.} consumables.attributes Attribute name or array of attribute names. + * @param {String|Array.} consumables.classes Class name or array of class names. + * @param {String|Array.} consumables.styles Style name or array of style names. + * @returns {Boolean} Returns `true` when all items included in method's call can be consumed, + * otherwise returns `false`. + */ + public consume( element: Node | DocumentFragment, consumables?: Consumables | Match ): boolean { + if ( this.test( element, consumables ) ) { + if ( element.is( '$text' ) || element.is( 'documentFragment' ) ) { + // For text nodes and document fragments set value to false. + this._consumables.set( element, false ); + } else { + // For elements - consume consumables object. + ( this._consumables.get( element ) as ViewElementConsumables ).consume( consumables! ); + } + + return true; + } + + return false; + } + + /** + * Reverts {@link module:engine/view/element~Element view element}, {@link module:engine/view/text~Text text node} or + * {@link module:engine/view/documentfragment~DocumentFragment document fragment} so they can be consumed once again. + * Method does not revert items that were never previously added for consumption, even if they are included in + * method's call. + * + * viewConsumable.revert( p, { name: true } ); // Reverts element's name. + * viewConsumable.revert( p, { attributes: 'name' } ); // Reverts element's attribute. + * viewConsumable.revert( p, { classes: 'foobar' } ); // Reverts element's class. + * viewConsumable.revert( p, { styles: 'color' } ); // Reverts element's style. + * viewConsumable.revert( p, { attributes: 'name', styles: 'color' } ); // Reverts attribute and style. + * viewConsumable.revert( p, { classes: [ 'baz', 'bar' ] } ); // Multiple names can be reverted. + * viewConsumable.revert( textNode ); // Reverts text node. + * viewConsumable.revert( docFragment ); // Reverts document fragment. + * + * Reverting classes and styles as attribute will revert all classes/styles that were previously added for + * consumption. + * + * viewConsumable.revert( p, { attributes: 'class' } ); // Reverts all classes added for consumption. + * viewConsumable.revert( p, { attributes: 'style' } ); // Reverts all styles added for consumption. + * + * @param {module:engine/view/element~Element|module:engine/view/text~Text|module:engine/view/documentfragment~DocumentFragment} element + * @param {Object} [consumables] Used only if first parameter is {@link module:engine/view/element~Element view element} instance. + * @param {Boolean} consumables.name If set to true element's name will be included. + * @param {String|Array.} consumables.attributes Attribute name or array of attribute names. + * @param {String|Array.} consumables.classes Class name or array of class names. + * @param {String|Array.} consumables.styles Style name or array of style names. + */ + public revert( element: Node, consumables: Consumables ): void { + const elementConsumables = this._consumables.get( element ); + + if ( elementConsumables !== undefined ) { + if ( element.is( '$text' ) || element.is( 'documentFragment' ) ) { + // For text nodes and document fragments - set consumable to true. + this._consumables.set( element, true ); + } else { + // For elements - revert items from consumables object. + ( elementConsumables as ViewElementConsumables ).revert( consumables ); + } + } + } + + /** + * Creates consumable object from {@link module:engine/view/element~Element view element}. Consumable object will include + * element's name and all its attributes, classes and styles. + * + * @static + * @param {module:engine/view/element~Element} element + * @returns {Object} consumables + */ + public static consumablesFromElement( element: Element ): Consumables & { element: Element } { + const consumables = { + element, + name: true, + attributes: [] as string[], + classes: [] as string[], + styles: [] as string[] + }; + + const attributes = element.getAttributeKeys(); + + for ( const attribute of attributes ) { + // Skip classes and styles - will be added separately. + if ( attribute == 'style' || attribute == 'class' ) { + continue; + } + + consumables.attributes.push( attribute ); + } + + const classes = element.getClassNames(); + + for ( const className of classes ) { + consumables.classes.push( className ); + } + + const styles = element.getStyleNames(); + + for ( const style of styles ) { + consumables.styles.push( style ); + } + + return consumables; + } + + /** + * Creates {@link module:engine/conversion/viewconsumable~ViewConsumable ViewConsumable} instance from + * {@link module:engine/view/node~Node node} or {@link module:engine/view/documentfragment~DocumentFragment document fragment}. + * Instance will contain all elements, child nodes, attributes, styles and classes added for consumption. + * + * @static + * @param {module:engine/view/node~Node|module:engine/view/documentfragment~DocumentFragment} from View node or document fragment + * from which `ViewConsumable` will be created. + * @param {module:engine/conversion/viewconsumable~ViewConsumable} [instance] If provided, given `ViewConsumable` instance will be used + * to add all consumables. It will be returned instead of a new instance. + */ + public static createFrom( from: Node | DocumentFragment, instance?: ViewConsumable ): ViewConsumable { + if ( !instance ) { + instance = new ViewConsumable(); + } + + if ( from.is( '$text' ) ) { + instance.add( from ); + + return instance; + } + + // Add `from` itself, if it is an element. + if ( from.is( 'element' ) ) { + instance.add( from, ViewConsumable.consumablesFromElement( from ) ); + } + + if ( from.is( 'documentFragment' ) ) { + instance.add( from ); + } + + for ( const child of ( from as Element | DocumentFragment ).getChildren() ) { + instance = ViewConsumable.createFrom( child, instance ); + } + + return instance; + } +} + +export interface Consumables { + name?: boolean; + attributes?: string | string[]; + classes?: string | string[]; + styles?: string | string[]; +} + +const CONSUMABLE_TYPES = [ 'attributes', 'classes', 'styles' ] as const; + +type ConsumableType = ( typeof CONSUMABLE_TYPES )[ number ]; + +/** + * This is a private helper-class for {@link module:engine/conversion/viewconsumable~ViewConsumable}. + * It represents and manipulates consumable parts of a single {@link module:engine/view/element~Element}. + * + * @private + */ +class ViewElementConsumables { + public element: Node | DocumentFragment; + private _canConsumeName: boolean | null; + private readonly _consumables: Record>; + + /** + * Creates ViewElementConsumables instance. + * + * @param {module:engine/view/node~Node|module:engine/view/documentfragment~DocumentFragment} from View node or document fragment + * from which `ViewElementConsumables` is being created. + */ + constructor( from: Node | DocumentFragment ) { + /** + * @readonly + * @member {module:engine/view/node~Node|module:engine/view/documentfragment~DocumentFragment} + */ + this.element = from; + + /** + * Flag indicating if name of the element can be consumed. + * + * @private + * @member {Boolean} + */ + this._canConsumeName = null; + + /** + * Contains maps of element's consumables: attributes, classes and styles. + * + * @private + * @member {Object} + */ + this._consumables = { + attributes: new Map(), + styles: new Map(), + classes: new Map() + }; + } + + /** + * Adds consumable parts of the {@link module:engine/view/element~Element view element}. + * Element's name itself can be marked to be consumed (when element's name is consumed its attributes, classes and + * styles still could be consumed): + * + * consumables.add( { name: true } ); + * + * Attributes classes and styles: + * + * consumables.add( { attributes: 'title', classes: 'foo', styles: 'color' } ); + * consumables.add( { attributes: [ 'title', 'name' ], classes: [ 'foo', 'bar' ] ); + * + * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `viewconsumable-invalid-attribute` when `class` or `style` + * attribute is provided - it should be handled separately by providing `style` and `class` in consumables object. + * + * @param {Object} consumables Object describing which parts of the element can be consumed. + * @param {Boolean} consumables.name If set to `true` element's name will be added as consumable. + * @param {String|Array.} consumables.attributes Attribute name or array of attribute names to add as consumable. + * @param {String|Array.} consumables.classes Class name or array of class names to add as consumable. + * @param {String|Array.} consumables.styles Style name or array of style names to add as consumable. + */ + public add( consumables: Consumables ): void { + if ( consumables.name ) { + this._canConsumeName = true; + } + + for ( const type of CONSUMABLE_TYPES ) { + if ( type in consumables ) { + this._add( type, consumables[ type ]! ); + } + } + } + + /** + * Tests if parts of the {@link module:engine/view/node~Node view node} can be consumed. + * + * Element's name can be tested: + * + * consumables.test( { name: true } ); + * + * Attributes classes and styles: + * + * consumables.test( { attributes: 'title', classes: 'foo', styles: 'color' } ); + * consumables.test( { attributes: [ 'title', 'name' ], classes: [ 'foo', 'bar' ] ); + * + * @param {Object} consumables Object describing which parts of the element should be tested. + * @param {Boolean} consumables.name If set to `true` element's name will be tested. + * @param {String|Array.} consumables.attributes Attribute name or array of attribute names to test. + * @param {String|Array.} consumables.classes Class name or array of class names to test. + * @param {String|Array.} consumables.styles Style name or array of style names to test. + * @returns {Boolean|null} `true` when all tested items can be consumed, `null` when even one of the items + * was never marked for consumption and `false` when even one of the items was already consumed. + */ + public test( consumables: Consumables | Match ): boolean | null { + // Check if name can be consumed. + if ( consumables.name && !this._canConsumeName ) { + return this._canConsumeName; + } + + for ( const type of CONSUMABLE_TYPES ) { + if ( type in consumables ) { + const value = this._test( type, consumables[ type ]! ); + + if ( value !== true ) { + return value; + } + } + } + + // Return true only if all can be consumed. + return true; + } + + /** + * Consumes parts of {@link module:engine/view/element~Element view element}. This function does not check if consumable item + * is already consumed - it consumes all consumable items provided. + * Element's name can be consumed: + * + * consumables.consume( { name: true } ); + * + * Attributes classes and styles: + * + * consumables.consume( { attributes: 'title', classes: 'foo', styles: 'color' } ); + * consumables.consume( { attributes: [ 'title', 'name' ], classes: [ 'foo', 'bar' ] ); + * + * @param {Object} consumables Object describing which parts of the element should be consumed. + * @param {Boolean} consumables.name If set to `true` element's name will be consumed. + * @param {String|Array.} consumables.attributes Attribute name or array of attribute names to consume. + * @param {String|Array.} consumables.classes Class name or array of class names to consume. + * @param {String|Array.} consumables.styles Style name or array of style names to consume. + */ + public consume( consumables: Consumables | Match ): void { + if ( consumables.name ) { + this._canConsumeName = false; + } + + for ( const type of CONSUMABLE_TYPES ) { + if ( type in consumables ) { + this._consume( type, consumables[ type ]! ); + } + } + } + + /** + * Revert already consumed parts of {@link module:engine/view/element~Element view Element}, so they can be consumed once again. + * Element's name can be reverted: + * + * consumables.revert( { name: true } ); + * + * Attributes classes and styles: + * + * consumables.revert( { attributes: 'title', classes: 'foo', styles: 'color' } ); + * consumables.revert( { attributes: [ 'title', 'name' ], classes: [ 'foo', 'bar' ] ); + * + * @param {Object} consumables Object describing which parts of the element should be reverted. + * @param {Boolean} consumables.name If set to `true` element's name will be reverted. + * @param {String|Array.} consumables.attributes Attribute name or array of attribute names to revert. + * @param {String|Array.} consumables.classes Class name or array of class names to revert. + * @param {String|Array.} consumables.styles Style name or array of style names to revert. + */ + public revert( consumables: Consumables ): void { + if ( consumables.name ) { + this._canConsumeName = true; + } + + for ( const type of CONSUMABLE_TYPES ) { + if ( type in consumables ) { + this._revert( type, consumables[ type ]! ); + } + } + } + + /** + * Helper method that adds consumables of a given type: attribute, class or style. + * + * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `viewconsumable-invalid-attribute` when `class` or `style` + * type is provided - it should be handled separately by providing actual style/class type. + * + * @private + * @param {String} type Type of the consumable item: `attributes`, `classes` or `styles`. + * @param {String|Array.} item Consumable item or array of items. + */ + private _add( type: ConsumableType, item: string | string[] ) { + const items = isArray( item ) ? item : [ item ]; + const consumables = this._consumables[ type ]; + + for ( const name of items ) { + if ( type === 'attributes' && ( name === 'class' || name === 'style' ) ) { + /** + * Class and style attributes should be handled separately in + * {@link module:engine/conversion/viewconsumable~ViewConsumable#add `ViewConsumable#add()`}. + * + * What you have done is trying to use: + * + * consumables.add( { attributes: [ 'class', 'style' ] } ); + * + * While each class and style should be registered separately: + * + * consumables.add( { classes: 'some-class', styles: 'font-weight' } ); + * + * @error viewconsumable-invalid-attribute + */ + throw new CKEditorError( 'viewconsumable-invalid-attribute', this ); + } + + consumables.set( name, true ); + + if ( type === 'styles' ) { + for ( const alsoName of this.element.document.stylesProcessor.getRelatedStyles( name ) ) { + consumables.set( alsoName, true ); + } + } + } + } + + /** + * Helper method that tests consumables of a given type: attribute, class or style. + * + * @private + * @param {String} type Type of the consumable item: `attributes`, `classes` or `styles`. + * @param {String|Array.} item Consumable item or array of items. + * @returns {Boolean|null} Returns `true` if all items can be consumed, `null` when one of the items cannot be + * consumed and `false` when one of the items is already consumed. + */ + private _test( type: ConsumableType, item: string | string[] ): boolean | null { + const items = isArray( item ) ? item : [ item ]; + const consumables = this._consumables[ type ]; + + for ( const name of items ) { + if ( type === 'attributes' && ( name === 'class' || name === 'style' ) ) { + const consumableName = name == 'class' ? 'classes' : 'styles'; + + // Check all classes/styles if class/style attribute is tested. + const value = this._test( consumableName, [ ...this._consumables[ consumableName ].keys() ] ); + + if ( value !== true ) { + return value; + } + } else { + const value = consumables.get( name ); + // Return null if attribute is not found. + if ( value === undefined ) { + return null; + } + + if ( !value ) { + return false; + } + } + } + + return true; + } + + /** + * Helper method that consumes items of a given type: attribute, class or style. + * + * @private + * @param {String} type Type of the consumable item: `attributes`, `classes` or `styles`. + * @param {String|Array.} item Consumable item or array of items. + */ + private _consume( type: ConsumableType, item: string | string[] ) { + const items = isArray( item ) ? item : [ item ]; + const consumables = this._consumables[ type ]; + + for ( const name of items ) { + if ( type === 'attributes' && ( name === 'class' || name === 'style' ) ) { + const consumableName = name == 'class' ? 'classes' : 'styles'; + + // If class or style is provided for consumption - consume them all. + this._consume( consumableName, [ ...this._consumables[ consumableName ].keys() ] ); + } else { + consumables.set( name, false ); + + if ( type == 'styles' ) { + for ( const toConsume of this.element.document.stylesProcessor.getRelatedStyles( name ) ) { + consumables.set( toConsume, false ); + } + } + } + } + } + + /** + * Helper method that reverts items of a given type: attribute, class or style. + * + * @private + * @param {String} type Type of the consumable item: `attributes`, `classes` or , `styles`. + * @param {String|Array.} item Consumable item or array of items. + */ + private _revert( type: ConsumableType, item: string | string[] ) { + const items = isArray( item ) ? item : [ item ]; + const consumables = this._consumables[ type ]; + + for ( const name of items ) { + if ( type === 'attributes' && ( name === 'class' || name === 'style' ) ) { + const consumableName = name == 'class' ? 'classes' : 'styles'; + + // If class or style is provided for reverting - revert them all. + this._revert( consumableName, [ ...this._consumables[ consumableName ].keys() ] ); + } else { + const value = consumables.get( name ); + + if ( value === false ) { + consumables.set( name, true ); + } + } + } + } +} diff --git a/packages/ckeditor5-engine/src/dataprocessor/basichtmlwriter.ts b/packages/ckeditor5-engine/src/dataprocessor/basichtmlwriter.ts new file mode 100644 index 00000000000..f3c696c2c3f --- /dev/null +++ b/packages/ckeditor5-engine/src/dataprocessor/basichtmlwriter.ts @@ -0,0 +1,34 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import type HtmlWriter from './htmlwriter'; + +/** + * @module engine/dataprocessor/basichtmlwriter + */ + +/* globals document */ + +/** + * Basic HTML writer. It uses the native `innerHTML` property for basic conversion + * from a document fragment to an HTML string. + * + * @implements module:engine/dataprocessor/htmlwriter~HtmlWriter + */ +export default class BasicHtmlWriter implements HtmlWriter { + /** + * Returns an HTML string created from the document fragment. + * + * @param {DocumentFragment} fragment + * @returns {String} + */ + public getHtml( fragment: DocumentFragment ): string { + const doc = document.implementation.createHTMLDocument( '' ); + const container = doc.createElement( 'div' ); + container.appendChild( fragment ); + + return container.innerHTML; + } +} diff --git a/packages/ckeditor5-engine/src/dataprocessor/dataprocessor.ts b/packages/ckeditor5-engine/src/dataprocessor/dataprocessor.ts new file mode 100644 index 00000000000..27cc124005a --- /dev/null +++ b/packages/ckeditor5-engine/src/dataprocessor/dataprocessor.ts @@ -0,0 +1,74 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module engine/dataprocessor/dataprocessor + */ + +import type ViewDocumentFragment from '../view/documentfragment'; +import { type MatcherPattern } from '../view/matcher'; + +/** + * The data processor interface. It should be implemented by actual data processors. + * + * Each data processor implements a certain format of the data. For example, {@glink features/markdown Markdown data processor} + * will convert the data (a Markdown string) to a {@link module:engine/view/documentfragment~DocumentFragment document fragment} and back. + * + * **Note:** While the CKEditor 5 architecture supports changing the data format, in most scenarios we do recommend sticking to + * the default format which is HTML (supported by the {@link module:engine/dataprocessor/htmldataprocessor~HtmlDataProcessor}). + * HTML remains [the best standard for rich-text data](https://medium.com/content-uneditable/a-standard-for-rich-text-data-4b3a507af552). + * + * And please do remember – using Markdown [does not automatically make your + * application/website secure](https://github.com/ckeditor/ckeditor5-markdown-gfm/issues/16#issuecomment-375752994). + * + * @interface DataProcessor + */ + +/** + * Converts a {@link module:engine/view/documentfragment~DocumentFragment document fragment} to data. + * + * @method #toData + * @param {module:engine/view/documentfragment~DocumentFragment} fragment The document fragment to be processed. + * @returns {*} + */ + +/** + * Converts the data to a {@link module:engine/view/documentfragment~DocumentFragment document fragment}. + * + * @method #toView + * @param {*} data The data to be processed. + * @returns {module:engine/view/documentfragment~DocumentFragment} + */ + +/** + * Registers a {@link module:engine/view/matcher~MatcherPattern} for view elements whose content should be treated as raw data + * and its content should be converted to a + * {@link module:engine/view/element~Element#getCustomProperty custom property of a view element} called `"$rawContent"` while + * converting {@link #toView to view}. + * + * @method #registerRawContentMatcher + * @param {module:engine/view/matcher~MatcherPattern} pattern Pattern matching all view elements whose content should + * be treated as plain text. + */ + +/** + * If the processor is set to use marked fillers, it will insert ` ` fillers wrapped in `` elements + * (` `) instead of regular ` ` characters. + * + * This mode allows for more precise handling of block fillers (so they do not leak into the editor content) but bloats the + * editor data with additional markup. + * + * This mode may be required by some features and will be turned on by them automatically. + * + * @method #useFillerType + * @param {'default'|'marked'} type Whether to use the default or marked ` ` block fillers. + */ + +export default interface DataProcessor { + toData( viewFragment: ViewDocumentFragment ): string; + toView( data: string ): ViewDocumentFragment; + registerRawContentMatcher( pattern: MatcherPattern ): void; + useFillerType( type: 'default' | 'marked' ): void; +} diff --git a/packages/ckeditor5-engine/src/dataprocessor/htmldataprocessor.ts b/packages/ckeditor5-engine/src/dataprocessor/htmldataprocessor.ts new file mode 100644 index 00000000000..16a46c0451c --- /dev/null +++ b/packages/ckeditor5-engine/src/dataprocessor/htmldataprocessor.ts @@ -0,0 +1,144 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module engine/dataprocessor/htmldataprocessor + */ + +/* globals DOMParser */ + +import BasicHtmlWriter from './basichtmlwriter'; +import DomConverter from '../view/domconverter'; + +import type DataProcessor from './dataprocessor'; +import type HtmlWriter from './htmlwriter'; +import type ViewDocument from '../view/document'; +import type ViewDocumentFragment from '../view/documentfragment'; +import { type MatcherPattern } from '../view/matcher'; + +/** + * The HTML data processor class. + * This data processor implementation uses HTML as input and output data. + * + * @implements module:engine/dataprocessor/dataprocessor~DataProcessor + */ +export default class HtmlDataProcessor implements DataProcessor { + public domParser: DOMParser; + public domConverter: DomConverter; + public htmlWriter: HtmlWriter; + + /** + * Creates a new instance of the HTML data processor class. + * + * @param {module:engine/view/document~Document} document The view document instance. + */ + constructor( document: ViewDocument ) { + /** + * A DOM parser instance used to parse an HTML string to an HTML document. + * + * @member {DOMParser} + */ + this.domParser = new DOMParser(); + + /** + * A DOM converter used to convert DOM elements to view elements. + * + * @member {module:engine/view/domconverter~DomConverter} + */ + this.domConverter = new DomConverter( document, { renderingMode: 'data' } ); + + /** + * A basic HTML writer instance used to convert DOM elements to an HTML string. + * + * @member {module:engine/dataprocessor/htmlwriter~HtmlWriter} + */ + this.htmlWriter = new BasicHtmlWriter(); + } + + /** + * Converts a provided {@link module:engine/view/documentfragment~DocumentFragment document fragment} + * to data format — in this case to an HTML string. + * + * @param {module:engine/view/documentfragment~DocumentFragment} viewFragment + * @returns {String} HTML string. + */ + public toData( viewFragment: ViewDocumentFragment ): string { + // Convert view DocumentFragment to DOM DocumentFragment. + const domFragment = this.domConverter.viewToDom( viewFragment ) as DocumentFragment; + + // Convert DOM DocumentFragment to HTML output. + return this.htmlWriter.getHtml( domFragment ); + } + + /** + * Converts the provided HTML string to a view tree. + * + * @param {String} data An HTML string. + * @returns {module:engine/view/node~Node|module:engine/view/documentfragment~DocumentFragment|null} A converted view element. + */ + public toView( data: string ): ViewDocumentFragment { + // Convert input HTML data to DOM DocumentFragment. + const domFragment = this._toDom( data ); + + // Convert DOM DocumentFragment to view DocumentFragment. + return this.domConverter.domToView( domFragment ) as ViewDocumentFragment; + } + + /** + * Registers a {@link module:engine/view/matcher~MatcherPattern} for view elements whose content should be treated as raw data + * and not processed during the conversion from the DOM to the view elements. + * + * The raw data can be later accessed by a + * {@link module:engine/view/element~Element#getCustomProperty custom property of a view element} called `"$rawContent"`. + * + * @param {module:engine/view/matcher~MatcherPattern} pattern Pattern matching all view elements whose content should + * be treated as raw data. + */ + public registerRawContentMatcher( pattern: MatcherPattern ): void { + this.domConverter.registerRawContentMatcher( pattern ); + } + + /** + * If the processor is set to use marked fillers, it will insert ` ` fillers wrapped in `` elements + * (` `) instead of regular ` ` characters. + * + * This mode allows for a more precise handling of the block fillers (so they do not leak into the editor content) but + * bloats the editor data with additional markup. + * + * This mode may be required by some features and will be turned on by them automatically. + * + * @param {'default'|'marked'} type Whether to use the default or the marked ` ` block fillers. + */ + public useFillerType( type: 'default' | 'marked' ): void { + this.domConverter.blockFillerMode = type == 'marked' ? 'markedNbsp' : 'nbsp'; + } + + /** + * Converts an HTML string to its DOM representation. Returns a document fragment containing nodes parsed from + * the provided data. + * + * @private + * @param {String} data + * @returns {DocumentFragment} + */ + private _toDom( data: string ): DocumentFragment { + // Wrap data with a tag so leading non-layout nodes (like