pdf-tei-editor / docs /code-assistant /plugin-communication.md
cmboulanger's picture
disaster-recovery deploy of pdf-tei-editor
6a49f21 verified
|
Raw
History Blame Contribute Delete
6.8 kB
# Inter-Plugin Communication
Three mechanisms are available. Choose based on the relationship between producer and consumer.
| Mechanism | Use when |
|-----------|----------|
| `dispatchStateChange` | Broadcasting domain state to unknown or multiple consumers. The producer doesn't target a specific plugin. |
| Extension points (`static extensionPoints`) | A host plugin needs structured contributions from many plugins. Contributions are additive and defined at install/start time. |
| `getDependency()` API | The interaction targets one specific plugin, or a synchronous return value is required. |
Cross-plugin UI access via the global `ui` object is never correct. A plugin that needs to affect another plugin's widget must use one of the three mechanisms above.
---
## State propagation
Use `dispatchStateChange` to broadcast a value that any number of plugins may react to. The producer does not know or care which plugins consume the change.
```javascript
// Producer: fires when user logs in
async handleLogin(user) {
await this.dispatchStateChange({ user })
}
// Consumer A: reacts to the same state key
async onUserChange(newUser) {
this.updateUserMenu(newUser)
}
// Consumer B: also reacts independently
async onUserChange(newUser) {
if (!newUser) this.clearEditor()
}
```
Plugin-specific data goes in `state.ext` to avoid key collisions:
```javascript
await this.dispatchStateChange({
ext: { [this.name]: { preferences: prefs } }
})
get preferences() {
return this.state?.ext?.[this.name]?.preferences ?? {}
}
```
**Rules:**
- `onStateUpdate` handlers are **observers only** β€” calling `dispatchStateChange` from inside one throws an error because state propagation is locked during notification.
- Call `dispatchStateChange` from event handlers, UI callbacks, or top-level async operations.
- When `onStateUpdate` triggers async work (e.g. an API call) whose result must be written back to state, use `scheduleStateChange` instead:
```javascript
async onXmlChange(newXml) {
const permissions = await this.fetchPermissions(newXml);
// scheduleStateChange defers the dispatch until propagation is fully done
await this.scheduleStateChange({ editorReadOnly: !permissions.canEdit });
}
```
`scheduleStateChange` is the only legitimate way to write state as a consequence of `onStateUpdate`. It is not a general escape hatch β€” synchronous handlers must remain pure observers.
---
## Extension points
Use extension points when a host plugin needs structured contributions from multiple plugins. The host defines the contract; contributors implement it.
### Declaring and implementing an extension point
```javascript
// extension-points.js
export default {
toolbar: {
contentItems: 'toolbar.contentItems',
menuItems: 'toolbar.menuItems',
}
}
```
```javascript
// Contributing plugin
import ep from '../extension-points.js'
class MyPlugin extends Plugin {
static extensionPoints = [ep.toolbar.contentItems];
/**
* Extension point handler for `ep.toolbar.contentItems`.
* Called by ToolbarPlugin during start() to collect this plugin's toolbar contributions.
* Delegates to {@link MyPlugin#getToolbarContentItems}.
* @returns {Array<{element: HTMLElement, priority: number, position: string}>}
*/
[ep.toolbar.contentItems](...args) { return this.getToolbarContentItems(...args) }
getToolbarContentItems() {
return [{ element: this.#ui, priority: 5, position: 'center' }]
}
}
```
The base class discovers the computed method automatically. The key is the full EP path string (`"toolbar.contentItems"`), so there are no naming conflicts between different namespaces.
> **ASI hazard**: always end `static extensionPoints = [...]` with a semicolon. Without it, the parser treats the following `[ep.X.Y](...)` computed method as a subscript access on the array, causing a `SyntaxError: Unexpected token '{'`.
Extension point handler methods MUST be documented with JSDoc: state the EP being handled, which host plugin invokes them and when, the `Delegates to` link, and `@param`/`@returns` tags.
### Invoking an extension point (host side)
```javascript
// ToolbarPlugin.start() collects all contributions
const results = await this.context.invokePluginEndpoint(
ep.toolbar.contentItems, [], { result: 'values', throws: false }
)
for (const items of results) {
if (!Array.isArray(items)) continue
for (const { element, priority = 0, position = 'center' } of items) {
ui.toolbar.add(element, priority, position)
}
}
```
### Auto-discovered extension points
The base class auto-mounts these without any declaration:
- **Lifecycle methods**: `install`, `ready`, `start`, `shutdown`, `onStateUpdate` β€” just define the method.
- **Per-key state handlers**: `on<Key>Change` β€” follow the naming convention, e.g. `onXmlChange`, `onUserChange`.
All other extension points require `static extensionPoints` with a corresponding computed handler method.
---
## `getDependency()` API
Use when the interaction is intentionally directed at one specific plugin, or when a synchronous return value is required (state and extension points are both asynchronous/broadcast).
```javascript
class DocumentActionsPlugin extends Plugin {
// Private getters β€” resolved lazily at call time, not at construction time.
// This avoids initialization-order issues and circular dependency problems.
get #logger() { return this.getDependency('logger') }
get #xmlEditor() { return this.getDependency('xmleditor') }
get #client() { return this.getDependency('client') }
async saveRevision() {
this.#logger.debug('saving...')
const xmlDoc = this.#xmlEditor.getXmlTree()
await this.#client.saveXml(xmlDoc)
}
}
```
Only add a plugin to `deps` when it must be fully installed before this plugin's own `install()` runs. For plugins only needed at action time (button clicks, async operations), the private getter above is sufficient β€” no `deps` entry required.
**Check for circular deps**: before declaring a dep, verify the full chain does not lead back to the current plugin. If plugin A depends on B and B (transitively) depends on A, declare neither as a dep β€” use lazy `getDependency()` calls at call time instead.
---
## Decision guide
```
Need to affect another plugin's widget?
β†’ No direct ui.otherPlugin.* access allowed.
β†’ If the other plugin owns the widget, add an API method to it and use getDependency().
β†’ If the value is domain state (e.g. readOnly, currentUser), use dispatchStateChange().
Need contributions from many plugins?
β†’ Define an extension point. Contributors implement it passively.
Need a return value from one specific plugin?
β†’ getDependency().methodName()
Broadcasting a domain event?
β†’ dispatchStateChange()
```