|
| 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