diff --git a/README.md b/README.md
index a1280de..b872391 100644
--- a/README.md
+++ b/README.md
@@ -160,6 +160,26 @@ Idiomorph.morph(document.documentElement, newPageSource, {head:{style: 'morph'}}
The `head` object also offers callbacks for configuring head merging specifics.
+### Plugins
+
+Idiomorph supports a plugin system that allows you to extend the functionality of the library, by registering an object of callbacks:
+
+```js
+Idiomorph.registerPlugin({
+ name: 'logger',
+ onBeforeNodeAdded: function(node) {
+ console.log('Node added:', node);
+ },
+ onBeforeNodeRemoved: function(node) {
+ console.log('Node removed:', node);
+ },
+});
+
+Idiomorph.plugins // { logger: { ...} };
+```
+
+These callbacks will be called in addition to any other callbacks that are registered in `Idiomorph.morph`. Multiple plugins can be registered.
+
### Setting Defaults
All the behaviors specified above can be set to a different default by mutating the `Idiomorph.defaults` object, including
diff --git a/src/idiomorph.js b/src/idiomorph.js
index f27e2e1..43841e3 100644
--- a/src/idiomorph.js
+++ b/src/idiomorph.js
@@ -144,6 +144,11 @@ var Idiomorph = (function () {
restoreFocus: true,
};
+ let plugins = {};
+ function addPlugin(plugin) {
+ plugins[plugin.name] = plugin;
+ }
+
/**
* Core idiomorph function for morphing one DOM tree to another
*
@@ -348,6 +353,26 @@ var Idiomorph = (function () {
}
}
+ function withNodeCallbacks(ctx, name, node, fn) {
+ const allPlugins = [...Object.values(plugins), ctx.callbacks];
+
+ const shouldAbort = allPlugins.some((plugin) => {
+ const beforeFn = plugin[`beforeNode${name}`];
+ return beforeFn && beforeFn(node) === false;
+ });
+
+ if (shouldAbort) return;
+
+ const resultNode = fn();
+
+ allPlugins.reverse().forEach((plugin) => {
+ const afterFn = plugin[`afterNode${name}`];
+ afterFn && afterFn(resultNode);
+ });
+
+ return resultNode;
+ }
+
/**
* This performs the action of inserting a new node while handling situations where the node contains
* elements with persistent ids and possible state info we can still preserve by moving in and then morphing
@@ -359,23 +384,22 @@ var Idiomorph = (function () {
* @returns {Node|null}
*/
function createNode(oldParent, newChild, insertionPoint, ctx) {
- if (ctx.callbacks.beforeNodeAdded(newChild) === false) return null;
- if (ctx.idMap.has(newChild)) {
- // node has children with ids with possible state so create a dummy elt of same type and apply full morph algorithm
- const newEmptyChild = document.createElement(
- /** @type {Element} */ (newChild).tagName,
- );
- oldParent.insertBefore(newEmptyChild, insertionPoint);
- morphNode(newEmptyChild, newChild, ctx);
- ctx.callbacks.afterNodeAdded(newEmptyChild);
- return newEmptyChild;
- } else {
- // optimisation: no id state to preserve so we can just insert a clone of the newChild and its descendants
- const newClonedChild = document.importNode(newChild, true); // importNode to not mutate newParent
- oldParent.insertBefore(newClonedChild, insertionPoint);
- ctx.callbacks.afterNodeAdded(newClonedChild);
- return newClonedChild;
- }
+ return withNodeCallbacks(ctx, "Added", newChild, () => {
+ if (ctx.idMap.has(newChild)) {
+ // node has children with ids with possible state so create a dummy elt of same type and apply full morph algorithm
+ const newEmptyChild = document.createElement(
+ /** @type {Element} */ (newChild).tagName,
+ );
+ oldParent.insertBefore(newEmptyChild, insertionPoint);
+ morphNode(newEmptyChild, newChild, ctx);
+ return newEmptyChild;
+ } else {
+ // optimisation: no id state to preserve so we can just insert a clone of the newChild and its descendants
+ const newClonedChild = document.importNode(newChild, true); // importNode to not mutate newParent
+ oldParent.insertBefore(newClonedChild, insertionPoint);
+ return newClonedChild;
+ }
+ });
}
//=============================================================================
@@ -505,9 +529,9 @@ var Idiomorph = (function () {
moveBefore(ctx.pantry, node, null);
} else {
// remove for realsies
- if (ctx.callbacks.beforeNodeRemoved(node) === false) return;
- node.parentNode?.removeChild(node);
- ctx.callbacks.afterNodeRemoved(node);
+ withNodeCallbacks(ctx, "Removed", node, () => {
+ return node.parentNode?.removeChild(node);
+ });
}
}
@@ -1300,5 +1324,6 @@ var Idiomorph = (function () {
return {
morph,
defaults,
+ addPlugin,
};
})();
diff --git a/test/index.html b/test/index.html
index f835d73..3f5db9b 100644
--- a/test/index.html
+++ b/test/index.html
@@ -45,6 +45,7 @@
Mocha Test Suite
+
diff --git a/test/plugins.js b/test/plugins.js
new file mode 100644
index 0000000..ddfa70c
--- /dev/null
+++ b/test/plugins.js
@@ -0,0 +1,277 @@
+describe("Plugin system", function () {
+ setup();
+
+ it("can add plugins", function () {
+ let calls = [];
+
+ const plugin = {
+ name: "foo",
+ beforeNodeAdded: function (node) {
+ calls.push(["beforeNodeAdded", node.outerHTML]);
+ },
+ afterNodeAdded: function (node) {
+ calls.push(["afterNodeAdded", node.outerHTML]);
+ },
+ beforeNodeRemoved: function (node) {
+ calls.push(["beforeNodeRemoved", node.outerHTML]);
+ },
+ afterNodeRemoved: function (node) {
+ calls.push(["afterNodeRemoved", node.outerHTML]);
+ },
+ };
+ Idiomorph.addPlugin(plugin);
+
+ Idiomorph.morph(make("
"), "
");
+
+ calls.should.eql([
+ ["beforeNodeAdded", "
"],
+ ["afterNodeAdded", "
"],
+ ["beforeNodeRemoved", "
"],
+ ["afterNodeRemoved", "
"],
+ ]);
+ });
+
+ it("can add multiple plugins", function () {
+ let calls = [];
+
+ const plugin1 = {
+ name: "foo",
+ beforeNodeAdded: function (node) {
+ calls.push(["beforeNodeAdded1", node.outerHTML]);
+ },
+ afterNodeAdded: function (node) {
+ calls.push(["afterNodeAdded1", node.outerHTML]);
+ },
+ beforeNodeRemoved: function (node) {
+ calls.push(["beforeNodeRemoved1", node.outerHTML]);
+ },
+ afterNodeRemoved: function (node) {
+ calls.push(["afterNodeRemoved1", node.outerHTML]);
+ },
+ };
+ Idiomorph.addPlugin(plugin1);
+
+ const plugin2 = {
+ name: "bar",
+ beforeNodeAdded: function (node) {
+ calls.push(["beforeNodeAdded2", node.outerHTML]);
+ },
+ afterNodeAdded: function (node) {
+ calls.push(["afterNodeAdded2", node.outerHTML]);
+ },
+ beforeNodeRemoved: function (node) {
+ calls.push(["beforeNodeRemoved2", node.outerHTML]);
+ },
+ afterNodeRemoved: function (node) {
+ calls.push(["afterNodeRemoved2", node.outerHTML]);
+ },
+ };
+ Idiomorph.addPlugin(plugin2);
+
+ Idiomorph.morph(make("
"), "
");
+
+ calls.should.eql([
+ ["beforeNodeAdded1", "
"],
+ ["beforeNodeAdded2", "
"],
+ ["afterNodeAdded2", "
"],
+ ["afterNodeAdded1", "
"],
+ ["beforeNodeRemoved1", "
"],
+ ["beforeNodeRemoved2", "
"],
+ ["afterNodeRemoved2", "
"],
+ ["afterNodeRemoved1", "
"],
+ ]);
+ });
+
+ it("can add multiple plugins alongside callbacks", function () {
+ let calls = [];
+
+ const plugin1 = {
+ name: "foo",
+ beforeNodeAdded: function (node) {
+ calls.push(["beforeNodeAdded1", node.outerHTML]);
+ },
+ afterNodeAdded: function (node) {
+ calls.push(["afterNodeAdded1", node.outerHTML]);
+ },
+ beforeNodeRemoved: function (node) {
+ calls.push(["beforeNodeRemoved1", node.outerHTML]);
+ },
+ afterNodeRemoved: function (node) {
+ calls.push(["afterNodeRemoved1", node.outerHTML]);
+ },
+ };
+ Idiomorph.addPlugin(plugin1);
+
+ const plugin2 = {
+ name: "bar",
+ beforeNodeAdded: function (node) {
+ calls.push(["beforeNodeAdded2", node.outerHTML]);
+ },
+ afterNodeAdded: function (node) {
+ calls.push(["afterNodeAdded2", node.outerHTML]);
+ },
+ beforeNodeRemoved: function (node) {
+ calls.push(["beforeNodeRemoved2", node.outerHTML]);
+ },
+ afterNodeRemoved: function (node) {
+ calls.push(["afterNodeRemoved2", node.outerHTML]);
+ },
+ };
+ Idiomorph.addPlugin(plugin2);
+
+ Idiomorph.morph(make("
"), "
", {
+ callbacks: {
+ beforeNodeAdded: function (node) {
+ calls.push(["beforeNodeAddedCallback", node.outerHTML]);
+ },
+ afterNodeAdded: function (node) {
+ calls.push(["afterNodeAddedCallback", node.outerHTML]);
+ },
+ beforeNodeRemoved: function (node) {
+ calls.push(["beforeNodeRemovedCallback", node.outerHTML]);
+ },
+ afterNodeRemoved: function (node) {
+ calls.push(["afterNodeRemovedCallback", node.outerHTML]);
+ },
+ },
+ });
+
+ calls.should.eql([
+ ["beforeNodeAdded1", "
"],
+ ["beforeNodeAdded2", "
"],
+ ["beforeNodeAddedCallback", "
"],
+ ["afterNodeAddedCallback", "
"],
+ ["afterNodeAdded2", "
"],
+ ["afterNodeAdded1", "
"],
+ ["beforeNodeRemoved1", "
"],
+ ["beforeNodeRemoved2", "
"],
+ ["beforeNodeRemovedCallback", "
"],
+ ["afterNodeRemovedCallback", "
"],
+ ["afterNodeRemoved2", "
"],
+ ["afterNodeRemoved1", "
"],
+ ]);
+ });
+
+ it("the first beforeNodeAdded => false halts the entire operation", function () {
+ let calls = [];
+
+ const plugin1 = {
+ name: "foo",
+ beforeNodeAdded: function (node) {
+ calls.push(["beforeNodeAdded1", node.outerHTML]);
+ return false;
+ },
+ afterNodeAdded: function (node) {
+ calls.push(["afterNodeAdded1", node.outerHTML]);
+ },
+ };
+ Idiomorph.addPlugin(plugin1);
+
+ const plugin2 = {
+ name: "bar",
+ beforeNodeAdded: function (node) {
+ calls.push(["beforeNodeAdded2", node.outerHTML]);
+ },
+ afterNodeAdded: function (node) {
+ calls.push(["afterNodeAdded2", node.outerHTML]);
+ },
+ };
+ Idiomorph.addPlugin(plugin2);
+
+ Idiomorph.morph(make(""), "
", {
+ callbacks: {
+ beforeNodeAdded: function (node) {
+ calls.push(["beforeNodeAddedCallback", node.outerHTML]);
+ },
+ afterNodeAdded: function (node) {
+ calls.push(["afterNodeAddedCallback", node.outerHTML]);
+ },
+ },
+ });
+
+ calls.should.eql([
+ ["beforeNodeAdded1", "
"]
+ ]);
+ });
+
+ it("the first beforeNodeRemoved => false halts the entire operation", function () {
+ let calls = [];
+
+ const plugin1 = {
+ name: "foo",
+ beforeNodeRemoved: function (node) {
+ calls.push(["beforeNodeRemoved1", node.outerHTML]);
+ return false
+ },
+ afterNodeRemoved: function (node) {
+ calls.push(["afterNodeRemoved1", node.outerHTML]);
+ },
+ };
+ Idiomorph.addPlugin(plugin1);
+
+ const plugin2 = {
+ name: "bar",
+ beforeNodeRemoved: function (node) {
+ calls.push(["beforeNodeRemoved2", node.outerHTML]);
+ },
+ afterNodeRemoved: function (node) {
+ calls.push(["afterNodeRemoved2", node.outerHTML]);
+ },
+ };
+ Idiomorph.addPlugin(plugin2);
+
+ Idiomorph.morph(make("
"), "
", {
+ callbacks: {
+ beforeNodeRemoved: function (node) {
+ calls.push(["beforeNodeRemovedCallback", node.outerHTML]);
+ },
+ afterNodeRemoved: function (node) {
+ calls.push(["afterNodeRemovedCallback", node.outerHTML]);
+ },
+ },
+ });
+
+ calls.should.eql([
+ ["beforeNodeRemoved1", "
"]
+ ]);
+ });
+
+ it("plugin callbacks are not all required to exist", function () {
+ let calls = [];
+
+ const plugin1 = {
+ name: "foo",
+ beforeNodeAdded: function (node) {
+ calls.push(["beforeNodeAdded1", node.outerHTML]);
+ },
+ afterNodeAdded: function (node) {
+ calls.push(["afterNodeAdded1", node.outerHTML]);
+ },
+ };
+ Idiomorph.addPlugin(plugin1);
+
+ const plugin2 = {
+ name: "bar",
+ };
+ Idiomorph.addPlugin(plugin2);
+
+ Idiomorph.morph(make("
"), "
", {
+ callbacks: {
+ beforeNodeAdded: function (node) {
+ calls.push(["beforeNodeAddedCallback", node.outerHTML]);
+ },
+ afterNodeAdded: function (node) {
+ calls.push(["afterNodeAddedCallback", node.outerHTML]);
+ },
+ },
+ });
+
+ calls.should.eql([
+ ["beforeNodeAdded1", "
"],
+ ["beforeNodeAddedCallback", "
"],
+ ["afterNodeAddedCallback", "
"],
+ ["afterNodeAdded1", "
"],
+ ]);
+ });
+});
+