fix(server): fixes admin page gets full DOM reload every 15 seconds
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) Patrick Steele-Idem <pnidem@gmail.com> (psteeleidem.com)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
@@ -16,6 +16,26 @@ const sidebarMeta = document.getElementById("sidebarMeta");
|
|||||||
document.getElementById("refreshButton").addEventListener("click", () => refresh(true));
|
document.getElementById("refreshButton").addEventListener("click", () => refresh(true));
|
||||||
window.addEventListener("hashchange", () => renderRoute());
|
window.addEventListener("hashchange", () => renderRoute());
|
||||||
|
|
||||||
|
const morphdomOptions = {
|
||||||
|
childrenOnly: true,
|
||||||
|
onBeforeElUpdated(fromEl, toEl) {
|
||||||
|
if (fromEl.isEqualNode(toEl)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function patchContent(html) {
|
||||||
|
const temp = document.createElement("section");
|
||||||
|
temp.innerHTML = html;
|
||||||
|
morphdom(content, temp, morphdomOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchSidebar(html) {
|
||||||
|
const temp = document.createElement("div");
|
||||||
|
temp.innerHTML = html;
|
||||||
|
morphdom(sidebarMeta, temp, morphdomOptions);
|
||||||
|
}
|
||||||
|
|
||||||
content.addEventListener("click", async (event) => {
|
content.addEventListener("click", async (event) => {
|
||||||
const button = event.target.closest("button[data-action]");
|
const button = event.target.closest("button[data-action]");
|
||||||
if (!button) {
|
if (!button) {
|
||||||
@@ -263,7 +283,7 @@ async function refresh(showNotice = false, render = true) {
|
|||||||
state.summary = await api("/summary");
|
state.summary = await api("/summary");
|
||||||
updateShell();
|
updateShell();
|
||||||
if (render) {
|
if (render) {
|
||||||
await renderRoute();
|
await renderRoute(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showNotice) {
|
if (showNotice) {
|
||||||
@@ -271,7 +291,7 @@ async function refresh(showNotice = false, render = true) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setNotice(error.message, true);
|
setNotice(error.message, true);
|
||||||
content.innerHTML = `<div class="panel"><div class="empty">${escapeHtml(error.message)}</div></div>`;
|
patchContent(`<div class="panel"><div class="empty">${escapeHtml(error.message)}</div></div>`);
|
||||||
} finally {
|
} finally {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
}
|
}
|
||||||
@@ -304,7 +324,7 @@ async function api(path, options = {}) {
|
|||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderRoute() {
|
async function renderRoute(showLoading = true) {
|
||||||
const hash = (window.location.hash || "#clients").slice(1);
|
const hash = (window.location.hash || "#clients").slice(1);
|
||||||
const parts = hash.split("/");
|
const parts = hash.split("/");
|
||||||
const view = parts[0];
|
const view = parts[0];
|
||||||
@@ -316,13 +336,13 @@ async function renderRoute() {
|
|||||||
|
|
||||||
if (view === "models" && encodedParam) {
|
if (view === "models" && encodedParam) {
|
||||||
const model = decodeURIComponent(encodedParam);
|
const model = decodeURIComponent(encodedParam);
|
||||||
await loadModelDetail(model);
|
await loadModelDetail(model, undefined, showLoading);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (view === "groups" && encodedParam) {
|
if (view === "groups" && encodedParam) {
|
||||||
const groupId = decodeURIComponent(encodedParam);
|
const groupId = decodeURIComponent(encodedParam);
|
||||||
await loadGroupDetail(groupId);
|
await loadGroupDetail(groupId, showLoading);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,7 +365,7 @@ async function renderRoute() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (view === "usage") {
|
if (view === "usage") {
|
||||||
await loadUsage();
|
await loadUsage(showLoading);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,13 +378,13 @@ function updateShell() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sidebarMeta.innerHTML = `
|
patchSidebar(`
|
||||||
<div>${escapeHtml(summary.user?.name || "Signed in")}</div>
|
<div>${escapeHtml(summary.user?.name || "Signed in")}</div>
|
||||||
<div>${summary.clients.length} clients</div>
|
<div>${summary.clients.length} clients</div>
|
||||||
<div>${summary.models.length} models</div>
|
<div>${summary.models.length} models</div>
|
||||||
<div>${(summary.groups || []).length} groups</div>
|
<div>${(summary.groups || []).length} groups</div>
|
||||||
<div>${formatDate(summary.generatedAtUtc)}</div>
|
<div>${formatDate(summary.generatedAtUtc)}</div>
|
||||||
`;
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderClients() {
|
function renderClients() {
|
||||||
@@ -372,7 +392,7 @@ function renderClients() {
|
|||||||
pageTitle.textContent = "Clients";
|
pageTitle.textContent = "Clients";
|
||||||
pageSubtitle.textContent = "Connected tunnel clients, request counts, and forwarding controls.";
|
pageSubtitle.textContent = "Connected tunnel clients, request counts, and forwarding controls.";
|
||||||
|
|
||||||
content.innerHTML = `
|
patchContent(`
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h2>Clients</h2>
|
<h2>Clients</h2>
|
||||||
@@ -382,7 +402,7 @@ function renderClients() {
|
|||||||
${clients.length ? clientsTable(clients) : emptyState("No clients have connected yet.")}
|
${clients.length ? clientsTable(clients) : emptyState("No clients have connected yet.")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clientsTable(clients) {
|
function clientsTable(clients) {
|
||||||
@@ -449,7 +469,7 @@ function renderModels() {
|
|||||||
pageTitle.textContent = "Models";
|
pageTitle.textContent = "Models";
|
||||||
pageSubtitle.textContent = "Listed and active models, recent request volume, and model operations.";
|
pageSubtitle.textContent = "Listed and active models, recent request volume, and model operations.";
|
||||||
|
|
||||||
content.innerHTML = `
|
patchContent(`
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h2>Run model action</h2>
|
<h2>Run model action</h2>
|
||||||
@@ -467,7 +487,7 @@ function renderModels() {
|
|||||||
${models.length ? modelsTable(models) : emptyState("No models have been reported yet.")}
|
${models.length ? modelsTable(models) : emptyState("No models have been reported yet.")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelActionForm(clients, selectedModel = "", selectedClient = "") {
|
function modelActionForm(clients, selectedModel = "", selectedClient = "") {
|
||||||
@@ -537,10 +557,12 @@ function modelsTable(models) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadModelDetail(model, clientId) {
|
async function loadModelDetail(model, clientId, showLoading = true) {
|
||||||
pageTitle.textContent = "Model Detail";
|
pageTitle.textContent = "Model Detail";
|
||||||
pageSubtitle.textContent = model;
|
pageSubtitle.textContent = model;
|
||||||
content.innerHTML = `<div class="panel"><div class="empty">Loading model detail...</div></div>`;
|
if (showLoading) {
|
||||||
|
patchContent(`<div class="panel"><div class="empty">Loading model detail...</div></div>`);
|
||||||
|
}
|
||||||
|
|
||||||
const query = new URLSearchParams({ model });
|
const query = new URLSearchParams({ model });
|
||||||
if (clientId) {
|
if (clientId) {
|
||||||
@@ -562,7 +584,7 @@ function renderModelDetail() {
|
|||||||
pageTitle.textContent = "Model Detail";
|
pageTitle.textContent = "Model Detail";
|
||||||
pageSubtitle.textContent = model;
|
pageSubtitle.textContent = model;
|
||||||
|
|
||||||
content.innerHTML = `
|
patchContent(`
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<a class="button secondary" href="#models">Back to models</a>
|
<a class="button secondary" href="#models">Back to models</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -618,7 +640,7 @@ function renderModelDetail() {
|
|||||||
${detail.show ? `<pre class="pre">${escapeHtml(formatJson(showBody))}</pre>` : emptyState("No connected client was available for details.")}
|
${detail.show ? `<pre class="pre">${escapeHtml(formatJson(showBody))}</pre>` : emptyState("No connected client was available for details.")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runModelCommand(clientId, model, action) {
|
async function runModelCommand(clientId, model, action) {
|
||||||
@@ -664,7 +686,7 @@ function renderApiKeys() {
|
|||||||
pageTitle.textContent = "API Keys";
|
pageTitle.textContent = "API Keys";
|
||||||
pageSubtitle.textContent = "Keys accepted by the proxy token header, bearer auth, query token, and token path.";
|
pageSubtitle.textContent = "Keys accepted by the proxy token header, bearer auth, query token, and token path.";
|
||||||
|
|
||||||
content.innerHTML = `
|
patchContent(`
|
||||||
${state.newKey ? newKeyPanel(state.newKey) : ""}
|
${state.newKey ? newKeyPanel(state.newKey) : ""}
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
@@ -689,7 +711,7 @@ function renderApiKeys() {
|
|||||||
${keys.length ? apiKeysTable(keys) : emptyState("No API keys have been created.")}
|
${keys.length ? apiKeysTable(keys) : emptyState("No API keys have been created.")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function newKeyPanel(key) {
|
function newKeyPanel(key) {
|
||||||
@@ -739,7 +761,7 @@ function renderGroups() {
|
|||||||
pageTitle.textContent = "Groups";
|
pageTitle.textContent = "Groups";
|
||||||
pageSubtitle.textContent = "Manage access groups that control which clients and models API keys can reach.";
|
pageSubtitle.textContent = "Manage access groups that control which clients and models API keys can reach.";
|
||||||
|
|
||||||
content.innerHTML = `
|
patchContent(`
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h2>Create group</h2>
|
<h2>Create group</h2>
|
||||||
@@ -763,7 +785,7 @@ function renderGroups() {
|
|||||||
${groups.length ? groupsTable(groups) : emptyState("No groups have been created.")}
|
${groups.length ? groupsTable(groups) : emptyState("No groups have been created.")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupsTable(groups) {
|
function groupsTable(groups) {
|
||||||
@@ -796,10 +818,12 @@ function groupsTable(groups) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadGroupDetail(groupId) {
|
async function loadGroupDetail(groupId, showLoading = true) {
|
||||||
pageTitle.textContent = "Group Detail";
|
pageTitle.textContent = "Group Detail";
|
||||||
pageSubtitle.textContent = groupId;
|
pageSubtitle.textContent = groupId;
|
||||||
content.innerHTML = `<div class="panel"><div class="empty">Loading group...</div></div>`;
|
if (showLoading) {
|
||||||
|
patchContent(`<div class="panel"><div class="empty">Loading group...</div></div>`);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [group, clients, apiKeyGroups, billing, rules, payments, balance] = await Promise.all([
|
const [group, clients, apiKeyGroups, billing, rules, payments, balance] = await Promise.all([
|
||||||
@@ -815,7 +839,7 @@ async function loadGroupDetail(groupId) {
|
|||||||
state.groupDetail = { group, clients, apiKeyGroups, billing, rules, payments, balance };
|
state.groupDetail = { group, clients, apiKeyGroups, billing, rules, payments, balance };
|
||||||
renderGroupDetail();
|
renderGroupDetail();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
content.innerHTML = `<div class="panel"><div class="empty">${escapeHtml(error.message)}</div></div>`;
|
patchContent(`<div class="panel"><div class="empty">${escapeHtml(error.message)}</div></div>`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,7 +855,7 @@ function renderGroupDetail() {
|
|||||||
pageTitle.textContent = "Group Detail";
|
pageTitle.textContent = "Group Detail";
|
||||||
pageSubtitle.textContent = group.name;
|
pageSubtitle.textContent = group.name;
|
||||||
|
|
||||||
content.innerHTML = `
|
patchContent(`
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<a class="button secondary" href="#groups">Back to groups</a>
|
<a class="button secondary" href="#groups">Back to groups</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -979,7 +1003,7 @@ function renderGroupDetail() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupClientsTable(clients, groupId) {
|
function groupClientsTable(clients, groupId) {
|
||||||
@@ -1143,10 +1167,12 @@ function formatCurrency(value, currency) {
|
|||||||
return formatted;
|
return formatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadUsage() {
|
async function loadUsage(showLoading = true) {
|
||||||
pageTitle.textContent = "Usage";
|
pageTitle.textContent = "Usage";
|
||||||
pageSubtitle.textContent = "Token usage and revenue statistics.";
|
pageSubtitle.textContent = "Token usage and revenue statistics.";
|
||||||
content.innerHTML = `<div class="panel"><div class="empty">Loading usage data...</div></div>`;
|
if (showLoading) {
|
||||||
|
patchContent(`<div class="panel"><div class="empty">Loading usage data...</div></div>`);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [usage, revenue] = await Promise.all([
|
const [usage, revenue] = await Promise.all([
|
||||||
@@ -1157,7 +1183,7 @@ async function loadUsage() {
|
|||||||
state.usageData = { usage, revenue };
|
state.usageData = { usage, revenue };
|
||||||
renderUsage();
|
renderUsage();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
content.innerHTML = `<div class="panel"><div class="empty">${escapeHtml(error.message)}</div></div>`;
|
patchContent(`<div class="panel"><div class="empty">${escapeHtml(error.message)}</div></div>`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1179,7 +1205,7 @@ function renderUsage() {
|
|||||||
revenueMap[r.clientId] = r;
|
revenueMap[r.clientId] = r;
|
||||||
}
|
}
|
||||||
|
|
||||||
content.innerHTML = `
|
patchContent(`
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h2>Tokens by model</h2>
|
<h2>Tokens by model</h2>
|
||||||
@@ -1216,7 +1242,7 @@ function renderUsage() {
|
|||||||
${byGroup.length ? tokenStatsGroupTable(byGroup) : emptyState("No token data yet.")}
|
${byGroup.length ? tokenStatsGroupTable(byGroup) : emptyState("No token data yet.")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function tokenStatsModelTable(stats) {
|
function tokenStatsModelTable(stats) {
|
||||||
@@ -1362,11 +1388,11 @@ async function refreshAfterModelCommand(model, clientId) {
|
|||||||
|
|
||||||
const hash = window.location.hash || "";
|
const hash = window.location.hash || "";
|
||||||
if (hash.startsWith(`#models/${encodeURIComponent(model)}`)) {
|
if (hash.startsWith(`#models/${encodeURIComponent(model)}`)) {
|
||||||
await loadModelDetail(model, clientId);
|
await loadModelDetail(model, clientId, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await renderRoute();
|
await renderRoute(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelActionResultDetail(result) {
|
function modelActionResultDetail(result) {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="/admin/morphdom-umd.js"></script>
|
||||||
<script src="/admin/app.js" defer></script>
|
<script src="/admin/app.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,783 @@
|
|||||||
|
/*!
|
||||||
|
* morphdom
|
||||||
|
* Copyright (c) Patrick Steele-Idem
|
||||||
|
* Licensed under the MIT License.
|
||||||
|
* See /docs/licenses/LICENSE.morphdom
|
||||||
|
*/
|
||||||
|
(function (global, factory) {
|
||||||
|
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
|
||||||
|
typeof define === 'function' && define.amd ? define(factory) :
|
||||||
|
(global = global || self, global.morphdom = factory());
|
||||||
|
}(this, function () { 'use strict';
|
||||||
|
|
||||||
|
var DOCUMENT_FRAGMENT_NODE = 11;
|
||||||
|
|
||||||
|
function morphAttrs(fromNode, toNode) {
|
||||||
|
var toNodeAttrs = toNode.attributes;
|
||||||
|
var attr;
|
||||||
|
var attrName;
|
||||||
|
var attrNamespaceURI;
|
||||||
|
var attrValue;
|
||||||
|
var fromValue;
|
||||||
|
|
||||||
|
// document-fragments dont have attributes so lets not do anything
|
||||||
|
if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE || fromNode.nodeType === DOCUMENT_FRAGMENT_NODE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// update attributes on original DOM element
|
||||||
|
for (var i = toNodeAttrs.length - 1; i >= 0; i--) {
|
||||||
|
attr = toNodeAttrs[i];
|
||||||
|
attrName = attr.name;
|
||||||
|
attrNamespaceURI = attr.namespaceURI;
|
||||||
|
attrValue = attr.value;
|
||||||
|
|
||||||
|
if (attrNamespaceURI) {
|
||||||
|
attrName = attr.localName || attrName;
|
||||||
|
fromValue = fromNode.getAttributeNS(attrNamespaceURI, attrName);
|
||||||
|
|
||||||
|
if (fromValue !== attrValue) {
|
||||||
|
if (attr.prefix === 'xmlns'){
|
||||||
|
attrName = attr.name; // It's not allowed to set an attribute with the XMLNS namespace without specifying the `xmlns` prefix
|
||||||
|
}
|
||||||
|
fromNode.setAttributeNS(attrNamespaceURI, attrName, attrValue);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fromValue = fromNode.getAttribute(attrName);
|
||||||
|
|
||||||
|
if (fromValue !== attrValue) {
|
||||||
|
fromNode.setAttribute(attrName, attrValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any extra attributes found on the original DOM element that
|
||||||
|
// weren't found on the target element.
|
||||||
|
var fromNodeAttrs = fromNode.attributes;
|
||||||
|
|
||||||
|
for (var d = fromNodeAttrs.length - 1; d >= 0; d--) {
|
||||||
|
attr = fromNodeAttrs[d];
|
||||||
|
attrName = attr.name;
|
||||||
|
attrNamespaceURI = attr.namespaceURI;
|
||||||
|
|
||||||
|
if (attrNamespaceURI) {
|
||||||
|
attrName = attr.localName || attrName;
|
||||||
|
|
||||||
|
if (!toNode.hasAttributeNS(attrNamespaceURI, attrName)) {
|
||||||
|
fromNode.removeAttributeNS(attrNamespaceURI, attrName);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!toNode.hasAttribute(attrName)) {
|
||||||
|
fromNode.removeAttribute(attrName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var range; // Create a range object for efficently rendering strings to elements.
|
||||||
|
var NS_XHTML = 'http://www.w3.org/1999/xhtml';
|
||||||
|
|
||||||
|
var doc = typeof document === 'undefined' ? undefined : document;
|
||||||
|
var HAS_TEMPLATE_SUPPORT = !!doc && 'content' in doc.createElement('template');
|
||||||
|
var HAS_RANGE_SUPPORT = !!doc && doc.createRange && 'createContextualFragment' in doc.createRange();
|
||||||
|
|
||||||
|
function createFragmentFromTemplate(str) {
|
||||||
|
var template = doc.createElement('template');
|
||||||
|
template.innerHTML = str;
|
||||||
|
return template.content.childNodes[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFragmentFromRange(str) {
|
||||||
|
if (!range) {
|
||||||
|
range = doc.createRange();
|
||||||
|
range.selectNode(doc.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
var fragment = range.createContextualFragment(str);
|
||||||
|
return fragment.childNodes[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFragmentFromWrap(str) {
|
||||||
|
var fragment = doc.createElement('body');
|
||||||
|
fragment.innerHTML = str;
|
||||||
|
return fragment.childNodes[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is about the same
|
||||||
|
* var html = new DOMParser().parseFromString(str, 'text/html');
|
||||||
|
* return html.body.firstChild;
|
||||||
|
*
|
||||||
|
* @method toElement
|
||||||
|
* @param {String} str
|
||||||
|
*/
|
||||||
|
function toElement(str) {
|
||||||
|
str = str.trim();
|
||||||
|
if (HAS_TEMPLATE_SUPPORT) {
|
||||||
|
// avoid restrictions on content for things like `<tr><th>Hi</th></tr>` which
|
||||||
|
// createContextualFragment doesn't support
|
||||||
|
// <template> support not available in IE
|
||||||
|
return createFragmentFromTemplate(str);
|
||||||
|
} else if (HAS_RANGE_SUPPORT) {
|
||||||
|
return createFragmentFromRange(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createFragmentFromWrap(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if two node's names are the same.
|
||||||
|
*
|
||||||
|
* NOTE: We don't bother checking `namespaceURI` because you will never find two HTML elements with the same
|
||||||
|
* nodeName and different namespace URIs.
|
||||||
|
*
|
||||||
|
* @param {Element} a
|
||||||
|
* @param {Element} b The target element
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
function compareNodeNames(fromEl, toEl) {
|
||||||
|
var fromNodeName = fromEl.nodeName;
|
||||||
|
var toNodeName = toEl.nodeName;
|
||||||
|
var fromCodeStart, toCodeStart;
|
||||||
|
|
||||||
|
if (fromNodeName === toNodeName) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fromCodeStart = fromNodeName.charCodeAt(0);
|
||||||
|
toCodeStart = toNodeName.charCodeAt(0);
|
||||||
|
|
||||||
|
// If the target element is a virtual DOM node or SVG node then we may
|
||||||
|
// need to normalize the tag name before comparing. Normal HTML elements that are
|
||||||
|
// in the "http://www.w3.org/1999/xhtml"
|
||||||
|
// are converted to upper case
|
||||||
|
if (fromCodeStart <= 90 && toCodeStart >= 97) { // from is upper and to is lower
|
||||||
|
return fromNodeName === toNodeName.toUpperCase();
|
||||||
|
} else if (toCodeStart <= 90 && fromCodeStart >= 97) { // to is upper and from is lower
|
||||||
|
return toNodeName === fromNodeName.toUpperCase();
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an element, optionally with a known namespace URI.
|
||||||
|
*
|
||||||
|
* @param {string} name the element name, e.g. 'div' or 'svg'
|
||||||
|
* @param {string} [namespaceURI] the element's namespace URI, i.e. the value of
|
||||||
|
* its `xmlns` attribute or its inferred namespace.
|
||||||
|
*
|
||||||
|
* @return {Element}
|
||||||
|
*/
|
||||||
|
function createElementNS(name, namespaceURI) {
|
||||||
|
return !namespaceURI || namespaceURI === NS_XHTML ?
|
||||||
|
doc.createElement(name) :
|
||||||
|
doc.createElementNS(namespaceURI, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the children of one DOM element to another DOM element
|
||||||
|
*/
|
||||||
|
function moveChildren(fromEl, toEl) {
|
||||||
|
var curChild = fromEl.firstChild;
|
||||||
|
while (curChild) {
|
||||||
|
var nextChild = curChild.nextSibling;
|
||||||
|
toEl.appendChild(curChild);
|
||||||
|
curChild = nextChild;
|
||||||
|
}
|
||||||
|
return toEl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncBooleanAttrProp(fromEl, toEl, name) {
|
||||||
|
if (fromEl[name] !== toEl[name]) {
|
||||||
|
fromEl[name] = toEl[name];
|
||||||
|
if (fromEl[name]) {
|
||||||
|
fromEl.setAttribute(name, '');
|
||||||
|
} else {
|
||||||
|
fromEl.removeAttribute(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var specialElHandlers = {
|
||||||
|
OPTION: function(fromEl, toEl) {
|
||||||
|
var parentNode = fromEl.parentNode;
|
||||||
|
if (parentNode) {
|
||||||
|
var parentName = parentNode.nodeName.toUpperCase();
|
||||||
|
if (parentName === 'OPTGROUP') {
|
||||||
|
parentNode = parentNode.parentNode;
|
||||||
|
parentName = parentNode && parentNode.nodeName.toUpperCase();
|
||||||
|
}
|
||||||
|
if (parentName === 'SELECT' && !parentNode.hasAttribute('multiple')) {
|
||||||
|
if (fromEl.hasAttribute('selected') && !toEl.selected) {
|
||||||
|
// Workaround for MS Edge bug where the 'selected' attribute can only be
|
||||||
|
// removed if set to a non-empty value:
|
||||||
|
// https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/12087679/
|
||||||
|
fromEl.setAttribute('selected', 'selected');
|
||||||
|
fromEl.removeAttribute('selected');
|
||||||
|
}
|
||||||
|
// We have to reset select element's selectedIndex to -1, otherwise setting
|
||||||
|
// fromEl.selected using the syncBooleanAttrProp below has no effect.
|
||||||
|
// The correct selectedIndex will be set in the SELECT special handler below.
|
||||||
|
parentNode.selectedIndex = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
syncBooleanAttrProp(fromEl, toEl, 'selected');
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* The "value" attribute is special for the <input> element since it sets
|
||||||
|
* the initial value. Changing the "value" attribute without changing the
|
||||||
|
* "value" property will have no effect since it is only used to the set the
|
||||||
|
* initial value. Similar for the "checked" attribute, and "disabled".
|
||||||
|
*/
|
||||||
|
INPUT: function(fromEl, toEl) {
|
||||||
|
syncBooleanAttrProp(fromEl, toEl, 'checked');
|
||||||
|
syncBooleanAttrProp(fromEl, toEl, 'disabled');
|
||||||
|
|
||||||
|
if (fromEl.value !== toEl.value) {
|
||||||
|
fromEl.value = toEl.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!toEl.hasAttribute('value')) {
|
||||||
|
fromEl.removeAttribute('value');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
TEXTAREA: function(fromEl, toEl) {
|
||||||
|
var newValue = toEl.value;
|
||||||
|
if (fromEl.value !== newValue) {
|
||||||
|
fromEl.value = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstChild = fromEl.firstChild;
|
||||||
|
if (firstChild) {
|
||||||
|
// Needed for IE. Apparently IE sets the placeholder as the
|
||||||
|
// node value and vise versa. This ignores an empty update.
|
||||||
|
var oldValue = firstChild.nodeValue;
|
||||||
|
|
||||||
|
if (oldValue == newValue || (!newValue && oldValue == fromEl.placeholder)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
firstChild.nodeValue = newValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
SELECT: function(fromEl, toEl) {
|
||||||
|
if (!toEl.hasAttribute('multiple')) {
|
||||||
|
var selectedIndex = -1;
|
||||||
|
var i = 0;
|
||||||
|
// We have to loop through children of fromEl, not toEl since nodes can be moved
|
||||||
|
// from toEl to fromEl directly when morphing.
|
||||||
|
// At the time this special handler is invoked, all children have already been morphed
|
||||||
|
// and appended to / removed from fromEl, so using fromEl here is safe and correct.
|
||||||
|
var curChild = fromEl.firstChild;
|
||||||
|
var optgroup;
|
||||||
|
var nodeName;
|
||||||
|
while(curChild) {
|
||||||
|
nodeName = curChild.nodeName && curChild.nodeName.toUpperCase();
|
||||||
|
if (nodeName === 'OPTGROUP') {
|
||||||
|
optgroup = curChild;
|
||||||
|
curChild = optgroup.firstChild;
|
||||||
|
} else {
|
||||||
|
if (nodeName === 'OPTION') {
|
||||||
|
if (curChild.hasAttribute('selected')) {
|
||||||
|
selectedIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
curChild = curChild.nextSibling;
|
||||||
|
if (!curChild && optgroup) {
|
||||||
|
curChild = optgroup.nextSibling;
|
||||||
|
optgroup = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fromEl.selectedIndex = selectedIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var ELEMENT_NODE = 1;
|
||||||
|
var DOCUMENT_FRAGMENT_NODE$1 = 11;
|
||||||
|
var TEXT_NODE = 3;
|
||||||
|
var COMMENT_NODE = 8;
|
||||||
|
|
||||||
|
function noop() {}
|
||||||
|
|
||||||
|
function defaultGetNodeKey(node) {
|
||||||
|
if (node) {
|
||||||
|
return (node.getAttribute && node.getAttribute('id')) || node.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function morphdomFactory(morphAttrs) {
|
||||||
|
|
||||||
|
return function morphdom(fromNode, toNode, options) {
|
||||||
|
if (!options) {
|
||||||
|
options = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof toNode === 'string') {
|
||||||
|
if (fromNode.nodeName === '#document' || fromNode.nodeName === 'HTML' || fromNode.nodeName === 'BODY') {
|
||||||
|
var toNodeHtml = toNode;
|
||||||
|
toNode = doc.createElement('html');
|
||||||
|
toNode.innerHTML = toNodeHtml;
|
||||||
|
} else {
|
||||||
|
toNode = toElement(toNode);
|
||||||
|
}
|
||||||
|
} else if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE$1) {
|
||||||
|
toNode = toNode.firstElementChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
var getNodeKey = options.getNodeKey || defaultGetNodeKey;
|
||||||
|
var onBeforeNodeAdded = options.onBeforeNodeAdded || noop;
|
||||||
|
var onNodeAdded = options.onNodeAdded || noop;
|
||||||
|
var onBeforeElUpdated = options.onBeforeElUpdated || noop;
|
||||||
|
var onElUpdated = options.onElUpdated || noop;
|
||||||
|
var onBeforeNodeDiscarded = options.onBeforeNodeDiscarded || noop;
|
||||||
|
var onNodeDiscarded = options.onNodeDiscarded || noop;
|
||||||
|
var onBeforeElChildrenUpdated = options.onBeforeElChildrenUpdated || noop;
|
||||||
|
var skipFromChildren = options.skipFromChildren || noop;
|
||||||
|
var addChild = options.addChild || function(parent, child){ return parent.appendChild(child); };
|
||||||
|
var childrenOnly = options.childrenOnly === true;
|
||||||
|
|
||||||
|
// This object is used as a lookup to quickly find all keyed elements in the original DOM tree.
|
||||||
|
var fromNodesLookup = Object.create(null);
|
||||||
|
var keyedRemovalList = [];
|
||||||
|
|
||||||
|
function addKeyedRemoval(key) {
|
||||||
|
keyedRemovalList.push(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkDiscardedChildNodes(node, skipKeyedNodes) {
|
||||||
|
if (node.nodeType === ELEMENT_NODE) {
|
||||||
|
var curChild = node.firstChild;
|
||||||
|
while (curChild) {
|
||||||
|
|
||||||
|
var key = undefined;
|
||||||
|
|
||||||
|
if (skipKeyedNodes && (key = getNodeKey(curChild))) {
|
||||||
|
// If we are skipping keyed nodes then we add the key
|
||||||
|
// to a list so that it can be handled at the very end.
|
||||||
|
addKeyedRemoval(key);
|
||||||
|
} else {
|
||||||
|
// Only report the node as discarded if it is not keyed. We do this because
|
||||||
|
// at the end we loop through all keyed elements that were unmatched
|
||||||
|
// and then discard them in one final pass.
|
||||||
|
onNodeDiscarded(curChild);
|
||||||
|
if (curChild.firstChild) {
|
||||||
|
walkDiscardedChildNodes(curChild, skipKeyedNodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
curChild = curChild.nextSibling;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a DOM node out of the original DOM
|
||||||
|
*
|
||||||
|
* @param {Node} node The node to remove
|
||||||
|
* @param {Node} parentNode The nodes parent
|
||||||
|
* @param {Boolean} skipKeyedNodes If true then elements with keys will be skipped and not discarded.
|
||||||
|
* @return {undefined}
|
||||||
|
*/
|
||||||
|
function removeNode(node, parentNode, skipKeyedNodes) {
|
||||||
|
if (onBeforeNodeDiscarded(node) === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentNode) {
|
||||||
|
parentNode.removeChild(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodeDiscarded(node);
|
||||||
|
walkDiscardedChildNodes(node, skipKeyedNodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// // TreeWalker implementation is no faster, but keeping this around in case this changes in the future
|
||||||
|
// function indexTree(root) {
|
||||||
|
// var treeWalker = document.createTreeWalker(
|
||||||
|
// root,
|
||||||
|
// NodeFilter.SHOW_ELEMENT);
|
||||||
|
//
|
||||||
|
// var el;
|
||||||
|
// while((el = treeWalker.nextNode())) {
|
||||||
|
// var key = getNodeKey(el);
|
||||||
|
// if (key) {
|
||||||
|
// fromNodesLookup[key] = el;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // NodeIterator implementation is no faster, but keeping this around in case this changes in the future
|
||||||
|
//
|
||||||
|
// function indexTree(node) {
|
||||||
|
// var nodeIterator = document.createNodeIterator(node, NodeFilter.SHOW_ELEMENT);
|
||||||
|
// var el;
|
||||||
|
// while((el = nodeIterator.nextNode())) {
|
||||||
|
// var key = getNodeKey(el);
|
||||||
|
// if (key) {
|
||||||
|
// fromNodesLookup[key] = el;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
function indexTree(node) {
|
||||||
|
if (node.nodeType === ELEMENT_NODE || node.nodeType === DOCUMENT_FRAGMENT_NODE$1) {
|
||||||
|
var curChild = node.firstChild;
|
||||||
|
while (curChild) {
|
||||||
|
var key = getNodeKey(curChild);
|
||||||
|
if (key) {
|
||||||
|
fromNodesLookup[key] = curChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk recursively
|
||||||
|
indexTree(curChild);
|
||||||
|
|
||||||
|
curChild = curChild.nextSibling;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
indexTree(fromNode);
|
||||||
|
|
||||||
|
function handleNodeAdded(el) {
|
||||||
|
onNodeAdded(el);
|
||||||
|
|
||||||
|
var curChild = el.firstChild;
|
||||||
|
while (curChild) {
|
||||||
|
var nextSibling = curChild.nextSibling;
|
||||||
|
|
||||||
|
var key = getNodeKey(curChild);
|
||||||
|
if (key) {
|
||||||
|
var unmatchedFromEl = fromNodesLookup[key];
|
||||||
|
// if we find a duplicate #id node in cache, replace `el` with cache value
|
||||||
|
// and morph it to the child node.
|
||||||
|
if (unmatchedFromEl && compareNodeNames(curChild, unmatchedFromEl)) {
|
||||||
|
curChild.parentNode.replaceChild(unmatchedFromEl, curChild);
|
||||||
|
morphEl(unmatchedFromEl, curChild);
|
||||||
|
} else {
|
||||||
|
handleNodeAdded(curChild);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// recursively call for curChild and it's children to see if we find something in
|
||||||
|
// fromNodesLookup
|
||||||
|
handleNodeAdded(curChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
curChild = nextSibling;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupFromEl(fromEl, curFromNodeChild, curFromNodeKey) {
|
||||||
|
// We have processed all of the "to nodes". If curFromNodeChild is
|
||||||
|
// non-null then we still have some from nodes left over that need
|
||||||
|
// to be removed
|
||||||
|
while (curFromNodeChild) {
|
||||||
|
var fromNextSibling = curFromNodeChild.nextSibling;
|
||||||
|
if ((curFromNodeKey = getNodeKey(curFromNodeChild))) {
|
||||||
|
// Since the node is keyed it might be matched up later so we defer
|
||||||
|
// the actual removal to later
|
||||||
|
addKeyedRemoval(curFromNodeKey);
|
||||||
|
} else {
|
||||||
|
// NOTE: we skip nested keyed nodes from being removed since there is
|
||||||
|
// still a chance they will be matched up later
|
||||||
|
removeNode(curFromNodeChild, fromEl, true /* skip keyed nodes */);
|
||||||
|
}
|
||||||
|
curFromNodeChild = fromNextSibling;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function morphEl(fromEl, toEl, childrenOnly) {
|
||||||
|
var toElKey = getNodeKey(toEl);
|
||||||
|
|
||||||
|
if (toElKey) {
|
||||||
|
// If an element with an ID is being morphed then it will be in the final
|
||||||
|
// DOM so clear it out of the saved elements collection
|
||||||
|
delete fromNodesLookup[toElKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!childrenOnly) {
|
||||||
|
// optional
|
||||||
|
var beforeUpdateResult = onBeforeElUpdated(fromEl, toEl);
|
||||||
|
if (beforeUpdateResult === false) {
|
||||||
|
return;
|
||||||
|
} else if (beforeUpdateResult instanceof HTMLElement) {
|
||||||
|
fromEl = beforeUpdateResult;
|
||||||
|
// reindex the new fromEl in case it's not in the same
|
||||||
|
// tree as the original fromEl
|
||||||
|
// (Phoenix LiveView sometimes returns a cloned tree,
|
||||||
|
// but keyed lookups would still point to the original tree)
|
||||||
|
indexTree(fromEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// update attributes on original DOM element first
|
||||||
|
morphAttrs(fromEl, toEl);
|
||||||
|
// optional
|
||||||
|
onElUpdated(fromEl);
|
||||||
|
|
||||||
|
if (onBeforeElChildrenUpdated(fromEl, toEl) === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromEl.nodeName !== 'TEXTAREA') {
|
||||||
|
morphChildren(fromEl, toEl);
|
||||||
|
} else {
|
||||||
|
specialElHandlers.TEXTAREA(fromEl, toEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function morphChildren(fromEl, toEl) {
|
||||||
|
var skipFrom = skipFromChildren(fromEl, toEl);
|
||||||
|
var curToNodeChild = toEl.firstChild;
|
||||||
|
var curFromNodeChild = fromEl.firstChild;
|
||||||
|
var curToNodeKey;
|
||||||
|
var curFromNodeKey;
|
||||||
|
|
||||||
|
var fromNextSibling;
|
||||||
|
var toNextSibling;
|
||||||
|
var matchingFromEl;
|
||||||
|
|
||||||
|
// walk the children
|
||||||
|
outer: while (curToNodeChild) {
|
||||||
|
toNextSibling = curToNodeChild.nextSibling;
|
||||||
|
curToNodeKey = getNodeKey(curToNodeChild);
|
||||||
|
|
||||||
|
// walk the fromNode children all the way through
|
||||||
|
while (!skipFrom && curFromNodeChild) {
|
||||||
|
fromNextSibling = curFromNodeChild.nextSibling;
|
||||||
|
|
||||||
|
if (curToNodeChild.isSameNode && curToNodeChild.isSameNode(curFromNodeChild)) {
|
||||||
|
curToNodeChild = toNextSibling;
|
||||||
|
curFromNodeChild = fromNextSibling;
|
||||||
|
continue outer;
|
||||||
|
}
|
||||||
|
|
||||||
|
curFromNodeKey = getNodeKey(curFromNodeChild);
|
||||||
|
|
||||||
|
var curFromNodeType = curFromNodeChild.nodeType;
|
||||||
|
|
||||||
|
// this means if the curFromNodeChild doesnt have a match with the curToNodeChild
|
||||||
|
var isCompatible = undefined;
|
||||||
|
|
||||||
|
if (curFromNodeType === curToNodeChild.nodeType) {
|
||||||
|
if (curFromNodeType === ELEMENT_NODE) {
|
||||||
|
// Both nodes being compared are Element nodes
|
||||||
|
|
||||||
|
if (curToNodeKey) {
|
||||||
|
// The target node has a key so we want to match it up with the correct element
|
||||||
|
// in the original DOM tree
|
||||||
|
if (curToNodeKey !== curFromNodeKey) {
|
||||||
|
// The current element in the original DOM tree does not have a matching key so
|
||||||
|
// let's check our lookup to see if there is a matching element in the original
|
||||||
|
// DOM tree
|
||||||
|
if ((matchingFromEl = fromNodesLookup[curToNodeKey])) {
|
||||||
|
if (fromNextSibling === matchingFromEl) {
|
||||||
|
// Special case for single element removals. To avoid removing the original
|
||||||
|
// DOM node out of the tree (since that can break CSS transitions, etc.),
|
||||||
|
// we will instead discard the current node and wait until the next
|
||||||
|
// iteration to properly match up the keyed target element with its matching
|
||||||
|
// element in the original tree
|
||||||
|
isCompatible = false;
|
||||||
|
} else {
|
||||||
|
// We found a matching keyed element somewhere in the original DOM tree.
|
||||||
|
// Let's move the original DOM node into the current position and morph
|
||||||
|
// it.
|
||||||
|
|
||||||
|
// NOTE: We use insertBefore instead of replaceChild because we want to go through
|
||||||
|
// the `removeNode()` function for the node that is being discarded so that
|
||||||
|
// all lifecycle hooks are correctly invoked
|
||||||
|
fromEl.insertBefore(matchingFromEl, curFromNodeChild);
|
||||||
|
|
||||||
|
// fromNextSibling = curFromNodeChild.nextSibling;
|
||||||
|
|
||||||
|
if (curFromNodeKey) {
|
||||||
|
// Since the node is keyed it might be matched up later so we defer
|
||||||
|
// the actual removal to later
|
||||||
|
addKeyedRemoval(curFromNodeKey);
|
||||||
|
} else {
|
||||||
|
// NOTE: we skip nested keyed nodes from being removed since there is
|
||||||
|
// still a chance they will be matched up later
|
||||||
|
removeNode(curFromNodeChild, fromEl, true /* skip keyed nodes */);
|
||||||
|
}
|
||||||
|
|
||||||
|
curFromNodeChild = matchingFromEl;
|
||||||
|
curFromNodeKey = getNodeKey(curFromNodeChild);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// The nodes are not compatible since the "to" node has a key and there
|
||||||
|
// is no matching keyed node in the source tree
|
||||||
|
isCompatible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (curFromNodeKey) {
|
||||||
|
// The original has a key
|
||||||
|
isCompatible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isCompatible = isCompatible !== false && compareNodeNames(curFromNodeChild, curToNodeChild);
|
||||||
|
if (isCompatible) {
|
||||||
|
// We found compatible DOM elements so transform
|
||||||
|
// the current "from" node to match the current
|
||||||
|
// target DOM node.
|
||||||
|
// MORPH
|
||||||
|
morphEl(curFromNodeChild, curToNodeChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (curFromNodeType === TEXT_NODE || curFromNodeType == COMMENT_NODE) {
|
||||||
|
// Both nodes being compared are Text or Comment nodes
|
||||||
|
isCompatible = true;
|
||||||
|
// Simply update nodeValue on the original node to
|
||||||
|
// change the text value
|
||||||
|
if (curFromNodeChild.nodeValue !== curToNodeChild.nodeValue) {
|
||||||
|
curFromNodeChild.nodeValue = curToNodeChild.nodeValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCompatible) {
|
||||||
|
// Advance both the "to" child and the "from" child since we found a match
|
||||||
|
// Nothing else to do as we already recursively called morphChildren above
|
||||||
|
curToNodeChild = toNextSibling;
|
||||||
|
curFromNodeChild = fromNextSibling;
|
||||||
|
continue outer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No compatible match so remove the old node from the DOM and continue trying to find a
|
||||||
|
// match in the original DOM. However, we only do this if the from node is not keyed
|
||||||
|
// since it is possible that a keyed node might match up with a node somewhere else in the
|
||||||
|
// target tree and we don't want to discard it just yet since it still might find a
|
||||||
|
// home in the final DOM tree. After everything is done we will remove any keyed nodes
|
||||||
|
// that didn't find a home
|
||||||
|
if (curFromNodeKey) {
|
||||||
|
// Since the node is keyed it might be matched up later so we defer
|
||||||
|
// the actual removal to later
|
||||||
|
addKeyedRemoval(curFromNodeKey);
|
||||||
|
} else {
|
||||||
|
// NOTE: we skip nested keyed nodes from being removed since there is
|
||||||
|
// still a chance they will be matched up later
|
||||||
|
removeNode(curFromNodeChild, fromEl, true /* skip keyed nodes */);
|
||||||
|
}
|
||||||
|
|
||||||
|
curFromNodeChild = fromNextSibling;
|
||||||
|
} // END: while(curFromNodeChild) {}
|
||||||
|
|
||||||
|
// If we got this far then we did not find a candidate match for
|
||||||
|
// our "to node" and we exhausted all of the children "from"
|
||||||
|
// nodes. Therefore, we will just append the current "to" node
|
||||||
|
// to the end
|
||||||
|
if (curToNodeKey && (matchingFromEl = fromNodesLookup[curToNodeKey]) && compareNodeNames(matchingFromEl, curToNodeChild)) {
|
||||||
|
// MORPH
|
||||||
|
if(!skipFrom){ addChild(fromEl, matchingFromEl); }
|
||||||
|
morphEl(matchingFromEl, curToNodeChild);
|
||||||
|
} else {
|
||||||
|
var onBeforeNodeAddedResult = onBeforeNodeAdded(curToNodeChild);
|
||||||
|
if (onBeforeNodeAddedResult !== false) {
|
||||||
|
if (onBeforeNodeAddedResult) {
|
||||||
|
curToNodeChild = onBeforeNodeAddedResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (curToNodeChild.actualize) {
|
||||||
|
curToNodeChild = curToNodeChild.actualize(fromEl.ownerDocument || doc);
|
||||||
|
}
|
||||||
|
addChild(fromEl, curToNodeChild);
|
||||||
|
handleNodeAdded(curToNodeChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
curToNodeChild = toNextSibling;
|
||||||
|
curFromNodeChild = fromNextSibling;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupFromEl(fromEl, curFromNodeChild, curFromNodeKey);
|
||||||
|
|
||||||
|
var specialElHandler = specialElHandlers[fromEl.nodeName];
|
||||||
|
if (specialElHandler) {
|
||||||
|
specialElHandler(fromEl, toEl);
|
||||||
|
}
|
||||||
|
} // END: morphChildren(...)
|
||||||
|
|
||||||
|
var morphedNode = fromNode;
|
||||||
|
var morphedNodeType = morphedNode.nodeType;
|
||||||
|
var toNodeType = toNode.nodeType;
|
||||||
|
|
||||||
|
if (!childrenOnly) {
|
||||||
|
// Handle the case where we are given two DOM nodes that are not
|
||||||
|
// compatible (e.g. <div> --> <span> or <div> --> TEXT)
|
||||||
|
if (morphedNodeType === ELEMENT_NODE) {
|
||||||
|
if (toNodeType === ELEMENT_NODE) {
|
||||||
|
if (!compareNodeNames(fromNode, toNode)) {
|
||||||
|
onNodeDiscarded(fromNode);
|
||||||
|
morphedNode = moveChildren(fromNode, createElementNS(toNode.nodeName, toNode.namespaceURI));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Going from an element node to a text node
|
||||||
|
morphedNode = toNode;
|
||||||
|
}
|
||||||
|
} else if (morphedNodeType === TEXT_NODE || morphedNodeType === COMMENT_NODE) { // Text or comment node
|
||||||
|
if (toNodeType === morphedNodeType) {
|
||||||
|
if (morphedNode.nodeValue !== toNode.nodeValue) {
|
||||||
|
morphedNode.nodeValue = toNode.nodeValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return morphedNode;
|
||||||
|
} else {
|
||||||
|
// Text node to something else
|
||||||
|
morphedNode = toNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (morphedNode === toNode) {
|
||||||
|
// The "to node" was not compatible with the "from node" so we had to
|
||||||
|
// toss out the "from node" and use the "to node"
|
||||||
|
onNodeDiscarded(fromNode);
|
||||||
|
} else {
|
||||||
|
if (toNode.isSameNode && toNode.isSameNode(morphedNode)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
morphEl(morphedNode, toNode, childrenOnly);
|
||||||
|
|
||||||
|
// We now need to loop over any keyed nodes that might need to be
|
||||||
|
// removed. We only do the removal if we know that the keyed node
|
||||||
|
// never found a match. When a keyed node is matched up we remove
|
||||||
|
// it out of fromNodesLookup and we use fromNodesLookup to determine
|
||||||
|
// if a keyed node has been matched up or not
|
||||||
|
if (keyedRemovalList) {
|
||||||
|
for (var i=0, len=keyedRemovalList.length; i<len; i++) {
|
||||||
|
var elToRemove = fromNodesLookup[keyedRemovalList[i]];
|
||||||
|
if (elToRemove) {
|
||||||
|
removeNode(elToRemove, elToRemove.parentNode, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!childrenOnly && morphedNode !== fromNode && fromNode.parentNode) {
|
||||||
|
if (morphedNode.actualize) {
|
||||||
|
morphedNode = morphedNode.actualize(fromNode.ownerDocument || doc);
|
||||||
|
}
|
||||||
|
// If we had to swap out the from node with a new node because the old
|
||||||
|
// node was not compatible with the target node then we need to
|
||||||
|
// replace the old DOM node in the original DOM tree. This is only
|
||||||
|
// possible if the original DOM node was part of a DOM tree which
|
||||||
|
// we know is the case if it has a parent node.
|
||||||
|
fromNode.parentNode.replaceChild(morphedNode, fromNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return morphedNode;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var morphdom = morphdomFactory(morphAttrs);
|
||||||
|
|
||||||
|
return morphdom;
|
||||||
|
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user