File size: 10,330 Bytes
6a49f21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
# Frontend Plugin Development Guide

Guide for creating and working with **frontend plugins** in the PDF-TEI Editor.

**Note**: This guide covers **frontend plugins** (JavaScript/TypeScript code running in the browser). For **backend plugins** (Python code running on the server), see [backend-plugins.md](./backend-plugins.md).

**Key Differences**:
- **Frontend plugins**: JavaScript classes in `app/src/plugins/` that extend the UI and handle client-side logic
- **Backend plugins**: Python modules in `fastapi_app/plugins/` that provide server-side functionality and API endpoints

For detailed frontend plugin architecture, see [../development/plugin-system-frontend.md](../development/plugin-system-frontend.md).

## Creating New Plugin Classes

```javascript
import Plugin from '../modules/plugin-base.js';
import { registerTemplate, createSingleFromTemplate } from '../ui.js';

class MyPlugin extends Plugin {
  constructor(context) {
    super(context, {
      name: 'my-plugin',
      deps: ['dependency1', 'dependency2']
    });
  }

  async install(state) {
    // Call parent to set initial state
    await super.install(state);

    // Register templates at install time
    await registerTemplate('my-template', 'my-template.html');
    const element = createSingleFromTemplate('my-template');
    document.body.appendChild(element);

    // Set up event handlers
    element.addEventListener('click', () => {
      this.handleClick();
    });
  }

  async onStateUpdate(changedKeys, state) {
    // React to specific state changes
    if (changedKeys.includes('user')) {
      this.updateUI();
    }
  }

  async handleClick() {
    // Dispatch state changes
    await this.dispatchStateChange({
      customProperty: 'new value'
    });
  }

  // Per-key state handler: called only when state.user changes
  // Named on<Key>Change where Key is the capitalized state property name
  async onUserChange(newUser, prevUser) {
    this.updateUI();
  }

  // Register a custom extension point via static declaration
  static extensionPoints = ['custom.action'];

  async action() { /* ... */ }
}

export default MyPlugin;
```

## Plugin Registration

After creating a plugin class:

1. Run `node bin/build.js --steps=plugins` β€” this adds the class to `app/src/plugin-registry.js`
2. In `app/src/plugins.js`, import the class from `./plugin-registry.js` and add it to the `plugins` array

```javascript
// app/src/plugins.js
import { MyPlugin } from './plugin-registry.js';

const plugins = [
  MyPlugin,  // Plugin class β€” instantiated automatically
  // ...
];
```

If the plugin's API needs to be accessible to other modules, also export the singleton from `plugins.js`:

```javascript
export const myPlugin = MyPlugin.getInstance();
```

## State Management in Plugins

See [plugin-communication.md](./plugin-communication.md) for when to use state vs. other mechanisms.

The application uses **immutable state management**:

- **Dispatch changes**: `await this.dispatchStateChange({ key: value })` β€” never mutate `this.state` directly
- **React to changes**: `onStateUpdate(changedKeys, state)` or per-key handlers (see below)
- **Read current state**: `this.state` β€” read-only property
- **Plugin-specific state**: store in `state.ext[this.name]` to avoid key collisions
- **Never call `dispatchStateChange` inside `onStateUpdate`** β€” state propagation is locked during observer notification; doing so throws an error. Use `scheduleStateChange` when async work triggered by `onStateUpdate` produces a result that must be written back to state (see below).

## Per-Key State Handlers

Instead of checking `changedKeys.includes(key)` inside `onStateUpdate`, declare a method named `on<Key>Change` where `Key` is the state property name with the first letter capitalized. The plugin base class auto-discovers these methods and registers them as `onStateUpdate.<key>` extension points.

```javascript
class MyPlugin extends Plugin {
  // Called only when state.xml changes β€” more efficient than a catch-all onStateUpdate
  async onXmlChange(newXml, prevXml) {
    if (newXml) this.loadDocument(newXml);
  }

  // Called only when state.user changes
  async onUserChange(newUser, prevUser) {
    this.updateUI(newUser);
  }

  // Called only when state.sessionId changes
  async onSessionIdChange(newId, prevId) {
    this.reconnect(newId);
  }
}
```

The naming convention: `on` + state key with first letter uppercased + `Change`.

- `state.xml` β†’ `onXmlChange`
- `state.user` β†’ `onUserChange`
- `state.sessionId` β†’ `onSessionIdChange`
- `state.editorReadOnly` β†’ `onEditorReadOnlyChange`

Per-key handlers receive `(newValue, prevValue)` β€” not `changedKeys` and the full state. Use `this.state` to access other state properties.

`onStateUpdate(changedKeys, state)` remains available as the catch-all and runs in parallel with per-key handlers. Both can coexist in the same class.

## Extension Points

See [plugin-communication.md](./plugin-communication.md) for the full extension point system, including when to use them vs. state or `getDependency()`.

Auto-discovered 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 (see below)

All other extension points: declare in `static extensionPoints` and implement a computed method that delegates to a named method:

```javascript
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' }]
  }
}
```

Always document the computed handler method with JSDoc (see the CLAUDE.md rule).

## Accessing Dependencies

See [plugin-communication.md](./plugin-communication.md) for when to use `getDependency()` vs. state or extension points.

Use private getter properties β€” resolved lazily at call time, avoiding initialization-order and circular-dependency issues:

```javascript
class DocumentActionsPlugin extends Plugin {
  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. Plugins only needed at action time don't need a `deps` entry.

## Common Patterns

```javascript
// Per-key handler (preferred over onStateUpdate for single-key reactions)
async onUserChange(newUser, prevUser) {
  if (newUser) await this.setupUserUI();
}

// Catch-all for multiple keys or when you need changedKeys
async onStateUpdate(changedKeys, state) {
  if (changedKeys.includes('user') && this.state.user) {
    await this.setupUserUI();
  }
}

// Plugin-specific state
async savePreferences(prefs) {
  await this.dispatchStateChange({
    ext: {
      [this.name]: { preferences: prefs }
    }
  });
}

// Accessing plugin-specific state
get preferences() {
  return this.state?.ext?.[this.name]?.preferences || {};
}

```

## Plugin Objects

Plugin objects are plain JavaScript objects that can also serve as plugins. The class-based `Plugin` class is implemented on top of this primitive pattern. If you need to understand the lower-level mechanics or work with object-based plugins directly, see [Object-Based Plugin Pattern](../development/plugin-system-object-based.md).

```javascript
import { updateState } from '../app.js';

let currentState;

async function onStateUpdate(changedKeys, state) {
  currentState = state;
  if (changedKeys.includes('user')) {
    // React to user changes
  }
}

async function someAction() {
  // Use currentState, not installation-time state
  await updateState({ pdf: 'new.pdf' });
}

export default {
  name: 'my-plugin',
  deps: ['dependency1'],
  api: { someAction },
  install: async (state) => { /* setup */ },
  onStateUpdate
};
```

## Memory Management

- State history is automatically limited to 10 entries to prevent memory leaks
- Older states are garbage collected when the limit is exceeded
- The `previousState` chain is properly broken to allow garbage collection

## Anti-Patterns to Avoid

❌ **DO NOT** import global state:

```javascript
import { state } from '../app.js';  // WRONG
```

❌ **DO NOT** mutate state directly:

```javascript
this.state.user = newUser;  // WRONG
```

❌ **DO NOT** call `dispatchStateChange` inside `onStateUpdate`:

```javascript
async onStateUpdate(changedKeys) {
  await this.dispatchStateChange({ ... });  // WRONG β€” throws, propagation is locked
}
```

βœ… **DO** dispatch state changes from event handlers:

```javascript
async handleButtonClick() {
  await this.dispatchStateChange({ user: newUser });  // CORRECT
}
```

βœ… **DO** react to state changes in `onStateUpdate`:

```javascript
async onStateUpdate(changedKeys) {
  if (changedKeys.includes('user')) {
    this.updateUI();  // CORRECT β€” observe and react, no state mutation
  }
}
```

βœ… **DO** use `scheduleStateChange` when async work inside `onStateUpdate` produces a result that must go back into state:

```javascript
async onXmlChange(newXml) {
  // Async API call triggered by a state change
  const permissions = await this.fetchPermissions(newXml);
  // dispatchStateChange would throw here β€” propagation may still be active.
  // scheduleStateChange flushes after the current cycle completes.
  await this.scheduleStateChange({ editorReadOnly: !permissions.canEdit });
}
```

`scheduleStateChange` is an explicit opt-in for this one legitimate pattern. It is **not** a general escape hatch from the observer rule β€” synchronous reactions must always remain pure observers.