Tiptap (and many other text editors based on ProseMirror) has this amazing API where we can chain commands.
For example:
editor.chain().focus().toggleItalic().run()
Here, calling chain() returns all the chainable commands which themselves return chainable commands and that's how the chain goes on and on until you call run().
After calling run(), the commands between chain() and run() are executed in sync.
What's interesting (as far as my knowledge goes) is that this isn't a class. All of this can also be extended (including typings) by creating custom extensions for Tiptap. See this bold extension and its types.
I did go thru there source but wasn't able to comprehend much.
I really liked this design pattern and was wondering how should I go about implementing this in general...
Say, I wanted to implement a calculator on this pattern. How can I make this line work?
calculator.chain().setValue(10).add(3).sub(12).run() // 1
Thank you!
There's lots of ways you could potentially implement this, here is just one with a simple object. A class encapsulation would propbably be a good idea. So long as you keep your methods contained within a single object, you can keep returning it and adding to the callback queue, which will eventually be executed on .run() and break the loop.
const calculator = {
state: { value: 0 },
callbacks: [] as ((...args: any[]) => any)[],
_exec: {
setValue: (val: number) => (calculator.state.value = val),
add: (val: number) => (calculator.state.value += val),
sub: (val: number) => (calculator.state.value -= val),
},
_queue: {
setValue: (val: number) => {
calculator.callbacks.push(() => calculator._exec.setValue(val));
return calculator._queue;
},
add: (val: number) => {
calculator.callbacks.push(() => calculator._exec.add(val));
return calculator._queue;
},
sub: (val: number) => {
calculator.callbacks.push(() => calculator._exec.sub(val));
return calculator._queue;
},
run: () => {
calculator.callbacks.forEach((cb) => cb());
calculator.callbacks = [];
},
},
chain: () => calculator._queue,
};
calculator.chain().setValue(10).add(3).sub(12).run();
console.log(calculator.state.value); // logs 1
Just use a class:
class Calculator {
constructor(value) {
this.value = value;
}
run() { return this.value; }
add(x) { return new Calculator(this.value + x); }
sub(x) { return new Calculator(this.value - x); }
log() { console.log(this.value); return this; }
static withValue(v) { return new this(v); }
}
const calc = Calculator.withValue(10).add(3).log();
console.log(calc.sub(12).run());
console.log(calc.add(29).run());
The only thing that is important for method chaining is to return an object with all the methods (here: a Calculator instance) from each of the chainable methods. You can achieve the same without class syntax as well:
function calculatorWithValue(v) {
return {
run() { return v; },
add(x) { return calculatorWithValue(v + x); },
sub(x) { return calculatorWithValue(v - x); },
log() { console.log(v); return this; },
};
}
const calc = calculatorWithValue(10).add(3).log();
console.log(calc.sub(12).run());
console.log(calc.add(29).run());
I recommend to keep the objects immutable, to allow creation of multiple independent chains or forking of an existing chain - see the examples above, where the second result is 10 + 3 + 29, not 10 + 3 - 12 + 29.
If you want the execution to be lazy, that's easy as well:
class Calculator {
constructor(run) {
this.run = run;
}
static withValue(v) { return new this(() => v); }
add(x) { return new Calculator(() => this.run() + x); }
sub(x) { return new Calculator(() => this.run() - x); }
log() { return new Calculator(() => {
const v = this.run();
console.log(v);
return v;
}); }
}
const calc = Calculator.withValue(10).add(3).log();
console.log(calc.sub(12).run());
console.log(calc.add(29).run());
If you want to make this extensible with some kind of plugin architecture, just put more methods on the prototype, or build the object dynamically. For TypeScript, you'd use declaration merging to amend the Calculator interface with the signatures that your plugin provides.