2
0

extending_draftail.rst 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. Extending the Draftail Editor
  2. =============================
  3. Wagtail’s rich text editor is built with `Draftail <https://www.draftail.org/>`__, and its functionality can be extended through plugins.
  4. Plugins come in three types:
  5. * Inline styles – To format a portion of a line, eg. ``bold``, ``italic``, ``monospace``.
  6. * Blocks – To indicate the structure of the content, eg. ``blockquote``, ``ol``.
  7. * Entities – To enter additional data/metadata, eg. ``link`` (with a URL), ``image`` (with a file).
  8. All of these plugins are created with a similar baseline, which we can demonstrate with one of the simplest examples – a custom feature for an inline style of ``mark``. Place the following in a ``wagtail_hooks.py`` file in any installed app:
  9. .. code-block:: python
  10. import wagtail.admin.rich_text.editors.draftail.features as draftail_features
  11. from wagtail.admin.rich_text.converters.html_to_contentstate import InlineStyleElementHandler
  12. from wagtail.core import hooks
  13. # 1. Use the register_rich_text_features hook.
  14. @hooks.register('register_rich_text_features')
  15. def register_mark_feature(features):
  16. """
  17. Registering the `mark` feature, which uses the `MARK` Draft.js inline style type,
  18. and is stored as HTML with a `<mark>` tag.
  19. """
  20. feature_name = 'mark'
  21. type_ = 'MARK'
  22. tag = 'mark'
  23. # 2. Configure how Draftail handles the feature in its toolbar.
  24. control = {
  25. 'type': type_,
  26. 'label': '☆',
  27. 'description': 'Mark',
  28. # This isn’t even required – Draftail has predefined styles for MARK.
  29. # 'style': {'textDecoration': 'line-through'},
  30. }
  31. # 3. Call register_editor_plugin to register the configuration for Draftail.
  32. features.register_editor_plugin(
  33. 'draftail', feature_name, draftail_features.InlineStyleFeature(control)
  34. )
  35. # 4.configure the content transform from the DB to the editor and back.
  36. db_conversion = {
  37. 'from_database_format': {tag: InlineStyleElementHandler(type_)},
  38. 'to_database_format': {'style_map': {type_: tag}},
  39. }
  40. # 5. Call register_converter_rule to register the content transformation conversion.
  41. features.register_converter_rule('contentstate', feature_name, db_conversion)
  42. # 6. (optional) Add the feature to the default features list to make it available
  43. # on rich text fields that do not specify an explicit 'features' list
  44. features.default_features.append('mark')
  45. These steps will always be the same for all Draftail plugins. The important parts are to:
  46. * Consistently use the feature’s Draft.js type or Wagtail feature names where appropriate.
  47. * Give enough information to Draftail so it knows how to make a button for the feature, and how to render it (more on this later).
  48. * Configure the conversion to use the right HTML element (as they are stored in the DB).
  49. For detailed configuration options, head over to the `Draftail documentation <https://www.draftail.org/docs/formatting-options>`__ to see all of the details. Here are some parts worth highlighting about controls:
  50. * The ``type`` is the only mandatory piece of information.
  51. * To display the control in the toolbar, combine ``icon``, ``label`` and ``description``.
  52. * The controls’ ``icon`` can be a string to use an icon font with CSS classes, say ``'icon': 'fas fa-user',``. It can also be an array of strings, to use SVG paths, or SVG symbol references eg. ``'icon': ['M100 100 H 900 V 900 H 100 Z'],``. The paths need to be set for a 1024x1024 viewbox.
  53. Creating new inline styles
  54. ~~~~~~~~~~~~~~~~~~~~~~~~~~
  55. In addition to the initial example, inline styles take a ``style`` property to define what CSS rules will be applied to text in the editor. Be sure to read the `Draftail documentation <https://www.draftail.org/docs/formatting-options>`__ on inline styles.
  56. Finally, the DB to/from conversion uses an ``InlineStyleElementHandler`` to map from a given tag (``<mark>`` in the example above) to a Draftail type, and the inverse mapping is done with `Draft.js exporter configuration <https://github.com/springload/draftjs_exporter>`_ of the ``style_map``.
  57. Creating new blocks
  58. ~~~~~~~~~~~~~~~~~~~
  59. Blocks are nearly as simple as inline styles:
  60. .. code-block:: python
  61. from wagtail.admin.rich_text.converters.html_to_contentstate import BlockElementHandler
  62. @hooks.register('register_rich_text_features')
  63. def register_help_text_feature(features):
  64. """
  65. Registering the `help-text` feature, which uses the `help-text` Draft.js block type,
  66. and is stored as HTML with a `<div class="help-text">` tag.
  67. """
  68. feature_name = 'help-text'
  69. type_ = 'help-text'
  70. control = {
  71. 'type': type_,
  72. 'label': '?',
  73. 'description': 'Help text',
  74. # Optionally, we can tell Draftail what element to use when displaying those blocks in the editor.
  75. 'element': 'div',
  76. }
  77. features.register_editor_plugin(
  78. 'draftail', feature_name, draftail_features.BlockFeature(control, css={'all': ['help-text.css']})
  79. )
  80. features.register_converter_rule('contentstate', feature_name, {
  81. 'from_database_format': {'div[class=help-text]': BlockElementHandler(type_)},
  82. 'to_database_format': {'block_map': {type_: {'element': 'div', 'props': {'class': 'help-text'}}}},
  83. })
  84. Here are the main differences:
  85. * We can configure an ``element`` to tell Draftail how to render those blocks in the editor.
  86. * We register the plugin with ``BlockFeature``.
  87. * We set up the conversion with ``BlockElementHandler`` and ``block_map``.
  88. Optionally, we can also define styles for the blocks with the ``Draftail-block--help-text`` (``Draftail-block--<block type>``) CSS class.
  89. That’s it! The extra complexity is that you may need to write CSS to style the blocks in the editor.
  90. Creating new entities
  91. ~~~~~~~~~~~~~~~~~~~~~
  92. .. warning::
  93. This is an advanced feature. Please carefully consider whether you really need this.
  94. Entities aren’t simply formatting buttons in the toolbar. They usually need to be much more versatile, communicating to APIs or requesting further user input. As such,
  95. * You will most likely need to write a **hefty dose of JavaScript**, some of it with React.
  96. * The API is very **low-level**. You will most likely need some **Draft.js knowledge**.
  97. * Custom UIs in rich text can be brittle. Be ready to spend time **testing in multiple browsers**.
  98. The good news is that having such a low-level API will enable third-party Wagtail plugins to innovate on rich text features, proposing new kinds of experiences.
  99. But in the meantime, consider implementing your UI through :doc:`StreamField <../../topics/streamfield>` instead, which has a battle-tested API meant for Django developers.
  100. ----
  101. Here are the main requirements to create a new entity feature:
  102. * Like for inline styles and blocks, register an editor plugin.
  103. * The editor plugin must define a ``source``: a React component responsible for creating new entity instances in the editor, using the Draft.js API.
  104. * The editor plugin also needs a ``decorator`` (for inline entities) or ``block`` (for block entities): a React component responsible for displaying entity instances within the editor.
  105. * Like for inline styles and blocks, set up the to/from DB conversion.
  106. * The conversion usually is more involved, since entities contain data that needs to be serialised to HTML.
  107. To write the React components, Wagtail exposes its own React, Draft.js and Draftail dependencies as global variables. Read more about this in :ref:`extending_clientside_components`.
  108. To go further, please look at the `Draftail documentation <https://www.draftail.org/docs/formatting-options>`__ as well as the `Draft.js exporter documentation <https://github.com/springload/draftjs_exporter>`_.
  109. Here is a detailed example to showcase how those tools are used in the context of Wagtail.
  110. For the sake of our example, we can imagine a news team working at a financial newspaper.
  111. They want to write articles about the stock market, refer to specific stocks anywhere inside of their content (eg. "$TSLA" tokens in a sentence), and then have their article automatically enriched with the stock’s information (a link, a number, a sparkline).
  112. The editor toolbar could contain a "stock chooser" that displays a list of available stocks, then inserts the user’s selection as a textual token. For our example, we will just pick a stock at random:
  113. .. image:: ../_static/images/draftail_entity_stock_source.gif
  114. Those tokens are then saved in the rich text on publish. When the news article is displayed on the site, we then insert live market data coming from an API next to each token:
  115. .. image:: ../_static/images/draftail_entity_stock_rendering.png
  116. In order to achieve this, we start with registering the rich text feature like for inline styles and blocks:
  117. .. code-block:: python
  118. @hooks.register('register_rich_text_features')
  119. def register_stock_feature(features):
  120. features.default_features.append('stock')
  121. """
  122. Registering the `stock` feature, which uses the `STOCK` Draft.js entity type,
  123. and is stored as HTML with a `<span data-stock>` tag.
  124. """
  125. feature_name = 'stock'
  126. type_ = 'STOCK'
  127. control = {
  128. 'type': type_,
  129. 'label': '$',
  130. 'description': 'Stock',
  131. }
  132. features.register_editor_plugin(
  133. 'draftail', feature_name, draftail_features.EntityFeature(
  134. control,
  135. js=['stock.js'],
  136. css={'all': ['stock.css']}
  137. )
  138. )
  139. features.register_converter_rule('contentstate', feature_name, {
  140. # Note here that the conversion is more complicated than for blocks and inline styles.
  141. 'from_database_format': {'span[data-stock]': StockEntityElementHandler(type_)},
  142. 'to_database_format': {'entity_decorators': {type_: stock_entity_decorator}},
  143. })
  144. The ``js`` and ``css`` keyword arguments on ``EntityFeature`` can be used to specify additional
  145. JS and CSS files to load when this feature is active. Both are optional. Their values are added to a ``Media`` object, more documentation on these objects
  146. is available in the :doc:`Django Form Assets documentation <django:topics/forms/media>`.
  147. Since entities hold data, the conversion to/from database format is more complicated. We have to create the two handlers:
  148. .. code-block:: python
  149. from draftjs_exporter.dom import DOM
  150. from wagtail.admin.rich_text.converters.html_to_contentstate import InlineEntityElementHandler
  151. def stock_entity_decorator(props):
  152. """
  153. Draft.js ContentState to database HTML.
  154. Converts the STOCK entities into a span tag.
  155. """
  156. return DOM.create_element('span', {
  157. 'data-stock': props['stock'],
  158. }, props['children'])
  159. class StockEntityElementHandler(InlineEntityElementHandler):
  160. """
  161. Database HTML to Draft.js ContentState.
  162. Converts the span tag into a STOCK entity, with the right data.
  163. """
  164. mutability = 'IMMUTABLE'
  165. def get_attribute_data(self, attrs):
  166. """
  167. Take the ``stock`` value from the ``data-stock`` HTML attribute.
  168. """
  169. return {
  170. 'stock': attrs['data-stock'],
  171. }
  172. Note how they both do similar conversions, but use different APIs. ``to_database_format`` is built with the `Draft.js exporter <https://github.com/springload/draftjs_exporter>`_ components API, whereas ``from_database_format`` uses a Wagtail API.
  173. The next step is to add JavaScript to define how the entities are created (the ``source``), and how they are displayed (the ``decorator``). Within ``stock.js``, we define the source component:
  174. .. code-block:: javascript
  175. const React = window.React;
  176. const Modifier = window.DraftJS.Modifier;
  177. const EditorState = window.DraftJS.EditorState;
  178. const DEMO_STOCKS = ['AMD', 'AAPL', 'TWTR', 'TSLA', 'BTC'];
  179. // Not a real React component – just creates the entities as soon as it is rendered.
  180. class StockSource extends React.Component {
  181. componentDidMount() {
  182. const { editorState, entityType, onComplete } = this.props;
  183. const content = editorState.getCurrentContent();
  184. const selection = editorState.getSelection();
  185. const randomStock = DEMO_STOCKS[Math.floor(Math.random() * DEMO_STOCKS.length)];
  186. // Uses the Draft.js API to create a new entity with the right data.
  187. const contentWithEntity = content.createEntity(entityType.type, 'IMMUTABLE', {
  188. stock: randomStock,
  189. });
  190. const entityKey = contentWithEntity.getLastCreatedEntityKey();
  191. // We also add some text for the entity to be activated on.
  192. const text = `$${randomStock}`;
  193. const newContent = Modifier.replaceText(content, selection, text, null, entityKey);
  194. const nextState = EditorState.push(editorState, newContent, 'insert-characters');
  195. onComplete(nextState);
  196. }
  197. render() {
  198. return null;
  199. }
  200. }
  201. This source component uses data and callbacks provided by `Draftail <https://www.draftail.org/docs/api>`_.
  202. It also uses dependencies from global variables – see :ref:`extending_clientside_components`.
  203. We then create the decorator component:
  204. .. code-block:: javascript
  205. const Stock = (props) => {
  206. const { entityKey, contentState } = props;
  207. const data = contentState.getEntity(entityKey).getData();
  208. return React.createElement('a', {
  209. role: 'button',
  210. onMouseUp: () => {
  211. window.open(`https://finance.yahoo.com/quote/${data.stock}`);
  212. },
  213. }, props.children);
  214. };
  215. This is a straightforward React component. It does not use JSX since we do not want to have to use a build step for our JavaScript.
  216. Finally, we register the JS components of our plugin:
  217. .. code-block:: javascript
  218. window.draftail.registerPlugin({
  219. type: 'STOCK',
  220. source: StockSource,
  221. decorator: Stock,
  222. });
  223. And that’s it! All of this setup will finally produce the following HTML on the site’s front-end:
  224. .. code-block:: html
  225. <p>
  226. Anyone following Elon Musk’s <span data-stock="TSLA">$TSLA</span> should also look into <span data-stock="BTC">$BTC</span>.
  227. </p>
  228. To fully complete the demo, we can add a bit of JavaScript to the front-end in order to decorate those tokens with links and a little sparkline.
  229. .. code-block:: javascript
  230. [].slice.call(document.querySelectorAll('[data-stock]')).forEach((elt) => {
  231. const link = document.createElement('a');
  232. link.href = `https://finance.yahoo.com/quote/${elt.dataset.stock}`;
  233. link.innerHTML = `${elt.innerHTML}<svg width="50" height="20" stroke-width="2" stroke="blue" fill="rgba(0, 0, 255, .2)"><path d="M4 14.19 L 4 14.19 L 13.2 14.21 L 22.4 13.77 L 31.59 13.99 L 40.8 13.46 L 50 11.68 L 59.19 11.35 L 68.39 10.68 L 77.6 7.11 L 86.8 7.85 L 96 4" fill="none"></path><path d="M4 14.19 L 4 14.19 L 13.2 14.21 L 22.4 13.77 L 31.59 13.99 L 40.8 13.46 L 50 11.68 L 59.19 11.35 L 68.39 10.68 L 77.6 7.11 L 86.8 7.85 L 96 4 V 20 L 4 20 Z" stroke="none"></path></svg>`;
  234. elt.innerHTML = '';
  235. elt.appendChild(link);
  236. });
  237. ----
  238. Custom block entities can also be created (have a look at the separate `Draftail documentation <https://www.draftail.org/docs/blocks>`__), but these are not detailed here since :ref:`StreamField <streamfield>` is the go-to way to create block-level rich text in Wagtail.
  239. Integration of the Draftail widgets
  240. ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  241. To further customise how the Draftail widgets are integrated into the UI, there are additional extension points for CSS and JS:
  242. * In JavaScript, use the ``[data-draftail-input]`` attribute selector to target the input which contains the data, and ``[data-draftail-editor-wrapper]`` for the element which wraps the editor.
  243. * The editor instance is bound on the input field for imperative access. Use ``document.querySelector('[data-draftail-input]').draftailEditor``.
  244. * In CSS, use the classes prefixed with ``Draftail-``.