Skip to content

Commit 1a86781

Browse files
JohnMcLearclaude
andcommitted
feat: add padToggle for native-style pad-wide + user settings
Plugins built on toggle() get only a User Settings checkbox stored in a per-user cookie — they never appear in the Pad Wide Settings panel and can't ride enforceSettings. That's the wrong half of Etherpad's native model: every core toggle (sticky chat, line numbers, etc.) renders in both panels and broadcasts pad-wide changes to every connected client. padToggle emits parallel checkboxes in both panels with one config object, stashes the pad-wide value at pad.padOptions[pluginName] so it rides the existing padoptions COLLABROOM rail, and honors enforce by locking the user-side checkbox when the pad creator turns it on. Capability-detects the ep_* passthrough patch via PluginCapabilities; on older cores the pad-wide block silently no-ops and the user-side cookie toggle keeps working, so plugins built on padToggle are backward-compatible. i18n is mandatory (no hardcoded English labels) — the helper requires an l10nId and emits only data-l10n-id, leaving translations to the plugin's own locales/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 76a8e47 commit 1a86781

4 files changed

Lines changed: 449 additions & 1 deletion

File tree

README.md

Lines changed: 58 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,62 @@ 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 (no hardcoded English label)
196+
defaultEnabled: false, // overridable via settings.json[pluginName].defaultEnabled
197+
});
198+
199+
// Server-side hooks
200+
exports.loadSettings = t.loadSettings;
201+
exports.clientVars = t.clientVars;
202+
exports.eejsBlock_mySettings = t.eejsBlock_mySettings;
203+
exports.eejsBlock_padSettings = t.eejsBlock_padSettings;
204+
205+
// Client-side hooks
206+
exports.postAceInit = (hook, ctx) => {
207+
const state = t.init({
208+
onChange: (enabled) => {
209+
// fires on initial load AND whenever the effective value changes
210+
enabled ? myFeature.enable() : myFeature.disable();
211+
},
212+
});
213+
// state.getEnabled() returns the current effective value
214+
};
215+
exports.handleClientMessage_CLIENT_MESSAGE = t.handleClientMessage_CLIENT_MESSAGE;
216+
```
217+
218+
The plugin's `ep.json` must list each hook on the right side:
219+
220+
```json
221+
{
222+
"hooks": {
223+
"loadSettings": "ep_myplugin",
224+
"clientVars": "ep_myplugin",
225+
"eejsBlock_mySettings": "ep_myplugin",
226+
"eejsBlock_padSettings": "ep_myplugin"
227+
},
228+
"client_hooks": {
229+
"postAceInit": "ep_myplugin/static/js/index",
230+
"handleClientMessage_CLIENT_MESSAGE": "ep_myplugin/static/js/index"
231+
}
232+
}
233+
```
234+
235+
**Effective value rules** (returned by `init`'s `onChange` and `getEnabled`):
236+
- `enforceSettings` on → use the pad-wide value
237+
- `enforceSettings` off → use the user cookie value, falling back to pad-wide, falling back to `defaultEnabled`
238+
183239
### Message Relay
184240

185241
Intercept and relay real-time COLLABROOM messages.
@@ -245,6 +301,7 @@ Old function names still work as aliases:
245301
| `rawHTML` | `eejsBlock.raw` |
246302
| `settings` | `createSettingsRelay` |
247303
| `toggle` | `createSettingsToggle` |
304+
| `padToggle` | `createPadToggle` |
248305
| `messageRelay` | `createMessageRelay` |
249306
| `logger` | `createLogger` |
250307

index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ 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+
get padToggle() { return require('./pad-toggle').padToggle; },
27+
2228
// Messages — intercept and relay real-time messages
2329
get messageRelay() { return require('./message-relay').messageRelay; },
2430

pad-toggle.js

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
'use strict';
2+
3+
// padToggle — emit parallel checkboxes in the User Settings (mySettings) and
4+
// Pad Wide Settings (padSettings) panels, mirroring how native settings like
5+
// stickychat / lineNumbers / disablechat work. The pad-wide value rides the
6+
// existing padoptions COLLABROOM rail (the helper's value lives at
7+
// pad.padOptions[pluginName] = {enabled: bool}), so broadcast, persistence,
8+
// creator-only-write, and enforceSettings semantics all come for free —
9+
// provided Etherpad core ships the ep_* passthrough patch (since 2.7.4).
10+
//
11+
// On older cores (no PluginCapabilities module), the pad-wide block is a
12+
// no-op and the user-side cookie toggle still works. Plugins built on this
13+
// helper continue to function everywhere; only the pad-wide column is gone.
14+
15+
const PLUGIN_NAME_RE = /^ep_[a-z0-9_]+$/;
16+
17+
let padOptionsPluginPassthrough = false;
18+
try {
19+
// Server-only. Wrapped because the Settings module pulls in node deps
20+
// (fs, path) that don't exist in the esbuild-bundled client. The require
21+
// also fails on Etherpad versions before the passthrough patch shipped.
22+
// eslint-disable-next-line global-require
23+
const caps = require('ep_etherpad-lite/node/utils/PluginCapabilities');
24+
padOptionsPluginPassthrough = caps && caps.padOptionsPluginPassthrough === true;
25+
} catch (_e) { /* older core or client bundle — leave as false */ }
26+
27+
const padToggle = (config) => {
28+
if (!config || typeof config !== 'object') {
29+
throw new Error('padToggle requires a config object');
30+
}
31+
const {pluginName, settingId, l10nId, defaultEnabled = true} = config;
32+
if (!PLUGIN_NAME_RE.test(pluginName || '')) {
33+
throw new Error(
34+
`padToggle pluginName must match /^ep_[a-z0-9_]+$/, got: ${pluginName}`);
35+
}
36+
if (!settingId || typeof settingId !== 'string') {
37+
throw new Error('padToggle requires settingId (string)');
38+
}
39+
if (!l10nId || typeof l10nId !== 'string') {
40+
throw new Error('padToggle requires l10nId (string) — never hardcode a label');
41+
}
42+
43+
const userCheckboxId = `options-${settingId}`;
44+
const padCheckboxId = `padsettings-options-${settingId}`;
45+
let cachedDefaultEnabled = !!defaultEnabled;
46+
47+
// ---------- Server hooks ----------
48+
49+
const loadSettings = async (hookName, args) => {
50+
const ps = (args && args.settings && args.settings[pluginName]) || {};
51+
if (typeof ps.defaultEnabled === 'boolean') cachedDefaultEnabled = ps.defaultEnabled;
52+
};
53+
54+
const clientVars = async (hookName, ctx) => {
55+
let initialPadEnabled = cachedDefaultEnabled;
56+
try {
57+
const padSettings = ctx && ctx.pad && typeof ctx.pad.getPadSettings === 'function'
58+
? ctx.pad.getPadSettings() : null;
59+
const stored = padSettings && padSettings[pluginName];
60+
if (stored && typeof stored.enabled === 'boolean') initialPadEnabled = stored.enabled;
61+
} catch (_e) { /* leave initialPadEnabled at instance default */ }
62+
63+
const helperBlock = {
64+
[pluginName]: {
65+
padWideSupported: padOptionsPluginPassthrough,
66+
settingId,
67+
l10nId,
68+
defaultEnabled: cachedDefaultEnabled,
69+
initialPadEnabled,
70+
},
71+
};
72+
return {ep_plugin_helpers: {padToggle: helperBlock}};
73+
};
74+
75+
const renderCheckbox = (idPrefix) =>
76+
`<p>` +
77+
`<input type="checkbox" id="${idPrefix}options-${settingId}">` +
78+
`<label for="${idPrefix}options-${settingId}" data-l10n-id="${l10nId}"></label>` +
79+
`</p>`;
80+
81+
const eejsBlock_mySettings = (hookName, args, cb) => {
82+
args.content += renderCheckbox('');
83+
return cb();
84+
};
85+
86+
const eejsBlock_padSettings = (hookName, args, cb) => {
87+
if (!padOptionsPluginPassthrough) return cb();
88+
args.content += renderCheckbox('padsettings-');
89+
return cb();
90+
};
91+
92+
// ---------- Client-side state (closed over by init/handleClientMessage) ----------
93+
94+
let onChangeCallback = () => {};
95+
let lastEffective = null;
96+
97+
const getPad = () => {
98+
if (typeof window === 'undefined') return null;
99+
// Try the AMD pad module first (preferred), then the global.
100+
try {
101+
// eslint-disable-next-line global-require
102+
const m = require('ep_etherpad-lite/static/js/pad');
103+
if (m && m.pad) return m.pad;
104+
} catch (_e) { /* fall through */ }
105+
return window.pad || (window.top && window.top.pad) || null;
106+
};
107+
108+
const getCookie = () => {
109+
try {
110+
// eslint-disable-next-line global-require
111+
return require('ep_etherpad-lite/static/js/pad_cookie').padcookie;
112+
} catch (_e) { return null; }
113+
};
114+
115+
const getClientVars = () => {
116+
if (typeof window === 'undefined') return null;
117+
return window.clientVars || (window.top && window.top.clientVars) || null;
118+
};
119+
120+
const isSupportedClient = () => {
121+
const cv = getClientVars();
122+
const block = cv && cv.ep_plugin_helpers && cv.ep_plugin_helpers.padToggle &&
123+
cv.ep_plugin_helpers.padToggle[pluginName];
124+
return !!(block && block.padWideSupported);
125+
};
126+
127+
const readPadValue = () => {
128+
const pad = getPad();
129+
if (!pad || typeof pad.getPadOptions !== 'function') return undefined;
130+
const opts = pad.getPadOptions();
131+
const v = opts && opts[pluginName];
132+
return (v && typeof v.enabled === 'boolean') ? v.enabled : undefined;
133+
};
134+
135+
const readUserValue = () => {
136+
const cookie = getCookie();
137+
if (!cookie) return undefined;
138+
const pref = cookie.getPref(settingId);
139+
return (pref === true || pref === false) ? pref : undefined;
140+
};
141+
142+
const isEnforced = () => {
143+
const pad = getPad();
144+
return !!(pad && typeof pad.isPadSettingsEnforcedForMe === 'function' &&
145+
pad.isPadSettingsEnforcedForMe());
146+
};
147+
148+
const getEffective = () => {
149+
if (isEnforced()) {
150+
const padVal = readPadValue();
151+
return padVal != null ? padVal : cachedDefaultEnabled;
152+
}
153+
const userVal = readUserValue();
154+
if (userVal != null) return userVal;
155+
const padVal = readPadValue();
156+
return padVal != null ? padVal : cachedDefaultEnabled;
157+
};
158+
159+
const refreshUI = () => {
160+
const $u = window.$(`#${userCheckboxId}`);
161+
const $p = window.$(`#${padCheckboxId}`);
162+
const eff = getEffective();
163+
const padVal = readPadValue();
164+
if ($u.length) {
165+
$u.prop('checked', eff);
166+
$u.prop('disabled', isEnforced());
167+
}
168+
if ($p.length && padVal != null) {
169+
$p.prop('checked', padVal);
170+
}
171+
if (eff !== lastEffective) {
172+
lastEffective = eff;
173+
try { onChangeCallback(eff); } catch (e) { console.error(e); }
174+
}
175+
};
176+
177+
const init = (opts = {}) => {
178+
onChangeCallback = typeof opts.onChange === 'function' ? opts.onChange : () => {};
179+
const pad = getPad();
180+
const cookie = getCookie();
181+
const $u = window.$(`#${userCheckboxId}`);
182+
const $p = window.$(`#${padCheckboxId}`);
183+
184+
// User-side checkbox: cookie-backed, mirrors the effective value when not
185+
// enforced. Disabled visually + functionally when the pad creator has
186+
// turned on enforceSettings.
187+
if ($u.length) {
188+
$u.prop('checked', getEffective());
189+
$u.prop('disabled', isEnforced());
190+
$u.on('change', () => {
191+
if (isEnforced()) {
192+
$u.prop('checked', getEffective());
193+
return;
194+
}
195+
const v = $u.is(':checked');
196+
if (cookie) cookie.setPref(settingId, v);
197+
refreshUI();
198+
});
199+
}
200+
201+
// Pad-wide checkbox: only present when the rendering hook ran (i.e. the
202+
// server has the passthrough patch). changePadOption broadcasts and the
203+
// local applyPadSettings updates pad.padOptions[pluginName] in-place.
204+
if ($p.length && pad && typeof pad.changePadOption === 'function') {
205+
const initial = readPadValue();
206+
if (initial != null) $p.prop('checked', initial);
207+
$p.on('change', () => {
208+
const v = $p.is(':checked');
209+
pad.changePadOption(pluginName, {enabled: v});
210+
refreshUI();
211+
});
212+
} else if (!isSupportedClient()) {
213+
// Surface the degraded state once per pad load so the operator notices
214+
// when their core lacks the passthrough patch but plugin authors expect
215+
// pad-wide behavior.
216+
if (typeof console !== 'undefined' && !init._warned) {
217+
console.warn(
218+
`[ep_plugin_helpers.padToggle ${pluginName}] pad-wide settings ` +
219+
'unavailable — server lacks ep_* passthrough patch (Etherpad < 2.7.4). ' +
220+
'Per-user cookie toggle still works.');
221+
init._warned = true;
222+
}
223+
}
224+
225+
lastEffective = getEffective();
226+
try { onChangeCallback(lastEffective); } catch (e) { console.error(e); }
227+
228+
return {
229+
getEnabled: () => lastEffective,
230+
refresh: refreshUI,
231+
};
232+
};
233+
234+
// Etherpad dispatches handleClientMessage_<type> for every incoming
235+
// COLLABROOM message. For pad-wide changes, the outer type is
236+
// CLIENT_MESSAGE and the inner payload.type is padoptions. Plugins
237+
// re-export this hook so the helper can refresh local state when another
238+
// user toggles the pad-wide value.
239+
const handleClientMessage_CLIENT_MESSAGE = (hookName, ctx) => {
240+
if (!ctx || !ctx.payload) return;
241+
if (ctx.payload.type === 'padoptions') refreshUI();
242+
};
243+
244+
return {
245+
loadSettings,
246+
clientVars,
247+
eejsBlock_mySettings,
248+
eejsBlock_padSettings,
249+
init,
250+
handleClientMessage_CLIENT_MESSAGE,
251+
};
252+
};
253+
254+
module.exports = {padToggle, createPadToggle: padToggle};

0 commit comments

Comments
 (0)