diff --git a/README.md b/README.md index d84682f..c96777c 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,59 @@ The plugin's `ep.json` must list each hook on the right side: - `enforceSettings` on → use the pad-wide value - `enforceSettings` off → use the user cookie value, falling back to pad-wide, falling back to `defaultEnabled` +### ToolbarSelect + +DRY up the toolbar ``. | +| `context` | yes | — | The `context` argument from `postAceInit` — must expose `context.ace.callWithAce` and `context.ace.focus`. | +| `invoke` | yes | — | `(ace, coercedValue) => void`. Runs inside `callWithAce` so the edit joins the undo stack. | +| `op` | no | `'toolbarSelect'` | `callWithAce` label — useful for debugging the undo stack. | +| `coerce` | no | `'int'` | One of `'int' \| 'number' \| 'string' \| 'identity'`, or a custom `(raw) => coerced \| null`. Returning `null` skips the edit but still restores focus. | +| `resetValue` | no | `'dummy'` | Value to write back to the select after a successful edit, so picking the same option again still fires `change`. | +| `onAfterChange` | no | — | `(coercedValue) => void`, called after focus is restored. Errors are swallowed and logged so a buggy callback can't break the editor. | + +Focus restoration runs unconditionally — even if `coerce` returned `null` — so an accidental pick on a non-numeric option leaves the user typing back in the pad, not on the toolbar control. + ### Message Relay Intercept and relay real-time COLLABROOM messages. diff --git a/package.json b/package.json index 980985d..67ed037 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ep_plugin_helpers", - "version": "0.5.3", + "version": "0.6.0", "description": "Shared factory functions to eliminate boilerplate across Etherpad plugins", "author": { "name": "John McLear", diff --git a/test/toolbar-select.js b/test/toolbar-select.js new file mode 100644 index 0000000..5a56a75 --- /dev/null +++ b/test/toolbar-select.js @@ -0,0 +1,231 @@ +'use strict'; + +const assert = require('assert'); +const {toolbarSelect} = require('../toolbar-select'); + +// Minimal jQuery-shaped stub. The real plugin runs under Etherpad's bundled +// jQuery; the helper only uses `.on('change', fn)` and `.val(...)`. +const makeFakeSelect = (initialValue = '12') => { + let value = initialValue; + let changeHandler = null; + const $el = { + on(event, handler) { + if (event !== 'change') throw new Error(`unexpected event: ${event}`); + changeHandler = handler; + return $el; + }, + val(v) { + if (v === undefined) return value; + value = v; + return $el; + }, + // Test-only helper: simulate the user picking an option. + _fire(newValue) { + value = newValue; + if (changeHandler) changeHandler.call({_isThis: true, _$: $el}); + }, + }; + return $el; +}; + +const installJqueryStub = ($el) => { + // Capture the previous global so multiple tests can swap stubs cleanly. + const prev = globalThis.window; + globalThis.window = { + $: (selectorOrThis) => { + // window.$(this) inside the change handler returns the element wrapper; + // window.$(selector) returns the captured $el. + if (selectorOrThis && selectorOrThis._isThis) return selectorOrThis._$; + return $el; + }, + }; + return () => { globalThis.window = prev; }; +}; + +const makeContext = () => { + const calls = []; + let focused = 0; + const aceObj = { + ace_invoked: (val) => calls.push({type: 'invoked', val}), + }; + return { + calls, + get focusCount() { return focused; }, + ctx: { + ace: { + callWithAce(fn, op, fast) { calls.push({type: 'callWithAce', op, fast}); fn(aceObj); }, + focus() { focused++; }, + }, + }, + }; +}; + +describe('toolbarSelect', () => { + describe('config validation', () => { + let restore; + beforeEach(() => { restore = installJqueryStub(makeFakeSelect()); }); + afterEach(() => restore()); + + const ok = () => { + const {ctx} = makeContext(); + return { + selector: '#x', + context: ctx, + invoke: (ace, v) => ace.ace_invoked(v), + }; + }; + + it('throws when config is missing', () => { + assert.throws(() => toolbarSelect(), /config object/); + assert.throws(() => toolbarSelect(null), /config object/); + }); + + it('throws when selector is missing or not a string', () => { + assert.throws(() => toolbarSelect({...ok(), selector: ''}), /selector/); + assert.throws(() => toolbarSelect({...ok(), selector: 42}), /selector/); + }); + + it('throws when context.ace is absent or missing required methods', () => { + assert.throws(() => toolbarSelect({...ok(), context: {}}), /context\.ace/); + assert.throws( + () => toolbarSelect({...ok(), context: {ace: {callWithAce: () => {}}}}), + /callWithAce \/ focus/); + assert.throws( + () => toolbarSelect({...ok(), context: {ace: {focus: () => {}}}}), + /callWithAce \/ focus/); + }); + + it('throws when invoke is not a function', () => { + assert.throws(() => toolbarSelect({...ok(), invoke: undefined}), /invoke/); + assert.throws(() => toolbarSelect({...ok(), invoke: 'not-a-fn'}), /invoke/); + }); + + it('throws when op is provided but empty', () => { + assert.throws(() => toolbarSelect({...ok(), op: ''}), /op must be a non-empty string/); + }); + + it('throws when coerce is an unknown token', () => { + assert.throws(() => toolbarSelect({...ok(), coerce: 'bigint'}), + /coerce must be a function or one of/); + }); + + it('throws when onAfterChange is provided but not a function', () => { + assert.throws(() => toolbarSelect({...ok(), onAfterChange: 42}), /onAfterChange/); + }); + }); + + describe('change behaviour', () => { + it("calls invoke with coerced int, resets select, and focuses editor", () => { + const $sel = makeFakeSelect('initial'); + const restore = installJqueryStub($sel); + const {ctx, calls} = makeContext(); + let captured = ctx; // alias for clarity + + toolbarSelect({ + selector: '#font-size', + context: ctx, + invoke: (ace, v) => ace.ace_invoked(v), + op: 'insertsize', + }); + + $sel._fire('24'); + restore(); + + assert.deepStrictEqual(calls, [ + {type: 'callWithAce', op: 'insertsize', fast: true}, + {type: 'invoked', val: 24}, + ]); + assert.strictEqual($sel.val(), 'dummy', 'select should be reset to sentinel'); + assert.strictEqual(captured === ctx, true); + }); + + it('still focuses the editor when the coerced value is unusable', () => { + const $sel = makeFakeSelect('initial'); + const restore = installJqueryStub($sel); + const ce = makeContext(); + + toolbarSelect({ + selector: '#font-size', + context: ce.ctx, + invoke: (ace, v) => ace.ace_invoked(v), + }); + + $sel._fire('not-a-number'); + restore(); + + // No edit happened, but focus was restored — the user picked from a + // toolbar control and we don't want keystrokes to land back on it. + assert.strictEqual(ce.calls.length, 0); + assert.strictEqual(ce.focusCount, 1); + assert.strictEqual($sel.val(), 'not-a-number', + 'unusable picks must not reset the select — the user can see what they picked'); + }); + + it('supports a function coercer', () => { + const $sel = makeFakeSelect(); + const restore = installJqueryStub($sel); + const ce = makeContext(); + + toolbarSelect({ + selector: '#x', + context: ce.ctx, + invoke: (ace, v) => ace.ace_invoked(v), + coerce: (raw) => raw === 'special' ? {marker: true} : null, + }); + + $sel._fire('special'); + restore(); + + assert.strictEqual(ce.calls.length, 2); + assert.deepStrictEqual(ce.calls[1], {type: 'invoked', val: {marker: true}}); + }); + + it('honours a custom resetValue', () => { + const $sel = makeFakeSelect(); + const restore = installJqueryStub($sel); + const ce = makeContext(); + + toolbarSelect({ + selector: '#x', + context: ce.ctx, + invoke: (ace, v) => ace.ace_invoked(v), + resetValue: '__none__', + }); + + $sel._fire('7'); + restore(); + + assert.strictEqual($sel.val(), '__none__'); + }); + + it('invokes onAfterChange with the coerced value and swallows callback errors', () => { + const $sel = makeFakeSelect(); + const restore = installJqueryStub($sel); + const ce = makeContext(); + const seen = []; + const consoleErr = console.error; + console.error = () => {}; // silence the expected error log + + try { + toolbarSelect({ + selector: '#x', + context: ce.ctx, + invoke: (ace, v) => ace.ace_invoked(v), + onAfterChange: (v) => { + seen.push(v); + if (v === 99) throw new Error('boom'); + }, + }); + + $sel._fire('5'); + $sel._fire('99'); + } finally { + console.error = consoleErr; + restore(); + } + + assert.deepStrictEqual(seen, [5, 99]); + assert.strictEqual(ce.focusCount, 2, 'focus must run for both changes'); + }); + }); +}); diff --git a/toolbar-select.js b/toolbar-select.js new file mode 100644 index 0000000..ef2639b --- /dev/null +++ b/toolbar-select.js @@ -0,0 +1,117 @@ +'use strict'; + +// toolbarSelect (client side) — binds the change handler for a toolbar +// to a sentinel value (so picking the same option +// again still fires `change`) and route focus back to the editor +// +// No top-level requires that touch server-only modules — esbuild bundles +// this into the browser pad bundle, and any node-only path would break +// the client build. + +const ALLOWED_COERCE = new Set(['int', 'number', 'string', 'identity']); + +const validateConfig = (config) => { + if (!config || typeof config !== 'object') { + throw new Error('toolbarSelect requires a config object'); + } + const { + selector, + context, + invoke, + op = 'toolbarSelect', + coerce = 'int', + resetValue = 'dummy', + onAfterChange, + } = config; + + if (!selector || typeof selector !== 'string') { + throw new Error('toolbarSelect requires selector (jQuery selector string)'); + } + if (!context || typeof context !== 'object' || !context.ace) { + throw new Error('toolbarSelect requires the postAceInit `context` (must include context.ace)'); + } + if (typeof context.ace.callWithAce !== 'function' || typeof context.ace.focus !== 'function') { + throw new Error('toolbarSelect: context.ace is missing callWithAce / focus — wrong context?'); + } + if (typeof invoke !== 'function') { + throw new Error('toolbarSelect requires invoke: (ace, value) => void'); + } + if (typeof op !== 'string' || !op) { + throw new Error('toolbarSelect: op must be a non-empty string when provided'); + } + if (typeof coerce !== 'function' && !ALLOWED_COERCE.has(coerce)) { + throw new Error( + `toolbarSelect: coerce must be a function or one of ${[...ALLOWED_COERCE].join(', ')}`); + } + if (onAfterChange != null && typeof onAfterChange !== 'function') { + throw new Error('toolbarSelect: onAfterChange must be a function when provided'); + } + + return {selector, context, invoke, op, coerce, resetValue, onAfterChange}; +}; + +// Resolve a coerce token (or function) into a unary coercer. The coercer +// returns null whenever the value cannot be used — the caller then skips +// the edit but still restores focus. +const resolveCoerce = (coerce) => { + if (typeof coerce === 'function') return coerce; + if (coerce === 'int') return (raw) => { + const n = parseInt(raw, 10); + return Number.isNaN(n) ? null : n; + }; + if (coerce === 'number') return (raw) => { + const n = Number(raw); + return Number.isNaN(n) ? null : n; + }; + if (coerce === 'string') return (raw) => (raw == null || raw === '') ? null : String(raw); + // 'identity' + return (raw) => (raw == null || raw === '') ? null : raw; +}; + +const toolbarSelect = (rawConfig) => { + const cfg = validateConfig(rawConfig); + const coercer = resolveCoerce(cfg.coerce); + + // window.$ is jQuery as exposed by Etherpad's pad bundle. We don't import + // jquery directly so the helper works whether the host plugin pulls jQuery + // from the same npm version or relies on the bundled one. + const $sel = window.$(cfg.selector); + + $sel.on('change', function onToolbarSelectChange() { + const $this = window.$(this); + const raw = $this.val(); + const value = coercer(raw); + + if (value != null) { + cfg.context.ace.callWithAce((ace) => { + cfg.invoke(ace, value); + }, cfg.op, true); + $this.val(cfg.resetValue); + } + + // Focus restoration runs unconditionally: even if the coerced value was + // unusable, the user clicked the select and we don't want to leave focus + // stuck on a toolbar control where the next keystroke would be lost + // (or, in some browsers, scroll the select's option list). + cfg.context.ace.focus(); + + if (cfg.onAfterChange) { + try { cfg.onAfterChange(value); } catch (e) { + // eslint-disable-next-line no-console + if (typeof console !== 'undefined') console.error('toolbarSelect onAfterChange threw', e); + } + } + }); + + return {$sel}; +}; + +module.exports = {toolbarSelect};