Flexible extension architecture using facets and state fields for configurable editor behavior and plugin development.
Facets provide a way to make aspects of the editor configurable by allowing extensions to contribute values that are combined into a single output.
/**
* A facet is a labeled value that is associated with an editor state.
* It takes inputs from any number of extensions, and combines those into a single output value.
*/
class Facet<Input, Output = readonly Input[]> {
/** Define a new facet */
static define<Input, Output = readonly Input[]>(config?: FacetConfig<Input, Output>): Facet<Input, Output>;
/** Returns a facet reader for this facet */
get reader(): FacetReader<Output>;
/** Returns an extension that adds the given value to this facet */
of(value: Input): Extension;
/** Create an extension that computes a value for the facet from a state */
compute(deps: readonly Slot<any>[], get: (state: EditorState) => Input): Extension;
/** Create an extension that computes zero or more values for this facet from a state */
computeN(deps: readonly Slot<any>[], get: (state: EditorState) => readonly Input[]): Extension;
/** Create facet source with a state field as input */
from<T extends Input>(field: StateField<T>): Extension;
from<T>(field: StateField<T>, get: (value: T) => Input): Extension;
/** @internal - The default value when no inputs are present */
readonly default: Output;
}
interface FacetConfig<Input, Output> {
/** How to combine the input values into a single output value */
combine?: (value: readonly Input[]) => Output;
/** How to compare output values to determine whether the facet changed */
compare?: (a: Output, b: Output) => boolean;
/** How to compare input values to avoid recomputing when inputs haven't changed */
compareInput?: (a: Input, b: Input) => boolean;
/** Forbids dynamic inputs to this facet */
static?: boolean;
/** Extensions to be added when this facet is provided */
enables?: Extension | ((self: Facet<Input, Output>) => Extension);
}Usage Examples:
import { Facet, StateField, EditorState } from "@codemirror/state";
// Define a simple facet for theme colors
const themeColor = Facet.define<string, string>({
combine: values => values.length ? values[0] : "default"
});
// Define a facet that collects multiple values
const plugins = Facet.define<{name: string, handler: Function}>();
// Use facets in extensions
const darkTheme = themeColor.of("dark");
const lightTheme = themeColor.of("light");
const pluginA = plugins.of({name: "plugin-a", handler: () => {}});
const pluginB = plugins.of({name: "plugin-b", handler: () => {}});
// Create state with facet values
const state = EditorState.create({
extensions: [darkTheme, pluginA, pluginB]
});
// Read facet values
console.log(state.facet(themeColor)); // "dark"
console.log(state.facet(plugins)); // [{name: "plugin-a", ...}, {name: "plugin-b", ...}]State fields store additional information in an editor state and keep it in sync with the rest of the state.
/**
* Fields can store additional information in an editor state,
* and keep it in sync with the rest of the state.
*/
class StateField<Value> {
/** Define a state field */
static define<Value>(config: StateFieldSpec<Value>): StateField<Value>;
/** Returns an extension that enables this field and overrides initialization */
init(create: (state: EditorState) => Value): Extension;
/** State field instances can be used as Extension values */
get extension(): Extension;
}
interface StateFieldSpec<Value> {
/** Creates the initial value for the field when a state is created */
create: (state: EditorState) => Value;
/** Compute a new value from the field's previous value and a transaction */
update: (value: Value, transaction: Transaction) => Value;
/** Compare two values of the field, returning true when they are the same */
compare?: (a: Value, b: Value) => boolean;
/** Provide extensions based on this field */
provide?: (field: StateField<Value>) => Extension;
/** Function used to serialize this field's content to JSON */
toJSON?: (value: Value, state: EditorState) => any;
/** Function that deserializes the JSON representation */
fromJSON?: (json: any, state: EditorState) => Value;
}Usage Examples:
// Define a counter field that tracks document changes
const changeCount = StateField.define<number>({
create: () => 0,
update: (count, tr) => tr.docChanged ? count + 1 : count
});
// Define a field that tracks selection history
const selectionHistory = StateField.define<EditorSelection[]>({
create: (state) => [state.selection],
update: (history, tr) => {
if (tr.selection && !tr.selection.eq(tr.startState.selection)) {
return [...history.slice(-10), tr.selection]; // Keep last 10 selections
}
return history;
},
compare: (a, b) => a.length === b.length && a.every((sel, i) => sel.eq(b[i]))
});
// Use fields in state
const state = EditorState.create({
doc: "Hello world",
extensions: [changeCount, selectionHistory]
});
// Read field values
console.log(state.field(changeCount)); // 0
console.log(state.field(selectionHistory).length); // 1
// Update state and see field changes
const transaction = state.update({changes: {from: 0, insert: "Hi "}});
const newState = transaction.state;
console.log(newState.field(changeCount)); // 1Compartments allow dynamic reconfiguration of parts of the editor state.
/**
* Extension compartments can be used to make a configuration dynamic.
* By wrapping part of your configuration in a compartment, you can later
* replace that part through a transaction.
*/
class Compartment {
/** Create an instance of this compartment to add to your state configuration */
of(ext: Extension): Extension;
/** Create an effect that reconfigures this compartment */
reconfigure(content: Extension): StateEffect<unknown>;
/** Get the current content of the compartment in the state, or undefined if not present */
get(state: EditorState): Extension | undefined;
/** @internal - Static effect type for reconfiguration */
static reconfigure: StateEffectType<{compartment: Compartment, extension: Extension}>;
}Usage Examples:
// Create a compartment for theme configuration
const themeCompartment = new Compartment();
// Initial state with light theme
const state = EditorState.create({
extensions: [
themeCompartment.of(lightTheme),
// other extensions...
]
});
// Reconfigure to dark theme
const switchToDark = state.update({
effects: themeCompartment.reconfigure(darkTheme)
});
// Check current compartment content
console.log(themeCompartment.get(state)); // Returns light theme extension
console.log(themeCompartment.get(switchToDark.state)); // Returns dark theme extensionThe precedence system controls the order in which extensions are applied.
/**
* By default extensions are registered in the order they are found.
* Individual extension values can be assigned a precedence to override this.
*/
const Prec = {
/** The highest precedence level, for extensions that should end up near the start */
highest: (ext: Extension) => Extension,
/** A higher-than-default precedence */
high: (ext: Extension) => Extension,
/** The default precedence */
default: (ext: Extension) => Extension,
/** A lower-than-default precedence */
low: (ext: Extension) => Extension,
/** The lowest precedence level */
lowest: (ext: Extension) => Extension
};Usage Examples:
// Apply precedence to extensions
const highPriorityTheme = Prec.highest(darkTheme);
const lowPriorityPlugin = Prec.low(somePlugin);
const state = EditorState.create({
extensions: [
normalExtension,
highPriorityTheme, // Will be processed first
anotherExtension,
lowPriorityPlugin // Will be processed last
]
});Examples of sophisticated facet patterns for complex configuration scenarios.
// Computed facet that depends on other facets
const computedTheme = Facet.define<{bg: string, fg: string}>({
combine: values => values.length ? values[0] : {bg: "white", fg: "black"}
});
const themeProvider = computedTheme.compute([EditorState.tabSize, someOtherFacet], state => {
const tabSize = state.facet(EditorState.tabSize);
const other = state.facet(someOtherFacet);
return {
bg: tabSize > 2 ? "light-blue" : "white",
fg: other.darkMode ? "white" : "black"
};
});
// Multi-value facet that provides multiple inputs
const eventHandlers = Facet.define<{event: string, handler: Function}>();
const multiEventProvider = eventHandlers.computeN([someStateField], state => {
const fieldValue = state.field(someStateField);
return [
{event: "click", handler: () => console.log("click", fieldValue)},
{event: "keydown", handler: () => console.log("keydown", fieldValue)}
];
});
// Facet derived from state field
const derivedFacet = Facet.define<string>();
const fieldBasedProvider = derivedFacet.from(someStringField, value => value.toUpperCase());Patterns for combining and organizing extensions.
/**
* Extensions can be nested in arrays arbitrarily deep
*/
type Extension = {extension: Extension} | readonly Extension[];
// Helper for creating extension bundles
function createThemeExtension(colors: {primary: string, secondary: string}): Extension {
return [
themeColor.of(colors.primary),
secondaryColor.of(colors.secondary),
computedTheme.compute([], () => ({
bg: colors.primary,
fg: colors.secondary
}))
];
}
// Extension with conditional parts
function createConditionalExtension(options: {enableFeatureA: boolean, enableFeatureB: boolean}): Extension {
const extensions: Extension[] = [
coreExtension,
options.enableFeatureA ? featureAExtension : [],
options.enableFeatureB ? featureBExtension : []
];
return extensions;
}
// Extension that provides other extensions
const masterExtension = Facet.define<Extension>({
enables: self => self.reader // Self-referential to provide collected extensions
});/**
* A facet reader can be used to fetch the value of a facet, but not to define new values
*/
type FacetReader<Output> = {
id: number;
default: Output;
tag: Output;
};
/**
* Slot type for facet dependencies
*/
type Slot<T> = FacetReader<T> | StateField<T> | "doc" | "selection";
/**
* Extension type - the base type for all extensions
*/
type Extension = {extension: Extension} | readonly Extension[];
/**
* Configuration type for dynamic slots
*/
interface DynamicSlot {
create(state: EditorState): SlotStatus;
update(state: EditorState, tr: Transaction): SlotStatus;
reconfigure(state: EditorState, oldState: EditorState): SlotStatus;
}