Browse Source

Implement new Draftail customisation APIs

- Fixes #5580
- Remove TSLA/TWTR/BTC references and replace with clean energy FSLR / NEE stocks
Thibaud Colas 1 year ago
parent
commit
f4ea0156a2

+ 1 - 0
CHANGELOG.txt

@@ -16,6 +16,7 @@ Changelog
  * Optimise queries in collection permission policies using cache on the user object (Sage Abdullah)
  * Phone numbers entered via a link chooser will now have any spaces stripped out, ensuring a valid href="tel:..." attribute (Sahil Jangra)
  * Auto-select the `StreamField` block when only one block type is declared (Sébastien Corbin)
+ * Add support for more advanced Draftail customisation APIs (Thibaud Colas)
  * Fix: Prevent choosers from failing when initial value is an unrecognised ID, e.g. when moving a page from a location where `parent_page_types` would disallow it (Dan Braghis)
  * Fix: Move comment notifications toggle to the comments side panel (Sage Abdullah)
  * Fix: Remove comment button on InlinePanel fields (Sage Abdullah)

+ 19 - 3
client/src/components/Draftail/__snapshots__/index.test.js.snap

@@ -15,8 +15,19 @@ Object {
   "bottomToolbar": [Function],
   "commandToolbar": [Function],
   "commands": true,
-  "controls": Array [],
-  "decorators": Array [],
+  "controls": Array [
+    Object {
+      "meta": [Function],
+      "type": "sentences",
+    },
+  ],
+  "decorators": Array [
+    Object {
+      "component": [Function],
+      "strategy": [Function],
+      "type": "punctuation",
+    },
+  ],
   "editorState": null,
   "enableHorizontalRule": Object {
     "description": "Horizontal line",
@@ -43,7 +54,12 @@ Object {
   "onFocus": null,
   "onSave": [Function],
   "placeholder": "Write something or type ‘/’ to insert a block",
-  "plugins": Array [],
+  "plugins": Array [
+    Object {
+      "handlePastedText": [Function],
+      "type": "anchorify",
+    },
+  ],
   "rawContentState": null,
   "readOnly": false,
   "showRedoControl": false,

+ 34 - 9
client/src/components/Draftail/index.js

@@ -90,11 +90,21 @@ const onSetToolbar = (choice, callback) => {
 /**
  * Registry for client-side code of Draftail plugins.
  */
-const PLUGINS = {};
+const PLUGINS = {
+  entityTypes: {},
+  plugins: {},
+  controls: {},
+  decorators: {},
+};
 
-const registerPlugin = (plugin) => {
-  PLUGINS[plugin.type] = plugin;
-  return PLUGINS;
+/**
+ * Client-side editor-specific equivalent to register_editor_plugin.
+ * `optionName` defaults to entityTypes for backwards-compatibility with
+ * previous function signature only allowing registering entities.
+ */
+const registerPlugin = (type, optionName = 'entityTypes') => {
+  PLUGINS[optionName][type.type] = type;
+  return PLUGINS[optionName];
 };
 
 /**
@@ -157,15 +167,28 @@ const initEditor = (selector, originalOptions, currentScript) => {
     const blockTypes = newOptions.blockTypes || [];
     const inlineStyles = newOptions.inlineStyles || [];
     let controls = newOptions.controls || [];
+    let decorators = newOptions.decorators || [];
+    let plugins = newOptions.plugins || [];
     const commands = newOptions.commands || true;
     let entityTypes = newOptions.entityTypes || [];
 
-    entityTypes = entityTypes.map(wrapWagtailIcon).map((type) => {
-      const plugin = PLUGINS[type.type];
-
+    entityTypes = entityTypes
+      .map(wrapWagtailIcon)
       // Override the properties defined in the JS plugin: Python should be the source of truth.
-      return { ...plugin, ...type };
-    });
+      .map((type) => ({ ...PLUGINS.entityTypes[type.type], ...type }));
+
+    controls = controls.map((type) => ({
+      ...PLUGINS.controls[type.type],
+      ...type,
+    }));
+    decorators = decorators.map((type) => ({
+      ...PLUGINS.decorators[type.type],
+      ...type,
+    }));
+    plugins = plugins.map((type) => ({
+      ...PLUGINS.plugins[type.type],
+      ...type,
+    }));
 
     // Only initialise the character count / max length on fields explicitly requiring it.
     if (field.hasAttribute('maxlength')) {
@@ -228,6 +251,8 @@ const initEditor = (selector, originalOptions, currentScript) => {
       inlineStyles: inlineStyles.map(wrapWagtailIcon),
       entityTypes,
       controls,
+      decorators,
+      plugins,
       commands,
       enableHorizontalRule,
     };

+ 54 - 7
client/src/components/Draftail/index.test.js

@@ -41,14 +41,45 @@ describe('Draftail', () => {
       document.body.innerHTML = '<input id="test" value="null" />';
       const field = document.querySelector('#test');
 
-      draftail.registerPlugin({
-        type: 'IMAGE',
-        source: () => {},
-        block: () => {},
-      });
+      draftail.registerPlugin(
+        {
+          type: 'IMAGE',
+          source: () => {},
+          block: () => null,
+        },
+        'entityTypes',
+      );
+
+      draftail.registerPlugin(
+        {
+          type: 'sentences',
+          meta: () => null,
+        },
+        'controls',
+      );
+
+      draftail.registerPlugin(
+        {
+          type: 'punctuation',
+          strategy: () => {},
+          component: () => null,
+        },
+        'decorators',
+      );
+
+      draftail.registerPlugin(
+        {
+          type: 'anchorify',
+          handlePastedText: () => 'not-handled',
+        },
+        'plugins',
+      );
 
       draftail.initEditor('#test', {
         entityTypes: [{ type: 'IMAGE' }],
+        controls: [{ type: 'sentences' }],
+        decorators: [{ type: 'punctuation' }],
+        plugins: [{ type: 'anchorify' }],
         enableHorizontalRule: true,
       });
 
@@ -153,14 +184,30 @@ describe('Draftail', () => {
 
   describe('#registerPlugin', () => {
     it('works', () => {
+      const plugin = { type: 'TEST' };
+      expect(draftail.registerPlugin(plugin, 'entityTypes')).toMatchObject({
+        TEST: plugin,
+      });
+      expect(draftail.registerPlugin(plugin, 'controls')).toMatchObject({
+        TEST: plugin,
+      });
+      expect(draftail.registerPlugin(plugin, 'decorators')).toMatchObject({
+        TEST: plugin,
+      });
+      expect(draftail.registerPlugin(plugin, 'plugins')).toMatchObject({
+        TEST: plugin,
+      });
+    });
+
+    it('supports legacy entityTypes registration', () => {
       const plugin = {
-        type: 'TEST',
+        type: 'TEST_ENTITY',
         source: null,
         decorator: null,
       };
 
       expect(draftail.registerPlugin(plugin)).toMatchObject({
-        TEST: plugin,
+        TEST_ENTITY: plugin,
       });
     });
   });

+ 2 - 2
client/src/entrypoints/admin/draftail.js

@@ -17,7 +17,7 @@ window.Draftail = Draftail;
 window.draftail = draftail;
 
 // Plugins for the built-in entities.
-const plugins = [
+const entityTypes = [
   {
     type: 'DOCUMENT',
     source: draftail.DocumentModalWorkflowSource,
@@ -41,4 +41,4 @@ const plugins = [
   },
 ];
 
-plugins.forEach(draftail.registerPlugin);
+entityTypes.forEach((type) => draftail.registerPlugin(type, 'entityTypes'));

+ 38 - 10
client/src/entrypoints/admin/draftail.test.js

@@ -1,21 +1,49 @@
 require('./draftail');
 
 describe('draftail', () => {
-  it('exposes module as global', () => {
-    expect(window.draftail).toBeDefined();
+  it('exposes a stable API', () => {
+    expect(window.draftail).toMatchInlineSnapshot(`
+      Object {
+        "DocumentModalWorkflowSource": [Function],
+        "DraftUtils": Object {
+          "addHorizontalRuleRemovingSelection": [Function],
+          "addLineBreak": [Function],
+          "applyMarkdownStyle": [Function],
+          "getCommandPalettePrompt": [Function],
+          "getEntitySelection": [Function],
+          "getEntityTypeStrategy": [Function],
+          "getSelectedBlock": [Function],
+          "getSelectionEntity": [Function],
+          "handleDeleteAtomic": [Function],
+          "handleHardNewline": [Function],
+          "handleNewLine": [Function],
+          "insertNewUnstyledBlock": [Function],
+          "removeBlock": [Function],
+          "removeBlockEntity": [Function],
+          "removeCommandPalettePrompt": [Function],
+          "resetBlockWithType": [Function],
+          "updateBlockEntity": [Function],
+        },
+        "EmbedModalWorkflowSource": [Function],
+        "ImageModalWorkflowSource": [Function],
+        "LinkModalWorkflowSource": [Function],
+        "ModalWorkflowSource": [Function],
+        "Tooltip": [Function],
+        "TooltipEntity": [Function],
+        "initEditor": [Function],
+        "registerPlugin": [Function],
+        "splitState": [Function],
+      }
+    `);
   });
 
   it('exposes package as global', () => {
     expect(window.Draftail).toBeDefined();
   });
 
-  it('has defaults registered', () => {
-    expect(Object.keys(window.draftail.registerPlugin({}))).toEqual([
-      'DOCUMENT',
-      'LINK',
-      'IMAGE',
-      'EMBED',
-      'undefined',
-    ]);
+  it('has default entities registered', () => {
+    expect(
+      Object.keys(window.draftail.registerPlugin({}, 'entityTypes')),
+    ).toEqual(['DOCUMENT', 'LINK', 'IMAGE', 'EMBED', 'undefined']);
   });
 });

+ 259 - 77
docs/extending/extending_draftail.md

@@ -2,15 +2,17 @@
 
 # Extending the Draftail Editor
 
-Wagtail’s rich text editor is built with [Draftail](https://www.draftail.org/), and its functionality can be extended through plugins.
+Wagtail’s rich text editor is built with [Draftail](https://www.draftail.org/), which supports different types of extensions.
 
-Plugins come in three types:
+## Formatting extensions
 
--   Inline styles – To format a portion of a line, for example `bold`, `italic` or `monospace`.
--   Blocks – To indicate the structure of the content, for example, `blockquote`, `ol`.
--   Entities – To enter additional data/metadata, for example, `link` (with a URL) or `image` (with a file).
+Draftail supports three types of formatting:
 
-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:
+-   **Inline styles** – To format a portion of a line, for example `bold`, `italic` or `monospace`. Text can have as many inline styles as needed – for example bold _and_ italic at the same time.
+-   **Blocks** – To indicate the structure of the content, for example, `blockquote`, `ol`. Any given text can only be of one block type.
+-   **Entities** – To enter additional data/metadata, for example, `link` (with a URL) or `image` (with a file). Text can only have one entity applied at a time.
+
+All of these extensions 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:
 
 ```python
 import wagtail.admin.rich_text.editors.draftail.features as draftail_features
@@ -68,13 +70,13 @@ For detailed configuration options, head over to the [Draftail documentation](ht
 -   To display the control in the toolbar, combine `icon`, `label` and `description`.
 -   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 for example `'icon': ['M100 100 H 900 V 900 H 100 Z'],`. The paths need to be set for a 1024x1024 viewbox.
 
-## Creating new inline styles
+### Creating new inline styles
 
 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.
 
 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`.
 
-## Creating new blocks
+### Creating new blocks
 
 Blocks are nearly as simple as inline styles:
 
@@ -119,7 +121,7 @@ Optionally, we can also define styles for the blocks with the `Draftail-block--h
 
 That’s it! The extra complexity is that you may need to write CSS to style the blocks in the editor.
 
-## Creating new entities
+### Creating new entities
 
 ```{warning}
 This is an advanced feature. Please carefully consider whether you really need this.
@@ -147,7 +149,7 @@ To go further, please look at the [Draftail documentation](https://www.draftail.
 
 Here is a detailed example to showcase how those tools are used in the context of Wagtail.
 For the sake of our example, we can imagine a news team working at a financial newspaper.
-They want to write articles about the stock market, refer to specific stocks anywhere inside of their content (for example "$TSLA" tokens in a sentence), and then have their article automatically enriched with the stock’s information (a link, a number, a sparkline).
+They want to write articles about the stock market, refer to specific stocks anywhere inside of their content (for example "$NEE" tokens in a sentence), and then have their article automatically enriched with the stock’s information (a link, a number, a sparkline).
 
 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:
 
@@ -228,55 +230,47 @@ Note how they both do similar conversions, but use different APIs. `to_database_
 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:
 
 ```javascript
-const React = window.React;
-const Modifier = window.DraftJS.Modifier;
-const EditorState = window.DraftJS.EditorState;
+// Not a real React component – just creates the entities as soon as it is rendered.
+class StockSource extends window.React.Component {
+  componentDidMount() {
+    const { editorState, entityType, onComplete } = this.props;
 
-const DEMO_STOCKS = ['AMD', 'AAPL', 'TWTR', 'TSLA', 'BTC'];
+    const content = editorState.getCurrentContent();
+    const selection = editorState.getSelection();
 
-// Not a real React component – just creates the entities as soon as it is rendered.
-class StockSource extends React.Component {
-    componentDidMount() {
-        const { editorState, entityType, onComplete } = this.props;
-
-        const content = editorState.getCurrentContent();
-        const selection = editorState.getSelection();
-
-        const randomStock =
-            DEMO_STOCKS[Math.floor(Math.random() * DEMO_STOCKS.length)];
-
-        // Uses the Draft.js API to create a new entity with the right data.
-        const contentWithEntity = content.createEntity(
-            entityType.type,
-            'IMMUTABLE',
-            {
-                stock: randomStock,
-            },
-        );
-        const entityKey = contentWithEntity.getLastCreatedEntityKey();
+    const demoStocks = ['AMD', 'AAPL', 'NEE', 'FSLR'];
+    const randomStock = demoStocks[Math.floor(Math.random() * demoStocks.length)];
 
-        // We also add some text for the entity to be activated on.
-        const text = `$${randomStock}`;
+    // Uses the Draft.js API to create a new entity with the right data.
+    const contentWithEntity = content.createEntity(
+      entityType.type,
+      'IMMUTABLE',
+      { stock: randomStock },
+    );
+    const entityKey = contentWithEntity.getLastCreatedEntityKey();
 
-        const newContent = Modifier.replaceText(
-            content,
-            selection,
-            text,
-            null,
-            entityKey,
-        );
-        const nextState = EditorState.push(
-            editorState,
-            newContent,
-            'insert-characters',
-        );
+    // We also add some text for the entity to be activated on.
+    const text = `$${randomStock}`;
 
-        onComplete(nextState);
-    }
+    const newContent = window.DraftJS.Modifier.replaceText(
+      content,
+      selection,
+      text,
+      null,
+      entityKey,
+    );
+    const nextState = window.DraftJS.EditorState.push(
+      editorState,
+      newContent,
+      'insert-characters',
+    );
 
-    render() {
-        return null;
-    }
+    onComplete(nextState);
+  }
+
+  render() {
+    return null;
+  }
 }
 ```
 
@@ -287,19 +281,19 @@ We then create the decorator component:
 
 ```javascript
 const Stock = (props) => {
-    const { entityKey, contentState } = props;
-    const data = contentState.getEntity(entityKey).getData();
-
-    return React.createElement(
-        'a',
-        {
-            role: 'button',
-            onMouseUp: () => {
-                window.open(`https://finance.yahoo.com/quote/${data.stock}`);
-            },
-        },
-        props.children,
-    );
+  const { entityKey, contentState } = props;
+  const data = contentState.getEntity(entityKey).getData();
+
+  return window.React.createElement(
+    'a',
+    {
+      role: 'button',
+      onMouseUp: () => {
+        window.open(`https://finance.yahoo.com/quote/${data.stock}`);
+      },
+    },
+    props.children,
+  );
 };
 ```
 
@@ -310,18 +304,18 @@ Finally, we register the JS components of our plugin:
 ```javascript
 // Register the plugin directly on script execution so the editor loads it when initialising.
 window.draftail.registerPlugin({
-    type: 'STOCK',
-    source: StockSource,
-    decorator: Stock,
-});
+  type: 'STOCK',
+  source: StockSource,
+  decorator: Stock,
+}, 'entityTypes');
 ```
 
 And that’s it! All of this setup will finally produce the following HTML on the site’s front-end:
 
 ```html
 <p>
-    Anyone following Elon Musk’s <span data-stock="TSLA">$TSLA</span> should
-    also look into <span data-stock="BTC">$BTC</span>.
+    Anyone following NextEra technology <span data-stock="NEE">$NEE</span> should
+    also look into <span data-stock="FSLR">$FSLR</span>.
 </p>
 ```
 
@@ -329,17 +323,205 @@ To fully complete the demo, we can add a bit of JavaScript to the front-end in o
 
 ```javascript
 document.querySelectorAll('[data-stock]').forEach((elt) => {
-    const link = document.createElement('a');
-    link.href = `https://finance.yahoo.com/quote/${elt.dataset.stock}`;
-    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>`;
+  const link = document.createElement('a');
+  link.href = `https://finance.yahoo.com/quote/${elt.dataset.stock}`;
+  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>`;
 
-    elt.innerHTML = '';
-    elt.appendChild(link);
+  elt.innerHTML = '';
+  elt.appendChild(link);
 });
 ```
 
 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 [StreamField](streamfield_topic) is the go-to way to create block-level rich text in Wagtail.
 
+(extending_the_draftail_editor_advanced)=
+
+## Other editor extensions
+
+Draftail has additional APIs for more complex customisations:
+
+-   **Controls** – To add arbitrary UI elements to editor toolbars.
+-   **Decorators** – For arbitrary text decorations / highlighting.
+-   **Plugins** – For direct access to all Draft.js APIs.
+
+### Custom toolbar controls
+
+To add an arbitrary new UI element to editor toolbars, Draftail comes with a [controls API](https://www.draftail.org/docs/arbitrary-controls). Controls can be arbitrary React components, which can get and set the editor state. Note controls update on _every keystroke_ in the editor – make sure they render fast!
+
+Here is an example with a simple sentence counter – first, registering the editor feature in a `wagtail_hooks.py`:
+
+```python
+from wagtail.admin.rich_text.editors.draftail.features import ControlFeature
+
+
+@hooks.register('register_rich_text_features')
+def register_sentences_counter(features):
+    feature_name = 'sentences'
+    features.default_features.append(feature_name)
+
+    features.register_editor_plugin(
+        'draftail',
+        feature_name,
+        ControlFeature({
+            'type': feature_name,
+        },
+        js=['draftail_sentences.js'],
+        ),
+    )
+```
+
+Then, `draftail_sentences.js` declares a React component that will be rendered in the "meta" bottom toolbar of the editor:
+
+```javascript
+const countSentences = (str) =>
+  str ? (str.match(/[.?!…]+./g) || []).length + 1 : 0;
+
+const SentenceCounter = ({ getEditorState }) => {
+  const editorState = getEditorState();
+  const content = editorState.getCurrentContent();
+  const text = content.getPlainText();
+
+  return window.React.createElement('div', {
+    className: 'w-inline-block w-tabular-nums w-help-text w-mr-4',
+  }, `Sentences: ${countSentences(text)}`);
+}
+
+window.draftail.registerPlugin({
+  type: 'sentences',
+  meta: SentenceCounter,
+}, 'controls');
+```
+
+### Text decorators
+
+The [decorators API](https://www.draftail.org/docs/decorators) is how Draftail / Draft.js supports highlighting text with special formatting in the editor. It uses the [CompositeDecorator](https://draftjs.org/docs/advanced-topics-decorators/#compositedecorator) API, with each entry having a `strategy` function to determine what text to target, and a `component` function to render the decoration.
+
+There are two important considerations when using this API:
+
+-   Order matters: only one decorator can render per character in the editor. This includes any entities that are rendered as decorations.
+-   For performance reasons, Draft.js only re-renders decorators that are on the currently-focused line of text.
+
+Here is an example with highlighting of problematic punctuation – first, registering the editor feature in a `wagtail_hooks.py`:
+
+```python
+from wagtail.admin.rich_text.editors.draftail.features import DecoratorFeature
+
+
+@hooks.register('register_rich_text_features')
+def register_punctuation_highlighter(features):
+    feature_name = 'punctuation'
+    features.default_features.append(feature_name)
+
+    features.register_editor_plugin(
+        'draftail',
+        feature_name,
+        DecoratorFeature({
+            'type': feature_name,
+        },
+            js=['draftail_punctuation.js'],
+        ),
+    )
+```
+
+Then, `draftail_punctuation.js` defines the strategy and the highlighting component:
+
+```javascript
+const PUNCTUATION = /(\.\.\.|!!|\?!)/g;
+
+const punctuationStrategy = (block, callback) => {
+  const text = block.getText();
+  let matches;
+  while ((matches = PUNCTUATION.exec(text)) !== null) {
+    callback(matches.index, matches.index + matches[0].length);
+  }
+};
+
+const errorHighlight = {
+  color: 'var(--w-color-text-error)',
+  outline: '1px solid currentColor',
+}
+
+const PunctuationHighlighter = ({ children }) => (
+  window.React.createElement('span', { style: errorHighlight, title: 'refer to our styleguide' }, children)
+);
+
+window.draftail.registerPlugin({
+  type: 'punctuation',
+  strategy: punctuationStrategy,
+  component: PunctuationHighlighter,
+}, 'decorators');
+```
+
+### Arbitrary plugins
+
+```{warning}
+This is an advanced feature. Please carefully consider whether you really need this.
+```
+
+Draftail supports plugins following the [Draft.js Plugins](https://www.draft-js-plugins.com/) architecture. Such plugins are the most advanced and powerful type of extension for the editor, offering customisation capabilities equal to what would be possible with a custom Draft.js editor.
+
+A common scenario where this API can help is to add bespoke copy-paste processing. Here is a simple example, automatically converting URL anchor hash references to links. First, let’s register the extension in Python:
+
+```python
+@hooks.register('register_rich_text_features')
+def register_anchorify(features):
+    feature_name = 'anchorify'
+    features.default_features.append(feature_name)
+
+    features.register_editor_plugin(
+        'draftail',
+        feature_name,
+        PluginFeature({
+            'type': feature_name,
+        },
+            js=['draftail_anchorify.js'],
+        ),
+    )
+```
+
+Then, in `draftail_anchorify.js`:
+
+```javascript
+const anchorifyPlugin = {
+  type: 'anchorify',
+
+  handlePastedText(text, html, editorState, { setEditorState }) {
+    let nextState = editorState;
+
+    if (text.match(/^#[a-zA-Z0-9_-]+$/ig)) {
+      const selection = nextState.getSelection();
+      let content = nextState.getCurrentContent();
+      content = content.createEntity("LINK", "MUTABLE", { url: text });
+      const entityKey = content.getLastCreatedEntityKey();
+
+      if (selection.isCollapsed()) {
+        content = window.DraftJS.Modifier.insertText(
+          content,
+          selection,
+          text,
+          undefined,
+          entityKey,
+        )
+        nextState = window.DraftJS.EditorState.push(
+          nextState,
+          content,
+          "insert-fragment",
+        );
+      } else {
+        nextState = window.DraftJS.RichUtils.toggleLink(nextState, selection, entityKey);
+      }
+
+      setEditorState(nextState);
+      return "handled";
+    }
+
+    return "not-handled";
+  },
+};
+
+window.draftail.registerPlugin(anchorifyPlugin, 'plugins');
+```
+
 ## Integration of the Draftail widgets
 
 To further customise how the Draftail widgets are integrated into the UI, there are additional extension points for CSS and JS:

+ 1 - 0
docs/releases/5.1.md

@@ -40,6 +40,7 @@ Thank you to Damilola for his work, and to Google for sponsoring this project.
  * Optimise queries in collection permission policies using cache on the user object (Sage Abdullah)
  * Phone numbers entered via a link chooser will now have any spaces stripped out, ensuring a valid `href="tel:..."` attribute (Sahil Jangra)
  * Auto-select the `StreamField` block when only one block type is declared (Sébastien Corbin)
+ * Add support for more [advanced Draftail customisation APIs](extending_the_draftail_editor_advanced) (Thibaud Colas)
 
 ### Bug fixes
 

+ 18 - 0
wagtail/admin/rich_text/editors/draftail/features.py

@@ -74,3 +74,21 @@ class InlineStyleFeature(ListFeature):
     """A feature which is listed in the inlineStyles list of the options"""
 
     option_name = "inlineStyles"
+
+
+class DecoratorFeature(ListFeature):
+    """A feature which is listed in the decorators list of the options"""
+
+    option_name = "decorators"
+
+
+class ControlFeature(ListFeature):
+    """A feature which is listed in the controls list of the options"""
+
+    option_name = "controls"
+
+
+class PluginFeature(ListFeature):
+    """A feature which is listed in the plugins list of the options"""
+
+    option_name = "plugins"