Implement new Draftail customisation APIs

- Fixes #5580
- Remove TSLA/TWTR/BTC references and replace with clean energy FSLR / NEE stocks
pull/10633/head
Thibaud Colas 2023-06-12 14:42:53 +01:00 zatwierdzone przez LB (Ben Johnston)
rodzic 4f012d75ec
commit f4ea0156a2
9 zmienionych plików z 423 dodań i 105 usunięć

Wyświetl plik

@ -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)

Wyświetl plik

@ -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,

Wyświetl plik

@ -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,
};

Wyświetl plik

@ -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,
});
});
});

Wyświetl plik

@ -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'));

Wyświetl plik

@ -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']);
});
});

Wyświetl plik

@ -2,15 +2,17 @@
# Extending the Draftail Editor
Wagtails rich text editor is built with [Draftail](https://www.draftail.org/), and its functionality can be extended through plugins.
Wagtails 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
Thats 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 stocks 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 stocks 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 users 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;
const DEMO_STOCKS = ['AMD', 'AAPL', 'TWTR', 'TSLA', 'BTC'];
// 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;
class StockSource extends window.React.Component {
componentDidMount() {
const { editorState, entityType, onComplete } = this.props;
const content = editorState.getCurrentContent();
const selection = editorState.getSelection();
const content = editorState.getCurrentContent();
const selection = editorState.getSelection();
const randomStock =
DEMO_STOCKS[Math.floor(Math.random() * DEMO_STOCKS.length)];
const demoStocks = ['AMD', 'AAPL', 'NEE', 'FSLR'];
const randomStock = demoStocks[Math.floor(Math.random() * demoStocks.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();
// 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();
// We also add some text for the entity to be activated on.
const text = `$${randomStock}`;
// We also add some text for the entity to be activated on.
const text = `$${randomStock}`;
const newContent = Modifier.replaceText(
content,
selection,
text,
null,
entityKey,
);
const nextState = EditorState.push(
editorState,
newContent,
'insert-characters',
);
const newContent = window.DraftJS.Modifier.replaceText(
content,
selection,
text,
null,
entityKey,
);
const nextState = window.DraftJS.EditorState.push(
editorState,
newContent,
'insert-characters',
);
onComplete(nextState);
}
onComplete(nextState);
}
render() {
return null;
}
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();
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,
);
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 thats it! All of this setup will finally produce the following HTML on the sites front-end:
```html
<p>
Anyone following Elon Musks <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, lets 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:

Wyświetl plik

@ -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

Wyświetl plik

@ -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"