Skip to content

Commit 55ca19e

Browse files
authored
Merge pull request #8 from ether/feat/pad-toggle
feat(padToggle): native-style User + Pad Wide settings helper
2 parents d28143b + 0ea52bc commit 55ca19e

6 files changed

Lines changed: 529 additions & 2 deletions

File tree

README.md

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ relay.get('option') // specific key
161161

162162
### Toggle
163163

164-
Checkbox in the settings panel with cookie persistence.
164+
Checkbox in the User Settings panel with cookie persistence (per-user, per-pad).
165165

166166
```js
167167
const {toggle} = require('ep_plugin_helpers');
@@ -180,6 +180,64 @@ const state = myToggle.init(); // reads cookie, binds checkbox
180180
// state.enabled tracks current value
181181
```
182182

183+
### PadToggle
184+
185+
Parallel checkboxes in **both** the User Settings panel and the Pad Wide Settings panel — matching how native settings (sticky chat, line numbers, etc.) work. The pad-wide value rides Etherpad's existing `padoptions` broadcast/persist rail, so changes propagate to every connected client and are remembered across reloads. The pad creator can `enforceSettings` to lock the user-side checkbox for everyone.
186+
187+
Requires Etherpad with the `ep_*` padOptions passthrough patch (>= 2.7.4). On older cores the pad-wide column is hidden automatically and the user-side cookie toggle keeps working — plugins built on this helper run everywhere.
188+
189+
```js
190+
const {padToggle} = require('ep_plugin_helpers');
191+
192+
const t = padToggle({
193+
pluginName: 'ep_myplugin', // must match /^ep_[a-z0-9_]+$/
194+
settingId: 'my-feature', // → ids: options-my-feature, padsettings-options-my-feature
195+
l10nId: 'ep_myplugin.myFeature', // i18n key, html10n overwrites the fallback
196+
defaultLabel: 'My feature', // a11y fallback — rendered inside <label> so screen readers
197+
// announce something before html10n loads
198+
defaultEnabled: false, // overridable via settings.json[pluginName].defaultEnabled
199+
});
200+
201+
// Server-side hooks
202+
exports.loadSettings = t.loadSettings;
203+
exports.clientVars = t.clientVars;
204+
exports.eejsBlock_mySettings = t.eejsBlock_mySettings;
205+
exports.eejsBlock_padSettings = t.eejsBlock_padSettings;
206+
207+
// Client-side hooks
208+
exports.postAceInit = (hook, ctx) => {
209+
const state = t.init({
210+
onChange: (enabled) => {
211+
// fires on initial load AND whenever the effective value changes
212+
enabled ? myFeature.enable() : myFeature.disable();
213+
},
214+
});
215+
// state.getEnabled() returns the current effective value
216+
};
217+
exports.handleClientMessage_CLIENT_MESSAGE = t.handleClientMessage_CLIENT_MESSAGE;
218+
```
219+
220+
The plugin's `ep.json` must list each hook on the right side:
221+
222+
```json
223+
{
224+
"hooks": {
225+
"loadSettings": "ep_myplugin",
226+
"clientVars": "ep_myplugin",
227+
"eejsBlock_mySettings": "ep_myplugin",
228+
"eejsBlock_padSettings": "ep_myplugin"
229+
},
230+
"client_hooks": {
231+
"postAceInit": "ep_myplugin/static/js/index",
232+
"handleClientMessage_CLIENT_MESSAGE": "ep_myplugin/static/js/index"
233+
}
234+
}
235+
```
236+
237+
**Effective value rules** (returned by `init`'s `onChange` and `getEnabled`):
238+
- `enforceSettings` on → use the pad-wide value
239+
- `enforceSettings` off → use the user cookie value, falling back to pad-wide, falling back to `defaultEnabled`
240+
183241
### Message Relay
184242

185243
Intercept and relay real-time COLLABROOM messages.
@@ -245,6 +303,7 @@ Old function names still work as aliases:
245303
| `rawHTML` | `eejsBlock.raw` |
246304
| `settings` | `createSettingsRelay` |
247305
| `toggle` | `createSettingsToggle` |
306+
| `padToggle` | `createPadToggle` |
248307
| `messageRelay` | `createMessageRelay` |
249308
| `logger` | `createLogger` |
250309

index.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ module.exports = {
1919
// Toggle — checkbox in settings panel with cookie persistence
2020
get toggle() { return require('./settings-toggle').toggle; },
2121

22+
// PadToggle — parallel User Settings + Pad Wide Settings checkboxes,
23+
// matching native Etherpad behavior. Pad-wide value rides the existing
24+
// padoptions broadcast/persist rail; degrades gracefully on cores that
25+
// lack the ep_* passthrough patch (Etherpad < 2.7.4).
26+
//
27+
// Server side ONLY here. Client code must import the sub-path
28+
// 'ep_plugin_helpers/pad-toggle' directly to avoid pulling settings-toggle
29+
// and other server-only modules into the client bundle.
30+
get padToggle() { return require('./pad-toggle-server').padToggle; },
31+
2232
// Messages — intercept and relay real-time messages
2333
get messageRelay() { return require('./message-relay').messageRelay; },
2434

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ep_plugin_helpers",
3-
"version": "0.2.9",
3+
"version": "0.3.0",
44
"description": "Shared factory functions to eliminate boilerplate across Etherpad plugins",
55
"author": {
66
"name": "John McLear",

pad-toggle-server.js

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
'use strict';
2+
3+
// padToggle (server side) — emits parallel checkboxes in the User Settings
4+
// (mySettings) and Pad Wide Settings (padSettings) panels, mirroring native
5+
// Etherpad behavior. Pad-wide values ride the existing padoptions COLLABROOM
6+
// rail (stored at pad.padOptions[pluginName] = {enabled: bool}) when the core
7+
// has the ep_* passthrough patch (Etherpad >= 2.7.4); on older cores the
8+
// pad-wide block silently no-ops and the user-side cookie toggle alone still
9+
// works.
10+
//
11+
// This module is intended for server-side import only. The companion
12+
// `pad-toggle.js` provides the client-side init/handleClientMessage hooks.
13+
// They share the same config; plugin authors should use identical
14+
// `pluginName`, `settingId`, `l10nId`, and `defaultEnabled` on both sides so
15+
// the checkbox ids and clientVars block line up.
16+
17+
const PLUGIN_NAME_RE = /^ep_[a-z0-9_]+$/;
18+
19+
let padOptionsPluginPassthrough = false;
20+
try {
21+
// The require lands on a leaf module on patched cores (Etherpad >= 2.7.4)
22+
// and throws on older cores. Server-only: this file is never bundled for
23+
// the browser, so esbuild's static analysis does not run here.
24+
// eslint-disable-next-line global-require
25+
const caps = require('ep_etherpad-lite/node/utils/PluginCapabilities');
26+
padOptionsPluginPassthrough = caps && caps.padOptionsPluginPassthrough === true;
27+
} catch (_e) { /* older core — leave as false */ }
28+
29+
const HTML_ESCAPE_RE = /[&<>"']/g;
30+
const HTML_ESCAPES = {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'};
31+
const escapeHtml = (s) => String(s).replace(HTML_ESCAPE_RE, (c) => HTML_ESCAPES[c]);
32+
33+
const validateConfig = (config) => {
34+
if (!config || typeof config !== 'object') {
35+
throw new Error('padToggle requires a config object');
36+
}
37+
const {pluginName, settingId, l10nId, defaultLabel, defaultEnabled = true} = config;
38+
if (!PLUGIN_NAME_RE.test(pluginName || '')) {
39+
throw new Error(
40+
`padToggle pluginName must match /^ep_[a-z0-9_]+$/, got: ${pluginName}`);
41+
}
42+
if (!settingId || typeof settingId !== 'string') {
43+
throw new Error('padToggle requires settingId (string)');
44+
}
45+
if (!l10nId || typeof l10nId !== 'string') {
46+
throw new Error('padToggle requires l10nId (string) — i18n is mandatory');
47+
}
48+
if (!defaultLabel || typeof defaultLabel !== 'string') {
49+
throw new Error(
50+
'padToggle requires defaultLabel (string) — accessibility fallback ' +
51+
'rendered inside <label> so screen readers announce something before ' +
52+
'html10n loads. html10n overwrites it at runtime via data-l10n-id.');
53+
}
54+
return {pluginName, settingId, l10nId, defaultLabel, defaultEnabled: !!defaultEnabled};
55+
};
56+
57+
const renderCheckbox = (settingId, l10nId, defaultLabel, idPrefix) =>
58+
`<p>` +
59+
`<input type="checkbox" id="${idPrefix}options-${settingId}">` +
60+
`<label for="${idPrefix}options-${settingId}" ` +
61+
`data-l10n-id="${escapeHtml(l10nId)}">${escapeHtml(defaultLabel)}</label>` +
62+
`</p>`;
63+
64+
const padToggleServer = (rawConfig) => {
65+
const {pluginName, settingId, l10nId, defaultLabel, defaultEnabled} = validateConfig(rawConfig);
66+
let cachedDefaultEnabled = defaultEnabled;
67+
68+
const loadSettings = async (hookName, args) => {
69+
const ps = (args && args.settings && args.settings[pluginName]) || {};
70+
if (typeof ps.defaultEnabled === 'boolean') cachedDefaultEnabled = ps.defaultEnabled;
71+
};
72+
73+
const clientVars = async (hookName, ctx) => {
74+
let initialPadEnabled = cachedDefaultEnabled;
75+
try {
76+
const padSettings = ctx && ctx.pad && typeof ctx.pad.getPadSettings === 'function'
77+
? ctx.pad.getPadSettings() : null;
78+
const stored = padSettings && padSettings[pluginName];
79+
if (stored && typeof stored.enabled === 'boolean') initialPadEnabled = stored.enabled;
80+
} catch (_e) { /* leave initialPadEnabled at instance default */ }
81+
82+
return {
83+
ep_plugin_helpers: {
84+
padToggle: {
85+
[pluginName]: {
86+
padWideSupported: padOptionsPluginPassthrough,
87+
settingId,
88+
l10nId,
89+
defaultEnabled: cachedDefaultEnabled,
90+
initialPadEnabled,
91+
},
92+
},
93+
},
94+
};
95+
};
96+
97+
const eejsBlock_mySettings = (hookName, args, cb) => {
98+
args.content += renderCheckbox(settingId, l10nId, defaultLabel, '');
99+
return cb();
100+
};
101+
102+
const eejsBlock_padSettings = (hookName, args, cb) => {
103+
if (!padOptionsPluginPassthrough) return cb();
104+
args.content += renderCheckbox(settingId, l10nId, defaultLabel, 'padsettings-');
105+
return cb();
106+
};
107+
108+
return {loadSettings, clientVars, eejsBlock_mySettings, eejsBlock_padSettings};
109+
};
110+
111+
module.exports = {padToggle: padToggleServer, createPadToggle: padToggleServer};

0 commit comments

Comments
 (0)