Просмотр исходного кода

Implement combined rich text split and block insertion (#8923)

Co-authored-by: Thibaud Colas <thibaudcolas@gmail.com>
Jacob Topp-Mugglestone 2 лет назад
Родитель
Сommit
0cdb9d8915

+ 1 - 0
CHANGELOG.txt

@@ -69,6 +69,7 @@ Changelog
  * Add support for right-to-left (RTL) languages to the rich text editor (Thibaud Colas)
  * Change rich text editor placeholder to follow the user’s focus on empty blocks (Thibaud Colas)
  * Add rich text editor empty block highlight by showing their block type (Thibaud Colas)
+ * Add ability to split a rich text field and insert a StreamField block at the same time (Jacob Topp-Mugglestone)
  * Make ModelAdmin InspectView footer actions consistent with other parts of the UI (Thibaud Colas)
  * Introduce a new auto-updating preview panel inside the page editor (Sage Abdullah)
  * Add support for Twitter and other text-only embeds in Draftail embed previews (Iman Syed, Paarth Agarwal)

+ 4 - 0
client/src/components/Draftail/index.js

@@ -5,6 +5,7 @@ import {
   InlineToolbar,
   MetaToolbar,
   CommandPalette,
+  DraftUtils,
 } from 'draftail';
 import { Provider } from 'react-redux';
 
@@ -24,6 +25,7 @@ import MaxLength from './controls/MaxLength';
 import EditorFallback from './EditorFallback/EditorFallback';
 import CommentableEditor, {
   getSplitControl,
+  splitState,
 } from './CommentableEditor/CommentableEditor';
 
 export { default as Link, onPasteLink } from './decorators/Link';
@@ -200,7 +202,9 @@ const initEditor = (selector, originalOptions, currentScript) => {
 export default {
   initEditor,
   getSplitControl,
+  splitState,
   registerPlugin,
+  DraftUtils,
   // Components exposed for third-party reuse.
   ModalWorkflowSource,
   ImageModalWorkflowSource,

+ 15 - 0
client/src/components/StreamField/blocks/BaseSequenceBlock.js

@@ -192,6 +192,13 @@ export class BaseSequenceChild extends EventEmitter {
       enabled: true,
       fn: this.split.bind(this),
     });
+    capabilities.set('addSibling', {
+      enabled: true,
+      fn: this.addSibling.bind(this),
+      blockGroups: this.sequence.getBlockGroups(),
+      getBlockCount: this.sequence.getBlockCount.bind(this.sequence),
+      getBlockMax: this.sequence.getBlockMax.bind(this.sequence),
+    });
 
     this.block = this.blockDef.render(
       blockElement,
@@ -217,6 +224,10 @@ export class BaseSequenceChild extends EventEmitter {
     button.render(this.actionsContainerElement);
   }
 
+  addSibling(opts) {
+    this.sequence._onRequestInsert(this.index + 1, opts);
+  }
+
   moveUp() {
     this.sequence.moveBlockUp(this.index);
   }
@@ -393,6 +404,10 @@ export class BaseSequenceBlock {
   _getChildDataForInsertion(opts) {
     throw new Error('not implemented');
   }
+
+  getBlockGroups() {
+    throw new Error('not implemented');
+  }
   /* eslint-enable @typescript-eslint/no-unused-vars */
 
   clear() {

+ 13 - 8
client/src/components/StreamField/blocks/ListBlock.js

@@ -200,19 +200,11 @@ export class ListBlock extends BaseSequenceBlock {
         for (let i = 0; i < this.inserters.length; i++) {
           this.inserters[i].disable();
         }
-        for (let i = 0; i < this.children.length; i++) {
-          this.children[i].disableDuplication();
-          this.children[i].disableSplit();
-        }
       } else {
         /* allow adding new blocks */
         for (let i = 0; i < this.inserters.length; i++) {
           this.inserters[i].enable();
         }
-        for (let i = 0; i < this.children.length; i++) {
-          this.children[i].enableDuplication();
-          this.children[i].enableSplit();
-        }
       }
     }
   }
@@ -296,6 +288,19 @@ export class ListBlock extends BaseSequenceBlock {
       }
     });
   }
+
+  getBlockGroups() {
+    const group = ['', [this.blockDef.childBlockDef]];
+    return [group];
+  }
+
+  getBlockCount() {
+    return this.children.length;
+  }
+
+  getBlockMax() {
+    return this.blockDef.meta.maxNum || 0;
+  }
 }
 
 export class ListBlockDefinition {

+ 15 - 50
client/src/components/StreamField/blocks/ListBlock.test.js

@@ -360,13 +360,13 @@ describe('telepath: wagtail.blocks.ListBlock with maxNum set', () => {
   };
 
   const assertCannotAddBlock = () => {
-    // Test duplicate button
+    // Test duplicate button is always enabled
     // querySelector always returns the first element it sees so this only checks the first block
     expect(
       document
         .querySelector('button[title="Duplicate"]')
         .getAttribute('disabled'),
-    ).toEqual('disabled');
+    ).toBe(null);
 
     // Test menu
     expect(
@@ -397,67 +397,36 @@ describe('telepath: wagtail.blocks.ListBlock with maxNum set', () => {
     assertCannotAddBlock();
   });
 
-  test('insert disables new block', () => {
-    document.body.innerHTML = '<div id="placeholder"></div>';
-    const boundBlock = blockDef.render($('#placeholder'), 'the-prefix', [
-      { value: 'First value', id: '11111111-1111-1111-1111-111111111111' },
-      { value: 'Second value', id: '22222222-2222-2222-2222-222222222222' },
-    ]);
-
-    assertCanAddBlock();
-
-    boundBlock.insert('Third value', 2);
-
-    assertCannotAddBlock();
-  });
-
-  test('delete enables new block', () => {
-    document.body.innerHTML = '<div id="placeholder"></div>';
-    const boundBlock = blockDef.render($('#placeholder'), 'the-prefix', [
-      { value: 'First value', id: '11111111-1111-1111-1111-111111111111' },
-      { value: 'Second value', id: '22222222-2222-2222-2222-222222222222' },
-      { value: 'Third value', id: '33333333-3333-3333-3333-333333333333' },
-    ]);
-
-    assertCannotAddBlock();
-
-    boundBlock.deleteBlock(2);
-
-    assertCanAddBlock();
-  });
-
-  test('initialising at maxNum disables split', () => {
+  test('addSibling capability works', () => {
     document.body.innerHTML = '<div id="placeholder"></div>';
     const boundBlock = blockDef.render($('#placeholder'), 'the-prefix', [
       { value: 'First value', id: '11111111-1111-1111-1111-111111111111' },
       { value: 'Second value', id: '22222222-2222-2222-2222-222222222222' },
       { value: 'Third value', id: '33333333-3333-3333-3333-333333333333' },
     ]);
-
-    expect(
-      boundBlock.children[0].block.parentCapabilities.get('split').enabled,
-    ).toBe(false);
+    const addSibling =
+      boundBlock.children[0].block.parentCapabilities.get('addSibling');
+    expect(addSibling.getBlockMax()).toEqual(3);
+    expect(addSibling.getBlockCount()).toEqual(3);
+    addSibling.fn();
+    expect(boundBlock.children.length).toEqual(4);
   });
 
-  test('insert disables split', () => {
+  test('insert disables new block', () => {
     document.body.innerHTML = '<div id="placeholder"></div>';
     const boundBlock = blockDef.render($('#placeholder'), 'the-prefix', [
       { value: 'First value', id: '11111111-1111-1111-1111-111111111111' },
       { value: 'Second value', id: '22222222-2222-2222-2222-222222222222' },
     ]);
 
-    expect(
-      boundBlock.children[0].block.parentCapabilities.get('split').enabled,
-    ).toBe(true);
+    assertCanAddBlock();
 
     boundBlock.insert('Third value', 2);
 
-    expect(
-      boundBlock.children[0].block.parentCapabilities.get('split').enabled,
-    ).toBe(false);
+    assertCannotAddBlock();
   });
 
-  test('delete enables split', () => {
+  test('delete enables new block', () => {
     document.body.innerHTML = '<div id="placeholder"></div>';
     const boundBlock = blockDef.render($('#placeholder'), 'the-prefix', [
       { value: 'First value', id: '11111111-1111-1111-1111-111111111111' },
@@ -465,14 +434,10 @@ describe('telepath: wagtail.blocks.ListBlock with maxNum set', () => {
       { value: 'Third value', id: '33333333-3333-3333-3333-333333333333' },
     ]);
 
-    expect(
-      boundBlock.children[0].block.parentCapabilities.get('split').enabled,
-    ).toBe(false);
+    assertCannotAddBlock();
 
     boundBlock.deleteBlock(2);
 
-    expect(
-      boundBlock.children[0].block.parentCapabilities.get('split').enabled,
-    ).toBe(true);
+    assertCanAddBlock();
   });
 });

+ 44 - 26
client/src/components/StreamField/blocks/StreamBlock.js

@@ -233,6 +233,9 @@ export class StreamBlock extends BaseSequenceBlock {
     // StreamChild objects for the current (non-deleted) child blocks
     this.children = [];
 
+    // Cache for child block counting (not guaranteed to be fully populated)
+    this.childBlockCounts = new Map();
+
     // Insertion control objects - there are one more of these than there are children.
     // The control at index n will insert a block at index n
     this.inserters = [];
@@ -259,6 +262,36 @@ export class StreamBlock extends BaseSequenceBlock {
     }
   }
 
+  getBlockGroups() {
+    return this.blockDef.groupedChildBlockDefs;
+  }
+
+  getBlockCount(type) {
+    // Get the block count for a particular type, or if none is provided, the total block count
+    if (!type) {
+      return this.children.length;
+    }
+    if (!this.childBlockCounts.has(type)) {
+      this._updateBlockCount(type);
+    }
+    return this.childBlockCounts.get(type) || 0;
+  }
+
+  getBlockMax(type) {
+    // Get the maximum number of blocks allowable for a particular type, or if none is provided, the total maximum
+    if (!type) {
+      return this.blockDef.meta.maxNum;
+    }
+    return this.blockDef.meta.blockCounts[type]?.max_num;
+  }
+
+  _updateBlockCount(type) {
+    const currentBlockCount = this.children.filter(
+      (child) => child.type === type,
+    ).length;
+    this.childBlockCounts.set(type, currentBlockCount);
+  }
+
   /*
    * Called whenever a block is added or removed
    *
@@ -267,6 +300,7 @@ export class StreamBlock extends BaseSequenceBlock {
   blockCountChanged() {
     super.blockCountChanged();
     this.canAddBlock = true;
+    this.childBlockCounts.clear();
 
     if (
       typeof this.blockDef.meta.maxNum === 'number' &&
@@ -275,38 +309,22 @@ export class StreamBlock extends BaseSequenceBlock {
       this.canAddBlock = false;
     }
 
-    // If we can add blocks, check if there are any block types that have count limits
+    // Check if there are any block types that have count limits
     this.disabledBlockTypes = new Set();
-    if (this.canAddBlock) {
-      for (const blockType in this.blockDef.meta.blockCounts) {
-        if (this.blockDef.meta.blockCounts.hasOwnProperty(blockType)) {
-          const counts = this.blockDef.meta.blockCounts[blockType];
-
-          if (typeof counts.max_num === 'number') {
-            const currentBlockCount = this.children.filter(
-              (child) => child.type === blockType,
-            ).length;
-
-            if (currentBlockCount >= counts.max_num) {
-              this.disabledBlockTypes.add(blockType);
-            }
+    for (const blockType in this.blockDef.meta.blockCounts) {
+      if (this.blockDef.meta.blockCounts.hasOwnProperty(blockType)) {
+        const maxNum = this.getBlockMax(blockType);
+
+        if (typeof maxNum === 'number') {
+          const currentBlockCount = this.getBlockCount(blockType);
+
+          if (currentBlockCount >= maxNum) {
+            this.disabledBlockTypes.add(blockType);
           }
         }
       }
     }
 
-    for (let i = 0; i < this.children.length; i++) {
-      const canDuplicate =
-        this.canAddBlock && !this.disabledBlockTypes.has(this.children[i].type);
-
-      if (canDuplicate) {
-        this.children[i].enableDuplication();
-        this.children[i].enableSplit();
-      } else {
-        this.children[i].disableDuplication();
-        this.children[i].disableSplit();
-      }
-    }
     for (let i = 0; i < this.inserters.length; i++) {
       this.inserters[i].setNewBlockRestrictions(
         this.canAddBlock,

+ 41 - 81
client/src/components/StreamField/blocks/StreamBlock.test.js

@@ -495,13 +495,13 @@ describe('telepath: wagtail.blocks.StreamBlock with maxNum set', () => {
   };
 
   const assertCannotAddBlock = () => {
-    // Test duplicate button
+    // Test duplicate button is still enabled even when at block limit
     // querySelector always returns the first element it sees so this only checks the first block
     expect(
       document
         .querySelector('button[title="Duplicate"]')
         .getAttribute('disabled'),
-    ).toEqual('disabled');
+    ).toBe(null);
 
     // Test menu
     expect(
@@ -554,6 +554,30 @@ describe('telepath: wagtail.blocks.StreamBlock with maxNum set', () => {
     assertCannotAddBlock();
   });
 
+  test('addSibling capability works', () => {
+    document.body.innerHTML = '<div id="placeholder"></div>';
+    const boundBlock = blockDef.render($('#placeholder'), 'the-prefix', [
+      {
+        id: '1',
+        type: 'test_block_a',
+        value: 'First value',
+      },
+      {
+        id: '2',
+        type: 'test_block_b',
+        value: 'Second value',
+      },
+    ]);
+    const addSibling =
+      boundBlock.children[0].block.parentCapabilities.get('addSibling');
+    expect(addSibling.getBlockMax('test_block_a')).toBeUndefined();
+    expect(addSibling.getBlockMax()).toEqual(3);
+    expect(addSibling.getBlockCount()).toEqual(2);
+    addSibling.fn({ type: 'test_block_a' });
+    expect(boundBlock.children.length).toEqual(3);
+    expect(boundBlock.children[1].type).toEqual('test_block_a');
+  });
+
   test('insert disables new block', () => {
     document.body.innerHTML = '<div id="placeholder"></div>';
     const boundBlock = blockDef.render($('#placeholder'), 'the-prefix', [
@@ -692,13 +716,13 @@ describe('telepath: wagtail.blocks.StreamBlock with blockCounts.max_num set', ()
   };
 
   const assertCannotAddBlock = () => {
-    // Test duplicate button
+    // Test duplicate button is always enabled
     // querySelector always returns the first element it sees so this only checks the first block
     expect(
       document
         .querySelector('button[title="Duplicate"]')
         .getAttribute('disabled'),
-    ).toEqual('disabled');
+    ).toBe(null);
 
     // Test menu item
     expect(
@@ -708,7 +732,7 @@ describe('telepath: wagtail.blocks.StreamBlock with blockCounts.max_num set', ()
     ).toEqual('disabled');
   };
 
-  test('single instance allows creation of new block and duplication', () => {
+  test('addSibling capability works', () => {
     document.body.innerHTML = '<div id="placeholder"></div>';
     const boundBlock = blockDef.render($('#placeholder'), 'the-prefix', [
       {
@@ -722,12 +746,16 @@ describe('telepath: wagtail.blocks.StreamBlock with blockCounts.max_num set', ()
         value: 'Second value',
       },
     ]);
-    boundBlock.inserters[0].open();
-
-    assertCanAddBlock();
+    const addSibling =
+      boundBlock.children[0].block.parentCapabilities.get('addSibling');
+    expect(addSibling.getBlockMax('test_block_a')).toEqual(2);
+    expect(addSibling.getBlockCount('test_block_a')).toEqual(1);
+    addSibling.fn({ type: 'test_block_a' });
+    expect(boundBlock.children.length).toEqual(3);
+    expect(boundBlock.children[1].type).toEqual('test_block_a');
   });
 
-  test('initialising at max_num disables adding new block of that type and duplication', () => {
+  test('single instance allows creation of new block and duplication', () => {
     document.body.innerHTML = '<div id="placeholder"></div>';
     const boundBlock = blockDef.render($('#placeholder'), 'the-prefix', [
       {
@@ -740,75 +768,13 @@ describe('telepath: wagtail.blocks.StreamBlock with blockCounts.max_num set', ()
         type: 'test_block_b',
         value: 'Second value',
       },
-      {
-        id: '3',
-        type: 'test_block_a',
-        value: 'Third value',
-      },
     ]);
     boundBlock.inserters[0].open();
 
-    assertCannotAddBlock();
-  });
-
-  test('initialising at max_num disables splitting', () => {
-    document.body.innerHTML = '<div id="placeholder"></div>';
-    const boundBlock = blockDef.render($('#placeholder'), 'the-prefix', [
-      {
-        id: '1',
-        type: 'test_block_a',
-        value: 'First value',
-      },
-      {
-        id: '2',
-        type: 'test_block_b',
-        value: 'Second value',
-      },
-      {
-        id: '3',
-        type: 'test_block_a',
-        value: 'Third value',
-      },
-    ]);
-    expect(
-      boundBlock.children[2].block.parentCapabilities.get('split').enabled,
-    ).toBe(false);
-  });
-
-  test('insert disables splitting', () => {
-    document.body.innerHTML = '<div id="placeholder"></div>';
-    const boundBlock = blockDef.render($('#placeholder'), 'the-prefix', [
-      {
-        id: '1',
-        type: 'test_block_a',
-        value: 'First value',
-      },
-      {
-        id: '2',
-        type: 'test_block_b',
-        value: 'Second value',
-      },
-    ]);
-
-    expect(
-      boundBlock.children[0].block.parentCapabilities.get('split').enabled,
-    ).toBe(true);
-
-    boundBlock.insert(
-      {
-        id: '3',
-        type: 'test_block_a',
-        value: 'Third value',
-      },
-      2,
-    );
-
-    expect(
-      boundBlock.children[0].block.parentCapabilities.get('split').enabled,
-    ).toBe(false);
+    assertCanAddBlock();
   });
 
-  test('delete enables splitting', () => {
+  test('initialising at max_num disables adding new block of that type', () => {
     document.body.innerHTML = '<div id="placeholder"></div>';
     const boundBlock = blockDef.render($('#placeholder'), 'the-prefix', [
       {
@@ -827,15 +793,9 @@ describe('telepath: wagtail.blocks.StreamBlock with blockCounts.max_num set', ()
         value: 'Third value',
       },
     ]);
-    expect(
-      boundBlock.children[0].block.parentCapabilities.get('split').enabled,
-    ).toBe(false);
-
-    boundBlock.deleteBlock(2);
+    boundBlock.inserters[0].open();
 
-    expect(
-      boundBlock.children[0].block.parentCapabilities.get('split').enabled,
-    ).toBe(true);
+    assertCannotAddBlock();
   });
 
   test('insert disables new block', () => {

+ 134 - 26
client/src/entrypoints/admin/telepath/widgets.js

@@ -1,4 +1,5 @@
 /* global $ */
+import { gettext } from '../../../utils/gettext';
 
 class BoundWidget {
   constructor(element, name, idForLabel, initialState, parentCapabilities) {
@@ -170,18 +171,136 @@ class DraftailRichTextArea {
     this.options = options;
   }
 
-  render(container, name, id, initialState, parentCapabilities) {
-    const originalOptions = this.options;
-    const options = { ...originalOptions };
-    const capabilities = parentCapabilities || new Map();
+  /**
+   * Given original options object, return the options overrides
+   * that account for its contextual abilities (splitting or adding additional blocks)
+   */
+  getCapabilityOptions(originalOptions, parentCapabilities) {
+    const options = {};
+    const capabilities = parentCapabilities;
     const split = capabilities.get('split');
+    const addSibling = capabilities.get('addSibling');
+    let blockCommands = [];
     if (split) {
-      options.controls = options.controls ? [...options.controls] : [];
-      options.controls.push(
-        // eslint-disable-next-line no-undef
-        draftail.getSplitControl(split.fn, !!split.enabled),
-      );
+      options.controls = originalOptions.controls
+        ? [...originalOptions.controls]
+        : [];
+      options.controls.push({
+        meta: window.draftail.getSplitControl(split.fn, !!split.enabled),
+      });
+
+      const blockGroups =
+        addSibling && addSibling.enabled && split.enabled
+          ? addSibling.blockGroups
+          : [];
+      // Create commands for splitting + inserting a block. This requires both the split
+      // and addSibling capabilities to be available and enabled
+      blockCommands = blockGroups.map(([group, blocks]) => {
+        const blockControls = blocks.map((blockDef) => {
+          const blockMax = addSibling.getBlockMax(blockDef.name);
+          return {
+            icon: `#icon-${blockDef.meta.icon}`,
+            description: blockDef.meta.label,
+            type: blockDef.name,
+            render: ({ option }) => {
+              // If the specific block has a limit, render the current number/max alongside the description
+              const limitText =
+                typeof blockMax === 'number'
+                  ? ` (${addSibling.getBlockCount(blockDef.name)}/${blockMax})`
+                  : '';
+              return `${option.description}${limitText}`;
+            },
+            onSelect: ({ editorState }) => {
+              // Reset the current block to unstyled and empty before splitting, so we remove the command prompt if used.
+              const result = window.draftail.splitState(
+                window.draftail.DraftUtils.resetBlockWithType(
+                  editorState,
+                  'unstyled',
+                ),
+              );
+              // Run the split after a timeout to circumvent potential race condition.
+              setTimeout(() => {
+                if (result) {
+                  split.fn(
+                    result.stateBefore,
+                    result.stateAfter,
+                    result.shouldMoveCommentFn,
+                  );
+                }
+                addSibling.fn({ type: blockDef.name });
+              }, 50);
+            },
+          };
+        });
+        return {
+          label: group || gettext('Blocks'),
+          type: `streamfield-${group}`,
+          items: blockControls,
+        };
+      });
+
+      blockCommands.push({
+        label: 'Actions',
+        type: 'custom-actions',
+        items: [
+          {
+            icon: '#icon-cut',
+            description: gettext('Split block'),
+            type: 'split',
+            render: ({ option, getEditorState }) => {
+              const editorState = getEditorState();
+              const content = editorState.getCurrentContent();
+              const blocks = content.getBlockMap();
+              const text = `${option.description} (will split ${blocks.size} blocks)`;
+              return text;
+            },
+            onSelect: ({ editorState }) => {
+              const result = window.draftail.splitState(
+                window.draftail.DraftUtils.resetBlockWithType(
+                  editorState,
+                  'unstyled',
+                ),
+              );
+              // Run the split after a timeout to circumvent potential race condition.
+              setTimeout(() => {
+                if (result) {
+                  split.fn(
+                    result.stateBefore,
+                    result.stateAfter,
+                    result.shouldMoveCommentFn,
+                  );
+                }
+              }, 50);
+            },
+          },
+        ],
+      });
     }
+
+    options.commands = [
+      {
+        label: gettext('Rich text'),
+        type: 'blockTypes',
+      },
+      {
+        type: 'entityTypes',
+      },
+      ...blockCommands,
+    ];
+
+    return options;
+  }
+
+  getFullOptions(originalOptions, parentCapabilities) {
+    return {
+      ...originalOptions,
+      ...this.getCapabilityOptions(originalOptions, parentCapabilities),
+    };
+  }
+
+  render(container, name, id, initialState, parentCapabilities) {
+    const originalOptions = this.options;
+    const capabilities = new Map(parentCapabilities);
     const input = document.createElement('input');
     input.type = 'hidden';
     input.id = id;
@@ -192,10 +311,12 @@ class DraftailRichTextArea {
     const initialiseBlank = !!initialState.getCurrentContent;
     input.value = initialiseBlank ? 'null' : initialState;
     container.appendChild(input);
+
+    const getFullOptions = this.getFullOptions.bind(this);
     // eslint-disable-next-line no-undef
-    const [currentOptions, setOptions] = draftail.initEditor(
+    const [, setOptions] = draftail.initEditor(
       '#' + id,
-      options,
+      getFullOptions(originalOptions, parentCapabilities),
       document.currentScript,
     );
 
@@ -236,21 +357,8 @@ class DraftailRichTextArea {
           capabilities.get(capability),
           capabilityOptions,
         );
-        if (capability === 'split') {
-          setOptions({
-            ...currentOptions,
-            controls: [
-              ...(originalOptions || []),
-              {
-                // eslint-disable-next-line no-undef
-                block: draftail.getSplitControl(
-                  newCapability.fn,
-                  !!newCapability.enabled,
-                ),
-              },
-            ],
-          });
-        }
+        capabilities.set(capability, newCapability);
+        setOptions(getFullOptions(originalOptions, capabilities));
       },
     };
 

+ 1 - 0
docs/advanced_topics/customisation/admin_templates.md

@@ -236,6 +236,7 @@ window.Draftail;
 
 // Wagtail’s Draftail-related APIs and components.
 window.draftail;
+window.draftail.DraftUtils;
 window.draftail.ModalWorkflowSource;
 window.draftail.ImageModalWorkflowSource;
 window.draftail.EmbedModalWorkflowSource;

+ 1 - 0
docs/releases/4.0.md

@@ -31,6 +31,7 @@ As part of the page editor redesign project sponsored by Google, we have made a
 * RTL support: The editor’s UI now displays correctly in right-to-left languages.
 * Focus-aware placeholder: The editor’s placeholder text will now follow the user’s focus, to make it easier to understand where to type in long fields.
 * Empty heading highlight: The editor now highlights empty headings and list items by showing their type (“Heading 3”) as a placeholder, so content is less likely to be published with empty headings.
+* Split and insert: rich text fields can now be split while inserting a new StreamField block of the desired type.
 
 ### Live preview panel