kopia lustrzana https://github.com/wagtail/wagtail
Implement new Draftail customisation APIs
- Fixes #5580 - Remove TSLA/TWTR/BTC references and replace with clean energy FSLR / NEE stockspull/10633/head
rodzic
4f012d75ec
commit
f4ea0156a2
|
@ -16,6 +16,7 @@ Changelog
|
||||||
* Optimise queries in collection permission policies using cache on the user object (Sage Abdullah)
|
* 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)
|
* 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)
|
* 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: 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: Move comment notifications toggle to the comments side panel (Sage Abdullah)
|
||||||
* Fix: Remove comment button on InlinePanel fields (Sage Abdullah)
|
* Fix: Remove comment button on InlinePanel fields (Sage Abdullah)
|
||||||
|
|
|
@ -15,8 +15,19 @@ Object {
|
||||||
"bottomToolbar": [Function],
|
"bottomToolbar": [Function],
|
||||||
"commandToolbar": [Function],
|
"commandToolbar": [Function],
|
||||||
"commands": true,
|
"commands": true,
|
||||||
"controls": Array [],
|
"controls": Array [
|
||||||
"decorators": Array [],
|
Object {
|
||||||
|
"meta": [Function],
|
||||||
|
"type": "sentences",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"decorators": Array [
|
||||||
|
Object {
|
||||||
|
"component": [Function],
|
||||||
|
"strategy": [Function],
|
||||||
|
"type": "punctuation",
|
||||||
|
},
|
||||||
|
],
|
||||||
"editorState": null,
|
"editorState": null,
|
||||||
"enableHorizontalRule": Object {
|
"enableHorizontalRule": Object {
|
||||||
"description": "Horizontal line",
|
"description": "Horizontal line",
|
||||||
|
@ -43,7 +54,12 @@ Object {
|
||||||
"onFocus": null,
|
"onFocus": null,
|
||||||
"onSave": [Function],
|
"onSave": [Function],
|
||||||
"placeholder": "Write something or type ‘/’ to insert a block",
|
"placeholder": "Write something or type ‘/’ to insert a block",
|
||||||
"plugins": Array [],
|
"plugins": Array [
|
||||||
|
Object {
|
||||||
|
"handlePastedText": [Function],
|
||||||
|
"type": "anchorify",
|
||||||
|
},
|
||||||
|
],
|
||||||
"rawContentState": null,
|
"rawContentState": null,
|
||||||
"readOnly": false,
|
"readOnly": false,
|
||||||
"showRedoControl": false,
|
"showRedoControl": false,
|
||||||
|
|
|
@ -90,11 +90,21 @@ const onSetToolbar = (choice, callback) => {
|
||||||
/**
|
/**
|
||||||
* Registry for client-side code of Draftail plugins.
|
* Registry for client-side code of Draftail plugins.
|
||||||
*/
|
*/
|
||||||
const PLUGINS = {};
|
const PLUGINS = {
|
||||||
|
entityTypes: {},
|
||||||
|
plugins: {},
|
||||||
|
controls: {},
|
||||||
|
decorators: {},
|
||||||
|
};
|
||||||
|
|
||||||
const registerPlugin = (plugin) => {
|
/**
|
||||||
PLUGINS[plugin.type] = plugin;
|
* Client-side editor-specific equivalent to register_editor_plugin.
|
||||||
return PLUGINS;
|
* `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 blockTypes = newOptions.blockTypes || [];
|
||||||
const inlineStyles = newOptions.inlineStyles || [];
|
const inlineStyles = newOptions.inlineStyles || [];
|
||||||
let controls = newOptions.controls || [];
|
let controls = newOptions.controls || [];
|
||||||
|
let decorators = newOptions.decorators || [];
|
||||||
|
let plugins = newOptions.plugins || [];
|
||||||
const commands = newOptions.commands || true;
|
const commands = newOptions.commands || true;
|
||||||
let entityTypes = newOptions.entityTypes || [];
|
let entityTypes = newOptions.entityTypes || [];
|
||||||
|
|
||||||
entityTypes = entityTypes.map(wrapWagtailIcon).map((type) => {
|
entityTypes = entityTypes
|
||||||
const plugin = PLUGINS[type.type];
|
.map(wrapWagtailIcon)
|
||||||
|
|
||||||
// Override the properties defined in the JS plugin: Python should be the source of truth.
|
// 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.
|
// Only initialise the character count / max length on fields explicitly requiring it.
|
||||||
if (field.hasAttribute('maxlength')) {
|
if (field.hasAttribute('maxlength')) {
|
||||||
|
@ -228,6 +251,8 @@ const initEditor = (selector, originalOptions, currentScript) => {
|
||||||
inlineStyles: inlineStyles.map(wrapWagtailIcon),
|
inlineStyles: inlineStyles.map(wrapWagtailIcon),
|
||||||
entityTypes,
|
entityTypes,
|
||||||
controls,
|
controls,
|
||||||
|
decorators,
|
||||||
|
plugins,
|
||||||
commands,
|
commands,
|
||||||
enableHorizontalRule,
|
enableHorizontalRule,
|
||||||
};
|
};
|
||||||
|
|
|
@ -41,14 +41,45 @@ describe('Draftail', () => {
|
||||||
document.body.innerHTML = '<input id="test" value="null" />';
|
document.body.innerHTML = '<input id="test" value="null" />';
|
||||||
const field = document.querySelector('#test');
|
const field = document.querySelector('#test');
|
||||||
|
|
||||||
draftail.registerPlugin({
|
draftail.registerPlugin(
|
||||||
|
{
|
||||||
type: 'IMAGE',
|
type: 'IMAGE',
|
||||||
source: () => {},
|
source: () => {},
|
||||||
block: () => {},
|
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', {
|
draftail.initEditor('#test', {
|
||||||
entityTypes: [{ type: 'IMAGE' }],
|
entityTypes: [{ type: 'IMAGE' }],
|
||||||
|
controls: [{ type: 'sentences' }],
|
||||||
|
decorators: [{ type: 'punctuation' }],
|
||||||
|
plugins: [{ type: 'anchorify' }],
|
||||||
enableHorizontalRule: true,
|
enableHorizontalRule: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -153,14 +184,30 @@ describe('Draftail', () => {
|
||||||
|
|
||||||
describe('#registerPlugin', () => {
|
describe('#registerPlugin', () => {
|
||||||
it('works', () => {
|
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 = {
|
const plugin = {
|
||||||
type: 'TEST',
|
type: 'TEST_ENTITY',
|
||||||
source: null,
|
source: null,
|
||||||
decorator: null,
|
decorator: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(draftail.registerPlugin(plugin)).toMatchObject({
|
expect(draftail.registerPlugin(plugin)).toMatchObject({
|
||||||
TEST: plugin,
|
TEST_ENTITY: plugin,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -17,7 +17,7 @@ window.Draftail = Draftail;
|
||||||
window.draftail = draftail;
|
window.draftail = draftail;
|
||||||
|
|
||||||
// Plugins for the built-in entities.
|
// Plugins for the built-in entities.
|
||||||
const plugins = [
|
const entityTypes = [
|
||||||
{
|
{
|
||||||
type: 'DOCUMENT',
|
type: 'DOCUMENT',
|
||||||
source: draftail.DocumentModalWorkflowSource,
|
source: draftail.DocumentModalWorkflowSource,
|
||||||
|
@ -41,4 +41,4 @@ const plugins = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
plugins.forEach(draftail.registerPlugin);
|
entityTypes.forEach((type) => draftail.registerPlugin(type, 'entityTypes'));
|
||||||
|
|
|
@ -1,21 +1,49 @@
|
||||||
require('./draftail');
|
require('./draftail');
|
||||||
|
|
||||||
describe('draftail', () => {
|
describe('draftail', () => {
|
||||||
it('exposes module as global', () => {
|
it('exposes a stable API', () => {
|
||||||
expect(window.draftail).toBeDefined();
|
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', () => {
|
it('exposes package as global', () => {
|
||||||
expect(window.Draftail).toBeDefined();
|
expect(window.Draftail).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has defaults registered', () => {
|
it('has default entities registered', () => {
|
||||||
expect(Object.keys(window.draftail.registerPlugin({}))).toEqual([
|
expect(
|
||||||
'DOCUMENT',
|
Object.keys(window.draftail.registerPlugin({}, 'entityTypes')),
|
||||||
'LINK',
|
).toEqual(['DOCUMENT', 'LINK', 'IMAGE', 'EMBED', 'undefined']);
|
||||||
'IMAGE',
|
|
||||||
'EMBED',
|
|
||||||
'undefined',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -2,15 +2,17 @@
|
||||||
|
|
||||||
# Extending the Draftail Editor
|
# 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`.
|
Draftail supports three types of formatting:
|
||||||
- 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).
|
|
||||||
|
|
||||||
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
|
```python
|
||||||
import wagtail.admin.rich_text.editors.draftail.features as draftail_features
|
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`.
|
- 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.
|
- 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.
|
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`.
|
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:
|
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.
|
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}
|
```{warning}
|
||||||
This is an advanced feature. Please carefully consider whether you really need this.
|
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.
|
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.
|
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:
|
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,44 +230,36 @@ 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:
|
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
|
```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.
|
// Not a real React component – just creates the entities as soon as it is rendered.
|
||||||
class StockSource extends React.Component {
|
class StockSource extends window.React.Component {
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
const { editorState, entityType, onComplete } = this.props;
|
const { editorState, entityType, onComplete } = this.props;
|
||||||
|
|
||||||
const content = editorState.getCurrentContent();
|
const content = editorState.getCurrentContent();
|
||||||
const selection = editorState.getSelection();
|
const selection = editorState.getSelection();
|
||||||
|
|
||||||
const randomStock =
|
const demoStocks = ['AMD', 'AAPL', 'NEE', 'FSLR'];
|
||||||
DEMO_STOCKS[Math.floor(Math.random() * DEMO_STOCKS.length)];
|
const randomStock = demoStocks[Math.floor(Math.random() * demoStocks.length)];
|
||||||
|
|
||||||
// Uses the Draft.js API to create a new entity with the right data.
|
// Uses the Draft.js API to create a new entity with the right data.
|
||||||
const contentWithEntity = content.createEntity(
|
const contentWithEntity = content.createEntity(
|
||||||
entityType.type,
|
entityType.type,
|
||||||
'IMMUTABLE',
|
'IMMUTABLE',
|
||||||
{
|
{ stock: randomStock },
|
||||||
stock: randomStock,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
const entityKey = contentWithEntity.getLastCreatedEntityKey();
|
const entityKey = contentWithEntity.getLastCreatedEntityKey();
|
||||||
|
|
||||||
// We also add some text for the entity to be activated on.
|
// We also add some text for the entity to be activated on.
|
||||||
const text = `$${randomStock}`;
|
const text = `$${randomStock}`;
|
||||||
|
|
||||||
const newContent = Modifier.replaceText(
|
const newContent = window.DraftJS.Modifier.replaceText(
|
||||||
content,
|
content,
|
||||||
selection,
|
selection,
|
||||||
text,
|
text,
|
||||||
null,
|
null,
|
||||||
entityKey,
|
entityKey,
|
||||||
);
|
);
|
||||||
const nextState = EditorState.push(
|
const nextState = window.DraftJS.EditorState.push(
|
||||||
editorState,
|
editorState,
|
||||||
newContent,
|
newContent,
|
||||||
'insert-characters',
|
'insert-characters',
|
||||||
|
@ -290,7 +284,7 @@ const Stock = (props) => {
|
||||||
const { entityKey, contentState } = props;
|
const { entityKey, contentState } = props;
|
||||||
const data = contentState.getEntity(entityKey).getData();
|
const data = contentState.getEntity(entityKey).getData();
|
||||||
|
|
||||||
return React.createElement(
|
return window.React.createElement(
|
||||||
'a',
|
'a',
|
||||||
{
|
{
|
||||||
role: 'button',
|
role: 'button',
|
||||||
|
@ -313,15 +307,15 @@ window.draftail.registerPlugin({
|
||||||
type: 'STOCK',
|
type: 'STOCK',
|
||||||
source: StockSource,
|
source: StockSource,
|
||||||
decorator: Stock,
|
decorator: Stock,
|
||||||
});
|
}, 'entityTypes');
|
||||||
```
|
```
|
||||||
|
|
||||||
And that’s it! All of this setup will finally produce the following HTML on the site’s front-end:
|
And that’s it! All of this setup will finally produce the following HTML on the site’s front-end:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<p>
|
<p>
|
||||||
Anyone following Elon Musk’s <span data-stock="TSLA">$TSLA</span> should
|
Anyone following NextEra technology <span data-stock="NEE">$NEE</span> should
|
||||||
also look into <span data-stock="BTC">$BTC</span>.
|
also look into <span data-stock="FSLR">$FSLR</span>.
|
||||||
</p>
|
</p>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -340,6 +334,194 @@ document.querySelectorAll('[data-stock]').forEach((elt) => {
|
||||||
|
|
||||||
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.
|
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
|
## 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:
|
To further customise how the Draftail widgets are integrated into the UI, there are additional extension points for CSS and JS:
|
||||||
|
|
|
@ -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)
|
* 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)
|
* 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)
|
* 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
|
### Bug fixes
|
||||||
|
|
||||||
|
|
|
@ -74,3 +74,21 @@ class InlineStyleFeature(ListFeature):
|
||||||
"""A feature which is listed in the inlineStyles list of the options"""
|
"""A feature which is listed in the inlineStyles list of the options"""
|
||||||
|
|
||||||
option_name = "inlineStyles"
|
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"
|
||||||
|
|
Ładowanie…
Reference in New Issue