8
0

htmlembedediting.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  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. /**
  6. * @module html-embed/htmlembedediting
  7. */
  8. import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
  9. import HtmlDataProcessor from '@ckeditor/ckeditor5-engine/src/dataprocessor/htmldataprocessor';
  10. import UpcastWriter from '@ckeditor/ckeditor5-engine/src/view/upcastwriter';
  11. import { logWarning } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
  12. import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
  13. import InsertHtmlEmbedCommand from './inserthtmlembedcommand';
  14. import UpdateHtmlEmbedCommand from './updatehtmlembedcommand';
  15. import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
  16. import createElement from '@ckeditor/ckeditor5-utils/src/dom/createelement';
  17. import pencilIcon from '@ckeditor/ckeditor5-core/theme/icons/pencil.svg';
  18. import checkIcon from '@ckeditor/ckeditor5-core/theme/icons/check.svg';
  19. import cancelIcon from '@ckeditor/ckeditor5-core/theme/icons/cancel.svg';
  20. import '../theme/htmlembed.css';
  21. /**
  22. * The HTML embed editing feature.
  23. *
  24. * @extends module:core/plugin~Plugin
  25. */
  26. export default class HtmlEmbedEditing extends Plugin {
  27. /**
  28. * @inheritDoc
  29. */
  30. static get pluginName() {
  31. return 'HtmlEmbedEditing';
  32. }
  33. /**
  34. * @inheritDoc
  35. */
  36. constructor( editor ) {
  37. super( editor );
  38. editor.config.define( 'htmlEmbed', {
  39. showPreviews: false,
  40. sanitizeHtml: rawHtml => {
  41. /**
  42. * When using the HTML embed feature with `htmlEmbed.showPreviews=true` option, it is strongly recommended to
  43. * define a sanitize function that will clean up an input HTML in order to avoid XSS vulnerability.
  44. *
  45. * For a detailed overview, check the {@glink features/html-embed HTML embed feature} documentation.
  46. *
  47. * @error html-embed-provide-sanitize-function
  48. */
  49. logWarning( 'html-embed-provide-sanitize-function' );
  50. return {
  51. html: rawHtml,
  52. hasChanged: false
  53. };
  54. }
  55. } );
  56. }
  57. /**
  58. * @inheritDoc
  59. */
  60. init() {
  61. const editor = this.editor;
  62. const schema = editor.model.schema;
  63. schema.register( 'rawHtml', {
  64. isObject: true,
  65. allowWhere: '$block',
  66. allowAttributes: [ 'value' ]
  67. } );
  68. editor.commands.add( 'updateHtmlEmbed', new UpdateHtmlEmbedCommand( editor ) );
  69. editor.commands.add( 'insertHtmlEmbed', new InsertHtmlEmbedCommand( editor ) );
  70. this._setupConversion();
  71. }
  72. /**
  73. * Prepares converters for the feature.
  74. *
  75. * @private
  76. */
  77. _setupConversion() {
  78. const editor = this.editor;
  79. const t = editor.t;
  80. const view = editor.editing.view;
  81. const htmlEmbedConfig = editor.config.get( 'htmlEmbed' );
  82. const upcastWriter = new UpcastWriter( view.document );
  83. const htmlProcessor = new HtmlDataProcessor( view.document );
  84. editor.conversion.for( 'upcast' ).elementToElement( {
  85. view: {
  86. name: 'div',
  87. classes: 'raw-html-embed'
  88. },
  89. model: ( viewElement, { writer } ) => {
  90. // Note: The below line has a side-effect – the children are *moved* to the DF so
  91. // viewElement becomes empty. It's fine here.
  92. const fragment = upcastWriter.createDocumentFragment( viewElement.getChildren() );
  93. const innerHtml = htmlProcessor.toData( fragment );
  94. return writer.createElement( 'rawHtml', {
  95. value: innerHtml
  96. } );
  97. }
  98. } );
  99. editor.conversion.for( 'dataDowncast' ).elementToElement( {
  100. model: 'rawHtml',
  101. view: ( modelElement, { writer } ) => {
  102. return writer.createRawElement( 'div', { class: 'raw-html-embed' }, function( domElement ) {
  103. domElement.innerHTML = modelElement.getAttribute( 'value' ) || '';
  104. } );
  105. }
  106. } );
  107. editor.conversion.for( 'editingDowncast' ).elementToElement( {
  108. triggerBy: {
  109. attributes: [ 'value' ]
  110. },
  111. model: 'rawHtml',
  112. view: ( modelElement, { writer } ) => {
  113. let domContentWrapper, state, props;
  114. const viewContainer = writer.createContainerElement( 'div', {
  115. class: 'raw-html-embed'
  116. } );
  117. // Widget cannot be a raw element because the widget system would not be able
  118. // to add its UI to it. Thus, we need this wrapper.
  119. const viewContentWrapper = writer.createRawElement( 'div', {
  120. class: 'raw-html-embed__content-wrapper'
  121. }, function( domElement ) {
  122. domContentWrapper = domElement;
  123. renderContent( { domElement, editor, state, props } );
  124. } );
  125. // API exposed on each raw HTML embed widget so other features can control a particular widget.
  126. const rawHtmlApi = {
  127. makeEditable() {
  128. state = Object.assign( {}, state, {
  129. isEditable: true
  130. } );
  131. renderContent( { domElement: domContentWrapper, editor, state, props } );
  132. view.change( writer => {
  133. writer.setAttribute( 'data-cke-ignore-events', 'true', viewContentWrapper );
  134. } );
  135. // This could be potentially pulled to a separate method called focusTextarea().
  136. domContentWrapper.querySelector( 'textarea' ).focus();
  137. },
  138. save( newValue ) {
  139. // If the value didn't change, we just cancel. If it changed,
  140. // it's enough to update the model – the entire widget will be reconverted.
  141. if ( newValue !== state.getRawHtmlValue() ) {
  142. editor.execute( 'updateHtmlEmbed', newValue );
  143. } else {
  144. this.cancel();
  145. }
  146. },
  147. cancel() {
  148. state = Object.assign( {}, state, {
  149. isEditable: false
  150. } );
  151. renderContent( { domElement: domContentWrapper, editor, state, props } );
  152. view.change( writer => {
  153. writer.removeAttribute( 'data-cke-ignore-events', viewContentWrapper );
  154. } );
  155. }
  156. };
  157. state = {
  158. showPreviews: htmlEmbedConfig.showPreviews,
  159. isEditable: false,
  160. getRawHtmlValue: () => modelElement.getAttribute( 'value' ) || ''
  161. };
  162. props = {
  163. sanitizeHtml: htmlEmbedConfig.sanitizeHtml,
  164. textareaPlaceholder: t( 'Paste raw HTML here...' ),
  165. onEditClick() {
  166. rawHtmlApi.makeEditable();
  167. },
  168. onSaveClick( newValue ) {
  169. rawHtmlApi.save( newValue );
  170. },
  171. onCancelClick() {
  172. rawHtmlApi.cancel();
  173. }
  174. };
  175. writer.insert( writer.createPositionAt( viewContainer, 0 ), viewContentWrapper );
  176. writer.setCustomProperty( 'rawHtmlApi', rawHtmlApi, viewContainer );
  177. writer.setCustomProperty( 'rawHtml', true, viewContainer );
  178. return toWidget( viewContainer, writer, {
  179. widgetLabel: t( 'HTML snippet' ),
  180. hasSelectionHandle: true
  181. } );
  182. }
  183. } );
  184. function renderContent( { domElement, editor, state, props } ) {
  185. // Remove all children;
  186. domElement.textContent = '';
  187. const domDocument = domElement.ownerDocument;
  188. let domTextarea;
  189. if ( state.isEditable ) {
  190. const textareaProps = {
  191. isDisabled: false,
  192. placeholder: props.textareaPlaceholder
  193. };
  194. domTextarea = createDomTextarea( { domDocument, state, props: textareaProps } );
  195. domElement.append( domTextarea );
  196. } else if ( state.showPreviews ) {
  197. const previewContainerProps = {
  198. sanitizeHtml: props.sanitizeHtml
  199. };
  200. domElement.append( createPreviewContainer( { domDocument, state, props: previewContainerProps } ) );
  201. } else {
  202. const textareaProps = {
  203. isDisabled: true,
  204. placeholder: props.textareaPlaceholder
  205. };
  206. domElement.append( createDomTextarea( { domDocument, state, props: textareaProps } ) );
  207. }
  208. const buttonsWrapperProps = {
  209. onEditClick: props.onEditClick,
  210. onSaveClick: () => {
  211. props.onSaveClick( domTextarea.value );
  212. },
  213. onCancelClick: props.onCancelClick
  214. };
  215. domElement.prepend( createDomButtonsWrapper( { editor, domDocument, state, props: buttonsWrapperProps } ) );
  216. }
  217. function createDomButtonsWrapper( { editor, domDocument, state, props } ) {
  218. const domButtonsWrapper = createElement( domDocument, 'div', {
  219. class: 'raw-html-embed__buttons-wrapper'
  220. } );
  221. // TODO these should be cached and we should only clone here these cached nodes!
  222. const domEditButton = createDomButton( editor.locale, 'edit' );
  223. const domSaveButton = createDomButton( editor.locale, 'save' );
  224. const domCancelButton = createDomButton( editor.locale, 'cancel' );
  225. if ( state.isEditable ) {
  226. const clonedDomSaveButton = domSaveButton.cloneNode( true );
  227. const clonedDomCancelButton = domCancelButton.cloneNode( true );
  228. clonedDomSaveButton.addEventListener( 'click', evt => {
  229. evt.preventDefault();
  230. props.onSaveClick( );
  231. } );
  232. clonedDomCancelButton.addEventListener( 'click', evt => {
  233. evt.preventDefault();
  234. props.onCancelClick( );
  235. } );
  236. domButtonsWrapper.appendChild( clonedDomSaveButton );
  237. domButtonsWrapper.appendChild( clonedDomCancelButton );
  238. } else {
  239. const clonedDomEditButton = domEditButton.cloneNode( true );
  240. clonedDomEditButton.addEventListener( 'click', evt => {
  241. evt.preventDefault();
  242. props.onEditClick();
  243. } );
  244. domButtonsWrapper.appendChild( clonedDomEditButton );
  245. }
  246. return domButtonsWrapper;
  247. }
  248. function createDomTextarea( { domDocument, state, props } ) {
  249. const domTextarea = createElement( domDocument, 'textarea', {
  250. placeholder: props.placeholder,
  251. class: 'ck ck-reset ck-input ck-input-text raw-html-embed__source'
  252. } );
  253. domTextarea.disabled = props.isDisabled;
  254. domTextarea.value = state.getRawHtmlValue();
  255. return domTextarea;
  256. }
  257. function createPreviewContainer( { domDocument, state, props } ) {
  258. const domPreviewContainer = createElement( domDocument, 'div', {
  259. class: 'raw-html-embed__preview'
  260. } );
  261. const sanitizeOutput = props.sanitizeHtml( state.getRawHtmlValue() );
  262. domPreviewContainer.innerHTML = sanitizeOutput.html;
  263. return domPreviewContainer;
  264. }
  265. }
  266. }
  267. // Returns a toggle mode button DOM element that can be cloned and used in conversion.
  268. //
  269. // @param {module:utils/locale~Locale} locale Editor locale.
  270. // @param {'edit'|'save'|'cancel'} type Type of button to create.
  271. // @returns {HTMLElement}
  272. function createDomButton( locale, type ) {
  273. const t = locale.t;
  274. const buttonView = new ButtonView( locale );
  275. buttonView.set( {
  276. tooltipPosition: 'sw',
  277. icon: pencilIcon,
  278. tooltip: true
  279. } );
  280. buttonView.render();
  281. if ( type === 'edit' ) {
  282. buttonView.set( {
  283. icon: pencilIcon,
  284. label: t( 'Edit source' ),
  285. class: 'raw-html-embed__edit-button'
  286. } );
  287. } else if ( type === 'save' ) {
  288. buttonView.set( {
  289. icon: checkIcon,
  290. label: t( 'Save changes' ),
  291. class: 'raw-html-embed__save-button'
  292. } );
  293. } else {
  294. buttonView.set( {
  295. icon: cancelIcon,
  296. label: t( 'Cancel' ),
  297. class: 'raw-html-embed__cancel-button'
  298. } );
  299. }
  300. buttonView.destroy();
  301. return buttonView.element.cloneNode( true );
  302. }