multi-root-editor.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. /**
  2. * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  4. */
  5. /* globals console:false, document, window */
  6. // Multiroot editor dependencies.
  7. import Editor from '@ckeditor/ckeditor5-core/src/editor/editor';
  8. import DataApiMixin from '@ckeditor/ckeditor5-core/src/editor/utils/dataapimixin';
  9. import HtmlDataProcessor from '@ckeditor/ckeditor5-engine/src/dataprocessor/htmldataprocessor';
  10. import getDataFromElement from '@ckeditor/ckeditor5-utils/src/dom/getdatafromelement';
  11. import setDataInElement from '@ckeditor/ckeditor5-utils/src/dom/setdatainelement';
  12. import mix from '@ckeditor/ckeditor5-utils/src/mix';
  13. import EditorUI from '@ckeditor/ckeditor5-core/src/editor/editorui';
  14. import enableToolbarKeyboardFocus from '@ckeditor/ckeditor5-ui/src/toolbar/enabletoolbarkeyboardfocus';
  15. import normalizeToolbarConfig from '@ckeditor/ckeditor5-ui/src/toolbar/normalizetoolbarconfig';
  16. import { enablePlaceholder } from '@ckeditor/ckeditor5-engine/src/view/placeholder';
  17. import EditorUIView from '@ckeditor/ckeditor5-ui/src/editorui/editoruiview';
  18. import InlineEditableUIView from '@ckeditor/ckeditor5-ui/src/editableui/inline/inlineeditableuiview';
  19. import ToolbarView from '@ckeditor/ckeditor5-ui/src/toolbar/toolbarview';
  20. import Template from '@ckeditor/ckeditor5-ui/src/template';
  21. // Editor sample dependencies.
  22. import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
  23. import Heading from '@ckeditor/ckeditor5-heading/src/heading';
  24. import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
  25. import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
  26. import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
  27. import List from '@ckeditor/ckeditor5-list/src/list';
  28. import Link from '@ckeditor/ckeditor5-link/src/link';
  29. import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote';
  30. import Image from '@ckeditor/ckeditor5-image/src/image';
  31. import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption';
  32. import ImageStyle from '@ckeditor/ckeditor5-image/src/imagestyle';
  33. import ImageToolbar from '@ckeditor/ckeditor5-image/src/imagetoolbar';
  34. import ImageUpload from '@ckeditor/ckeditor5-image/src/imageupload';
  35. import ImageResize from '@ckeditor/ckeditor5-image/src/imageresize';
  36. import Table from '@ckeditor/ckeditor5-table/src/table';
  37. import TableToolbar from '@ckeditor/ckeditor5-table/src/tabletoolbar';
  38. import MediaEmbed from '@ckeditor/ckeditor5-media-embed/src/mediaembed';
  39. import EasyImage from '@ckeditor/ckeditor5-easy-image/src/easyimage';
  40. import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
  41. /**
  42. * The multiroot editor implementation. It provides an inline editables and a toolbar.
  43. *
  44. * Unlike other editors, the toolbar is not rendered automatically and needs to be attached to the DOM manually.
  45. *
  46. * This type of an editor is dedicated to integrations which require a customized UI with an open
  47. * structure, allowing developers to specify the exact location of the interface.
  48. *
  49. * @mixes module:core/editor/utils/dataapimixin~DataApiMixin
  50. * @implements module:core/editor/editorwithui~EditorWithUI
  51. * @extends module:core/editor/editor~Editor
  52. */
  53. class MultirootEditor extends Editor {
  54. /**
  55. * Creates an instance of the multiroot editor.
  56. *
  57. * **Note:** Do not use the constructor to create editor instances. Use the static `MultirootEditor.create()` method instead.
  58. *
  59. * @protected
  60. * @param {Object.<String,HTMLElement>} sourceElements The list of DOM elements that will be the source
  61. * for the created editor (on which the editor will be initialized).
  62. * @param {module:core/editor/editorconfig~EditorConfig} config The editor configuration.
  63. */
  64. constructor( sourceElements, config ) {
  65. super( config );
  66. this.data.processor = new HtmlDataProcessor( this.data.viewDocument );
  67. // Create root and UIView element for each editable container.
  68. for ( const rootName of Object.keys( sourceElements ) ) {
  69. this.model.document.createRoot( '$root', rootName );
  70. }
  71. this.ui = new MultirootEditorUI( this, new MultirootEditorUIView( this.locale, this.editing.view, sourceElements ) );
  72. }
  73. /**
  74. * @inheritDoc
  75. */
  76. destroy() {
  77. // Cache the data and editable DOM elements, then destroy.
  78. // It's safe to assume that the model->view conversion will not work after super.destroy(),
  79. // same as `ui.getEditableElement()` method will not return editables.
  80. const data = {};
  81. const editables = {};
  82. const editablesNames = Array.from( this.ui.getEditableElementsNames() );
  83. for ( const rootName of editablesNames ) {
  84. data[ rootName ] = this.getData( { rootName } );
  85. editables[ rootName ] = this.ui.getEditableElement( rootName );
  86. }
  87. this.ui.destroy();
  88. return super.destroy()
  89. .then( () => {
  90. for ( const rootName of editablesNames ) {
  91. setDataInElement( editables[ rootName ], data[ rootName ] );
  92. }
  93. } );
  94. }
  95. /**
  96. * Creates a multiroot editor instance.
  97. *
  98. * @param {Object.<String,HTMLElement>} sourceElements The list of DOM elements that will be the source
  99. * for the created editor (on which the editor will be initialized).
  100. * @param {module:core/editor/editorconfig~EditorConfig} config The editor configuration.
  101. * @returns {Promise} A promise resolved once the editor is ready. The promise returns the created multiroot editor instance.
  102. */
  103. static create( sourceElements, config ) {
  104. return new Promise( resolve => {
  105. const editor = new this( sourceElements, config );
  106. resolve(
  107. editor.initPlugins()
  108. .then( () => editor.ui.init() )
  109. .then( () => {
  110. const initialData = {};
  111. // Create initial data object containing data from all roots.
  112. for ( const rootName of Object.keys( sourceElements ) ) {
  113. initialData[ rootName ] = getDataFromElement( sourceElements[ rootName ] );
  114. }
  115. return editor.data.init( initialData );
  116. } )
  117. .then( () => editor.fire( 'ready' ) )
  118. .then( () => editor )
  119. );
  120. } );
  121. }
  122. }
  123. mix( MultirootEditor, DataApiMixin );
  124. /**
  125. * The multiroot editor UI class.
  126. *
  127. * @extends module:core/editor/editorui~EditorUI
  128. */
  129. class MultirootEditorUI extends EditorUI {
  130. /**
  131. * Creates an instance of the multiroot editor UI class.
  132. *
  133. * @param {module:core/editor/editor~Editor} editor The editor instance.
  134. * @param {module:ui/editorui/editoruiview~EditorUIView} view The view of the UI.
  135. */
  136. constructor( editor, view ) {
  137. super( editor );
  138. /**
  139. * The main (top–most) view of the editor UI.
  140. *
  141. * @readonly
  142. * @member {module:ui/editorui/editoruiview~EditorUIView} #view
  143. */
  144. this.view = view;
  145. /**
  146. * A normalized `config.toolbar` object.
  147. *
  148. * @type {Object}
  149. * @private
  150. */
  151. this._toolbarConfig = normalizeToolbarConfig( editor.config.get( 'toolbar' ) );
  152. }
  153. /**
  154. * Initializes the UI.
  155. */
  156. init() {
  157. const view = this.view;
  158. const editor = this.editor;
  159. const editingView = editor.editing.view;
  160. let lastFocusedEditableElement;
  161. view.render();
  162. // Keep track of the last focused editable element. Knowing which one was focused
  163. // is useful when the focus moves from editable to other UI components like balloons
  164. // (especially inputs) but the editable remains the "focus context" (e.g. link balloon
  165. // attached to a link in an editable). In this case, the editable should preserve visual
  166. // focus styles.
  167. this.focusTracker.on( 'change:focusedElement', ( evt, name, focusedElement ) => {
  168. for ( const editable of this.view.editables ) {
  169. if ( editable.element === focusedElement ) {
  170. lastFocusedEditableElement = editable.element;
  171. }
  172. }
  173. } );
  174. // If the focus tracker loses focus, stop tracking the last focused editable element.
  175. // Wherever the focus is restored, it will no longer be in the context of that editable
  176. // because the focus "came from the outside", as opposed to the focus moving from one element
  177. // to another withing the editor UI.
  178. this.focusTracker.on( 'change:isFocused', ( evt, name, isFocused ) => {
  179. if ( !isFocused ) {
  180. lastFocusedEditableElement = null;
  181. }
  182. } );
  183. for ( const editable of this.view.editables ) {
  184. // The editable UI element in DOM is available for sure only after the editor UI view has been rendered.
  185. // But it can be available earlier if a DOM element has been passed to DecoupledEditor.create().
  186. const editableElement = editable.element;
  187. // Register the editable UI view in the editor. A single editor instance can aggregate multiple
  188. // editable areas (roots) but the decoupled editor has only one.
  189. this.setEditableElement( editable.name, editableElement );
  190. // Let the global focus tracker know that the editable UI element is focusable and
  191. // belongs to the editor. From now on, the focus tracker will sustain the editor focus
  192. // as long as the editable is focused (e.g. the user is typing).
  193. this.focusTracker.add( editableElement );
  194. // Let the editable UI element respond to the changes in the global editor focus
  195. // tracker. It has been added to the same tracker a few lines above but, in reality, there are
  196. // many focusable areas in the editor, like balloons, toolbars or dropdowns and as long
  197. // as they have focus, the editable should act like it is focused too (although technically
  198. // it isn't), e.g. by setting the proper CSS class, visually announcing focus to the user.
  199. // Doing otherwise will result in editable focus styles disappearing, once e.g. the
  200. // toolbar gets focused.
  201. editable.bind( 'isFocused' ).to( this.focusTracker, 'isFocused', this.focusTracker, 'focusedElement',
  202. ( isFocused, focusedElement ) => {
  203. // When the focus tracker is blurred, it means the focus moved out of the editor UI.
  204. // No editable will maintain focus then.
  205. if ( !isFocused ) {
  206. return false;
  207. }
  208. // If the focus tracker says the editor UI is focused and currently focused element
  209. // is the editable, then the editable should be visually marked as focused too.
  210. if ( focusedElement === editableElement ) {
  211. return true;
  212. }
  213. // If the focus tracker says the editor UI is focused but the focused element is
  214. // not an editable, it is possible that the editable is still (context–)focused.
  215. // For instance, the focused element could be an input inside of a balloon attached
  216. // to the content in the editable. In such case, the editable should remain _visually_
  217. // focused even though technically the focus is somewhere else. The focus moved from
  218. // the editable to the input but the focus context remained the same.
  219. else {
  220. return lastFocusedEditableElement === editableElement;
  221. }
  222. } );
  223. // Bind the editable UI element to the editing view, making it an end– and entry–point
  224. // of the editor's engine. This is where the engine meets the UI.
  225. editingView.attachDomRoot( editableElement, editable.name );
  226. }
  227. this._initPlaceholder();
  228. this._initToolbar();
  229. this.fire( 'ready' );
  230. }
  231. /**
  232. * @inheritDoc
  233. */
  234. destroy() {
  235. const view = this.view;
  236. const editingView = this.editor.editing.view;
  237. for ( const editable of this.view.editables ) {
  238. editingView.detachDomRoot( editable.name );
  239. }
  240. view.destroy();
  241. super.destroy();
  242. }
  243. /**
  244. * Initializes the inline editor toolbar and its panel.
  245. *
  246. * @private
  247. */
  248. _initToolbar() {
  249. const editor = this.editor;
  250. const view = this.view;
  251. const toolbar = view.toolbar;
  252. toolbar.fillFromConfig( this._toolbarConfig.items, this.componentFactory );
  253. enableToolbarKeyboardFocus( {
  254. origin: editor.editing.view,
  255. originFocusTracker: this.focusTracker,
  256. originKeystrokeHandler: editor.keystrokes,
  257. toolbar
  258. } );
  259. }
  260. /**
  261. * Enable the placeholder text on the editing root, if any was configured.
  262. *
  263. * @private
  264. */
  265. _initPlaceholder() {
  266. const editor = this.editor;
  267. const editingView = editor.editing.view;
  268. for ( const editable of this.view.editables ) {
  269. const editingRoot = editingView.document.getRoot( editable.name );
  270. const sourceElement = this.getEditableElement( editable.name );
  271. const placeholderText = editor.config.get( 'placeholder' )[ editable.name ] ||
  272. sourceElement && sourceElement.tagName.toLowerCase() === 'textarea' && sourceElement.getAttribute( 'placeholder' );
  273. if ( placeholderText ) {
  274. enablePlaceholder( {
  275. view: editingView,
  276. element: editingRoot,
  277. text: placeholderText,
  278. isDirectHost: false
  279. } );
  280. }
  281. }
  282. }
  283. }
  284. /**
  285. * The multiroot editor UI view. It is a virtual view providing an inline editable, but without
  286. * any specific arrangement of the components in the DOM.
  287. *
  288. * @extends module:ui/editorui/editoruiview~EditorUIView
  289. */
  290. class MultirootEditorUIView extends EditorUIView {
  291. /**
  292. * Creates an instance of the multiroot editor UI view.
  293. *
  294. * @param {module:utils/locale~Locale} locale The {@link module:core/editor/editor~Editor#locale} instance.
  295. * @param {module:engine/view/view~View} editingView The editing view instance this view is related to.
  296. * @param {Object.<String,HTMLElement>} editableElements The list of editable elements, containing name and html element
  297. * for each editable.
  298. */
  299. constructor( locale, editingView, editableElements ) {
  300. super( locale );
  301. /**
  302. * The main toolbar of the decoupled editor UI.
  303. *
  304. * @readonly
  305. * @member {module:ui/toolbar/toolbarview~ToolbarView}
  306. */
  307. this.toolbar = new ToolbarView( locale );
  308. /**
  309. * The editables of the multiroot editor UI.
  310. *
  311. * @readonly
  312. * @member {Array.<module:ui/editableui/inline/inlineeditableuiview~InlineEditableUIView>}
  313. */
  314. this.editables = [];
  315. // Create InlineEditableUIView instance for each editable.
  316. for ( const editableName of Object.keys( editableElements ) ) {
  317. const editable = new InlineEditableUIView( locale, editingView, editableElements[ editableName ] );
  318. editable.name = editableName;
  319. this.editables.push( editable );
  320. }
  321. // This toolbar may be placed anywhere in the page so things like font size need to be reset in it.
  322. // Because of the above, make sure the toolbar supports rounded corners.
  323. // Also, make sure the toolbar has the proper dir attribute because its ancestor may not have one
  324. // and some toolbar item styles depend on this attribute.
  325. Template.extend( this.toolbar.template, {
  326. attributes: {
  327. class: [
  328. 'ck-reset_all',
  329. 'ck-rounded-corners'
  330. ],
  331. dir: locale.uiLanguageDirection
  332. }
  333. } );
  334. }
  335. /**
  336. * @inheritDoc
  337. */
  338. render() {
  339. super.render();
  340. this.registerChild( this.editables );
  341. this.registerChild( [ this.toolbar ] );
  342. }
  343. }
  344. // Initialize editor
  345. MultirootEditor
  346. .create( {
  347. header: document.querySelector( '#header' ),
  348. content: document.querySelector( '#content' ),
  349. footerleft: document.querySelector( '#footer-left' ),
  350. footerright: document.querySelector( '#footer-right' )
  351. }, {
  352. plugins: [ Essentials, Paragraph, Heading, Bold, Italic, List, Link, BlockQuote, Image, ImageCaption,
  353. ImageStyle, ImageResize, ImageToolbar, ImageUpload, Table, TableToolbar, MediaEmbed, EasyImage ],
  354. toolbar: [ 'heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', 'imageUpload', 'blockQuote',
  355. 'insertTable', 'mediaEmbed', 'undo', 'redo' ],
  356. image: {
  357. toolbar: [ 'imageTextAlternative', '|', 'imageStyle:alignLeft', 'imageStyle:full', 'imageStyle:alignRight' ],
  358. styles: [ 'full', 'alignLeft', 'alignRight' ]
  359. },
  360. table: {
  361. contentToolbar: [
  362. 'tableColumn',
  363. 'tableRow',
  364. 'mergeTableCells'
  365. ]
  366. },
  367. placeholder: {
  368. header: 'Header text goes here',
  369. content: 'Type content here',
  370. footerleft: 'Left footer content',
  371. footerright: 'Right footer content'
  372. },
  373. cloudServices: CS_CONFIG
  374. } )
  375. .then( newEditor => {
  376. document.querySelector( '#toolbar' ).appendChild( newEditor.ui.view.toolbar.element );
  377. window.editor = newEditor;
  378. } )
  379. .catch( err => {
  380. console.error( err.stack );
  381. } );