8
0

htmlembedediting.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  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 the `htmlEmbed.showPreviews=true` option, it is strongly recommended to
  43. * define a sanitize function that will clean up the 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. 'data-html-embed-label': t( 'HTML snippet' )
  117. } );
  118. // Widget cannot be a raw element because the widget system would not be able
  119. // to add its UI to it. Thus, we need this wrapper.
  120. const viewContentWrapper = writer.createRawElement( 'div', {
  121. class: 'raw-html-embed__content-wrapper'
  122. }, function( domElement ) {
  123. domContentWrapper = domElement;
  124. renderContent( { domElement, editor, state, props } );
  125. } );
  126. // API exposed on each raw HTML embed widget so other features can control a particular widget.
  127. const rawHtmlApi = {
  128. makeEditable() {
  129. state = Object.assign( {}, state, {
  130. isEditable: true
  131. } );
  132. renderContent( { domElement: domContentWrapper, editor, state, props } );
  133. view.change( writer => {
  134. writer.setAttribute( 'data-cke-ignore-events', 'true', viewContentWrapper );
  135. } );
  136. // This could be potentially pulled to a separate method called focusTextarea().
  137. domContentWrapper.querySelector( 'textarea' ).focus();
  138. },
  139. save( newValue ) {
  140. // If the value didn't change, we just cancel. If it changed,
  141. // it's enough to update the model – the entire widget will be reconverted.
  142. if ( newValue !== state.getRawHtmlValue() ) {
  143. editor.execute( 'updateHtmlEmbed', newValue );
  144. } else {
  145. this.cancel();
  146. }
  147. },
  148. cancel() {
  149. state = Object.assign( {}, state, {
  150. isEditable: false
  151. } );
  152. renderContent( { domElement: domContentWrapper, editor, state, props } );
  153. view.change( writer => {
  154. writer.removeAttribute( 'data-cke-ignore-events', viewContentWrapper );
  155. } );
  156. }
  157. };
  158. state = {
  159. showPreviews: htmlEmbedConfig.showPreviews,
  160. isEditable: false,
  161. getRawHtmlValue: () => modelElement.getAttribute( 'value' ) || ''
  162. };
  163. props = {
  164. sanitizeHtml: htmlEmbedConfig.sanitizeHtml,
  165. textareaPlaceholder: t( 'Paste raw HTML here...' ),
  166. onEditClick() {
  167. rawHtmlApi.makeEditable();
  168. },
  169. onSaveClick( newValue ) {
  170. rawHtmlApi.save( newValue );
  171. },
  172. onCancelClick() {
  173. rawHtmlApi.cancel();
  174. }
  175. };
  176. writer.insert( writer.createPositionAt( viewContainer, 0 ), viewContentWrapper );
  177. writer.setCustomProperty( 'rawHtmlApi', rawHtmlApi, viewContainer );
  178. writer.setCustomProperty( 'rawHtml', true, viewContainer );
  179. return toWidget( viewContainer, writer, {
  180. widgetLabel: t( 'HTML snippet' ),
  181. hasSelectionHandle: true
  182. } );
  183. }
  184. } );
  185. function renderContent( { domElement, editor, state, props } ) {
  186. // Remove all children;
  187. domElement.textContent = '';
  188. const domDocument = domElement.ownerDocument;
  189. let domTextarea;
  190. if ( state.isEditable ) {
  191. const textareaProps = {
  192. isDisabled: false,
  193. placeholder: props.textareaPlaceholder
  194. };
  195. domTextarea = createDomTextarea( { domDocument, state, props: textareaProps } );
  196. domElement.append( domTextarea );
  197. } else if ( state.showPreviews ) {
  198. const previewContainerProps = {
  199. sanitizeHtml: props.sanitizeHtml
  200. };
  201. domElement.append( createPreviewContainer( { domDocument, state, props: previewContainerProps } ) );
  202. } else {
  203. const textareaProps = {
  204. isDisabled: true,
  205. placeholder: props.textareaPlaceholder
  206. };
  207. domElement.append( createDomTextarea( { domDocument, state, props: textareaProps } ) );
  208. }
  209. const buttonsWrapperProps = {
  210. onEditClick: props.onEditClick,
  211. onSaveClick: () => {
  212. props.onSaveClick( domTextarea.value );
  213. },
  214. onCancelClick: props.onCancelClick
  215. };
  216. domElement.prepend( createDomButtonsWrapper( { editor, domDocument, state, props: buttonsWrapperProps } ) );
  217. }
  218. function createDomButtonsWrapper( { editor, domDocument, state, props } ) {
  219. const domButtonsWrapper = createElement( domDocument, 'div', {
  220. class: 'raw-html-embed__buttons-wrapper'
  221. } );
  222. // TODO these should be cached and we should only clone here these cached nodes!
  223. const domEditButton = createDomButton( editor.locale, 'edit' );
  224. const domSaveButton = createDomButton( editor.locale, 'save' );
  225. const domCancelButton = createDomButton( editor.locale, 'cancel' );
  226. if ( state.isEditable ) {
  227. const clonedDomSaveButton = domSaveButton.cloneNode( true );
  228. const clonedDomCancelButton = domCancelButton.cloneNode( true );
  229. clonedDomSaveButton.addEventListener( 'click', evt => {
  230. evt.preventDefault();
  231. props.onSaveClick( );
  232. } );
  233. clonedDomCancelButton.addEventListener( 'click', evt => {
  234. evt.preventDefault();
  235. props.onCancelClick( );
  236. } );
  237. domButtonsWrapper.appendChild( clonedDomSaveButton );
  238. domButtonsWrapper.appendChild( clonedDomCancelButton );
  239. } else {
  240. const clonedDomEditButton = domEditButton.cloneNode( true );
  241. clonedDomEditButton.addEventListener( 'click', evt => {
  242. evt.preventDefault();
  243. props.onEditClick();
  244. } );
  245. domButtonsWrapper.appendChild( clonedDomEditButton );
  246. }
  247. return domButtonsWrapper;
  248. }
  249. function createDomTextarea( { domDocument, state, props } ) {
  250. const domTextarea = createElement( domDocument, 'textarea', {
  251. placeholder: props.placeholder,
  252. class: 'ck ck-reset ck-input ck-input-text raw-html-embed__source'
  253. } );
  254. domTextarea.disabled = props.isDisabled;
  255. domTextarea.value = state.getRawHtmlValue();
  256. return domTextarea;
  257. }
  258. function createPreviewContainer( { domDocument, state, props } ) {
  259. const domPreviewContainer = createElement( domDocument, 'div', {
  260. class: 'raw-html-embed__preview'
  261. } );
  262. const sanitizeOutput = props.sanitizeHtml( state.getRawHtmlValue() );
  263. domPreviewContainer.innerHTML = sanitizeOutput.html;
  264. return domPreviewContainer;
  265. }
  266. }
  267. }
  268. // Returns a toggle mode button DOM element that can be cloned and used in conversion.
  269. //
  270. // @param {module:utils/locale~Locale} locale Editor locale.
  271. // @param {'edit'|'save'|'cancel'} type Type of button to create.
  272. // @returns {HTMLElement}
  273. function createDomButton( locale, type ) {
  274. const t = locale.t;
  275. const buttonView = new ButtonView( locale );
  276. buttonView.set( {
  277. tooltipPosition: 'sw',
  278. icon: pencilIcon,
  279. tooltip: true
  280. } );
  281. buttonView.render();
  282. if ( type === 'edit' ) {
  283. buttonView.set( {
  284. icon: pencilIcon,
  285. label: t( 'Edit source' ),
  286. class: 'raw-html-embed__edit-button'
  287. } );
  288. } else if ( type === 'save' ) {
  289. buttonView.set( {
  290. icon: checkIcon,
  291. label: t( 'Save changes' ),
  292. class: 'raw-html-embed__save-button'
  293. } );
  294. } else {
  295. buttonView.set( {
  296. icon: cancelIcon,
  297. label: t( 'Cancel' ),
  298. class: 'raw-html-embed__cancel-button'
  299. } );
  300. }
  301. buttonView.destroy();
  302. return buttonView.element.cloneNode( true );
  303. }