Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<select>`-change → ace edit → focus-restore boilerplate that plugins like `ep_font_color`, `ep_font_size`, and `ep_headings2` each implement by hand:

```js
// before — repeated in each plugin
exports.postAceInit = (hookName, context) => {
const hs = $('#font-size, select.size-selection');
hs.on('change', function () {
const value = $(this).val();
const intValue = parseInt(value, 10);
if (!isNaN(intValue)) {
context.ace.callWithAce((ace) => {
ace.ace_doInsertsizes(intValue);
}, 'insertsize', true);
hs.val('dummy');
context.ace.focus();
}
});
};
```

With this helper:

```js
// Client-only — import the sub-path directly so esbuild doesn't pull
// any server-only deps into the pad bundle.
const {toolbarSelect} = require('ep_plugin_helpers/toolbar-select');

exports.postAceInit = (hookName, context) => {
toolbarSelect({
selector: '#font-size, select.size-selection',
context,
invoke: (ace, value) => ace.ace_doInsertsizes(value),
op: 'insertsize',
});
};
```

**Config:**

| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `selector` | yes | — | jQuery selector for the toolbar `<select>`. |
| `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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
231 changes: 231 additions & 0 deletions test/toolbar-select.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
Loading