Defining Extensions
An extension is an plain JavaScript object that complies with the LexicalExtension interface. The defineExtension function is merely an identity function to make TypeScript inference convenient. It will be compiled down to the following, and likely optimized out by your bundler or minifier:
function defineExtension(extension) {
return extension;
}
Extensions must be stable references. It is most useful to define them at
module scope, but if for some reason you have to define it inside of a
React component you'll need some way to make it stable such as with
useState
, useMemo
, or useRef
as appropriate.
Required Properties
name
The only required property for an extension is name
. The name is a string
that should be unique to that extension your editor. It is best practice to
use a scope for your project or organization to ensure that reusable
extensions don't conflict with each other.
The general practice in the lexical repository is to use the name of the
package as the extension name when it only makes sense to export a single
extension, (e.g. DragonExtension
has the name @lexical/dragon
), or
with an additional path if multiple extensions are exported (e.g.
AutoFocusExtension
has the name @lexical/extension/AutoFocus
).
InitialEditorConfig
Extensions can specify configuration overrides for the editor, these are defined by the InitialEditorConfig interface. See the API docs for the full list of properties. Every property here has some default.
Merged properties
For an extension designed to be used as a dependency, it can also be useful
to specify properties that are merged such as
html
(override HTML import/export),
nodes
(register new nodes or node overrides with the editor),
or theme
(specify classes to be used for nodes provided by lexical).
export const CodeExtension = defineExtension({
name: '@lexical/code',
nodes: [CodeNode, CodeHighlightNode],
});
Root properties
There are other properties that are more useful for the "root" extension
that you provide to
buildEditorFromExtensions
or the extension
prop of
LexicalExtensionComposer
where you would specify properties that can only be meaningfully set
once such as $initialEditorState
, editable
, onError
, namespace
or parentEditor
.
There is no requirement that these properties be set from any specific level of the extension hierarchy, or at all, but it is only meaningful to set them once per editor.
const editor = buildEditorFromExtensions(
defineExtension({
name: "@example/basic-rich-text-editor",
namespace: "basic-rich-text-editor",
dependencies: [RichTextExtension],
register: (editor: LexicalEditor) => {
console.log("Editor Created");
return () => console.log("Editor Disposed");
},
}),
);
Extension Dependencies
Lexical Extensions have two ways for extensions to depend on and specify configuration for each other:
- dependencies are direct (by reference)
- peerDependencies are indirect (by name) and optional
The exception to this rule is with peerDependencies
. These are optional
dependencies
dependencies
The dependencies
property is an array of extensions by reference that your
extension depends on. For example, if your extension uses React then it should
depend on the ReactExtension
.
Dependencies form a directed acyclic graph. This means that there must not be any circular dependencies. If extension A depends on extension B then there must not be any dependency path from B to A (or from A to A).
This array can contain either direct references to extensions, or calls to configExtension.
export const ExampleExtension = defineExtension({
name: "@example/extension",
dependencies: [
SomeExtension,
configExtension(ReactExtension, {
decorators: [<ExampleDecorator />]
}),
],
});
peerDependencies
The peerDependencies
property is an array of optional extensions by name.
They are not requirements, but specifying them here allows your extension
to find them at runtime and override configuration for them if they are
built with the editor. You can use
declarePeerDependency to
build this array with type inference.
Since these are optional and by name, the graph between peerDependencies
are not restricted. Loops are allowed.
This is rarely needed in practice and is considered an advanced use case, typically to avoid a direct import or dependency loop. See Peer Dependencies for more detail.
conflictsWith
The conflictsWith
property is an array of extensions by name that are
known to conflict with this extension. For example, it does not make
sense to have
RichTextExtension and
PlainTextExtension
in the same editor.
export const PlainTextExtension = defineExtension({
conflictsWith: ['@lexical/rich-text'],
dependencies: [DragonExtension],
name: '@lexical/plain-text',
register: registerPlainText,
});
This is rarely needed in practice and is considered an advanced use case, but it can provide helpful early errors when it is needed.
Phases
The construction of an editor using extensions is done in sequential phases, and the following properties are useful ways to hook into that process at specific points.
config
config
is an object that is the default configuration for your extension,
properties of this object can be overridden by other extensions using
configExtension or
declarePeerDependency.
This object is used in later phases to build the init and/or output.
The config phase happens before the editor is constructed.
mergeConfig
mergeConfig
is a function that can be used to merge configuration
with its overrides if you need something more fine-grained than a
shallow object merge, such as concatenating arrays. For example:
interface StringArrayConfig {
array: string[];
}
const StringArrayExtension = defineExtension({
config: safeCast<StringArrayConfig>({array: []}),
name: "@example/StringArray",
mergeConfig(a, b) {
const config = shallowMergeConfig(a, b);
if (b.array) {
config.array =
b.array.length > 0
? [...a.array, ...b.array]
: a.array;
}
return config;
},
});
The default implementation is shallowMergeConfig, most extensions will not need to override this.
init
The init phase happens before the editor is constructed, but after the merged editor configuration is available and all extensions are configured. It can be used to reference configuration from peers, compute data that should be available during build, and is generally a last resort for making mutations to any extension or editor configuration before the editor is created.
The result of this function is available in later phases.
This is rarely needed in practice and is considered an advanced use case.
build
The build phase happens just before the editor is constructed but after
config and init. The return value of the build
phase is output
and is available for later phases.
output
is generally how extensions provide functionality to each
other and to the nodes in your application. A very common use case is
to build signals from configuration with namedSignals
so that the
behavior of your extension can be modified at runtime (e.g. disabled
is a very common use case).
Other use cases for build's output
are to provide
shared data structures, typed theme configuration, references to the
commands that the extension implements, etc.
export interface TabIndentationConfig {
disabled: boolean;
maxIndent: null | number;
}
export const TabIndentationExtension = defineExtension({
build(editor, config, state) {
return namedSignals(config);
},
config: safeCast<TabIndentationConfig>({disabled: false, maxIndent: null}),
name: '@lexical/extension/TabIndentation',
register(editor, config, state) {
const {disabled, maxIndent} = state.getOutput();
return effect(() => {
if (!disabled.value) {
return registerTabIndentation(editor, maxIndent);
}
});
},
});
register
This happens after the editor has been constructed. This is where you will
register any commands, listeners, etc. that your extension needs. It can use
the result of init
or build
via state.getInit()
and state.getOutput()
respectively.
The return value is a dispose function, typically the result of mergeRegister
.
afterRegistration
This happens after register for every extension has been called,
so all commands should be registered. This is the phase when the
$initialEditorState
is applied to the editor by InitialStateExtension
,
so editor.setRootElement
should be called no sooner than this phas e(it may
of course be called after the editor is built, outside of extensions).
The return value is a dispose function, typically the result of
mergeRegister
.