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", "
"], + ]); + }); +}); +