diff --git a/CHANGELOG.txt b/CHANGELOG.txt index a08e9814c8d3..77fd90b630e7 100644 --- a/CHANGELOG.txt +++ b/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) diff --git a/client/src/components/Draftail/__snapshots__/index.test.js.snap b/client/src/components/Draftail/__snapshots__/index.test.js.snap index 23b0a284358e..cd04bb587163 100644 --- a/client/src/components/Draftail/__snapshots__/index.test.js.snap +++ b/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, diff --git a/client/src/components/Draftail/index.js b/client/src/components/Draftail/index.js index a60ac79def66..964c577959e2 100644 --- a/client/src/components/Draftail/index.js +++ b/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, }; diff --git a/client/src/components/Draftail/index.test.js b/client/src/components/Draftail/index.test.js index aac590662ba5..da924c6b6cfc 100644 --- a/client/src/components/Draftail/index.test.js +++ b/client/src/components/Draftail/index.test.js @@ -41,14 +41,45 @@ describe('Draftail', () => { document.body.innerHTML = ''; 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, }); }); }); diff --git a/client/src/entrypoints/admin/draftail.js b/client/src/entrypoints/admin/draftail.js index 8011fa721f35..a1543e252be4 100644 --- a/client/src/entrypoints/admin/draftail.js +++ b/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')); diff --git a/client/src/entrypoints/admin/draftail.test.js b/client/src/entrypoints/admin/draftail.test.js index b263b316cc22..7c3bb419fc54 100644 --- a/client/src/entrypoints/admin/draftail.test.js +++ b/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']); }); }); diff --git a/docs/extending/extending_draftail.md b/docs/extending/extending_draftail.md index 1f55c16709e9..b0075fc7065d 100644 --- a/docs/extending/extending_draftail.md +++ b/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 (`` 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

- Anyone following Elon Musk’s $TSLA should - also look into $BTC. + Anyone following NextEra technology $NEE should + also look into $FSLR.

``` @@ -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}`; + const link = document.createElement('a'); + link.href = `https://finance.yahoo.com/quote/${elt.dataset.stock}`; + link.innerHTML = `${elt.innerHTML}`; - 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: diff --git a/docs/releases/5.1.md b/docs/releases/5.1.md index cd41609810f9..c9de65ed3894 100644 --- a/docs/releases/5.1.md +++ b/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 diff --git a/wagtail/admin/rich_text/editors/draftail/features.py b/wagtail/admin/rich_text/editors/draftail/features.py index 7bfa8894c5ca..50ae8cb3ca29 100644 --- a/wagtail/admin/rich_text/editors/draftail/features.py +++ b/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"