From d23d5bc6dae8035357f1dea088392ab3d2546f8e Mon Sep 17 00:00:00 2001 From: tdro Date: Mon, 12 Feb 2024 20:15:17 -0500 Subject: static/js: Add custom DOM filter Fallback to native navigation if transition in 300ms is not guaranteed --- assets/js/index.js | 102 ++++++++++++++++++++++++++++++++++ static/js/domfilter.ts | 145 +++++++++++++++++++++++++++++++++++++++++++++++++ static/js/index.ts | 1 + 3 files changed, 248 insertions(+) create mode 100644 static/js/domfilter.ts diff --git a/assets/js/index.js b/assets/js/index.js index 91d3e61..15f720a 100644 --- a/assets/js/index.js +++ b/assets/js/index.js @@ -751,6 +751,108 @@ }); }); })(); +(function() { + const timeout = 300; + const state = "on"; + const key = "config.speed.fast"; + function fetch(url, method, callback) { + const http = new XMLHttpRequest(); + http.onreadystatechange = function() { + if (callback && http.readyState === 4) { + if (http.status === 200) callback(http); + else { + console.error("ERROR: Unable to navigate", http); + self.location.href = url; + } + } + }; + http.open(method, url); + http.timeout = timeout; + http.send(); + return http; + } + function styles(node) { + return node.nodeName === "LINK" && node.rel && node.rel.includes("stylesheet"); + } + function scripts(node) { + return node.nodeName === "SCRIPT" && node.hasAttribute("src"); + } + function filter(url, http) { + let live = document; + let node = live.head.childNodes.length; + let next = new DOMParser().parseFromString(http.responseText, "text/html"); + if (next.doctype === null || !http.getResponseHeader("content-type").includes("text/html")) return false; + while(node--){ + if (styles(live.head.childNodes[node]) || scripts(live.head.childNodes[node])) {} else { + live.head.removeChild(live.head.childNodes[node]); + } + } + while(next.head.firstChild){ + if (styles(next.head.firstChild) || scripts(next.head.firstChild)) { + next.head.removeChild(next.head.firstChild); + } else { + live.head.appendChild(next.head.firstChild); + } + } + while(live.documentElement.attributes.length > 0){ + live.documentElement.removeAttribute(live.documentElement.attributes[live.documentElement.attributes.length - 1].name); + } + while(next.documentElement.attributes.length > 0){ + live.documentElement.setAttribute(next.documentElement.attributes[next.documentElement.attributes.length - 1].name, next.documentElement.attributes[next.documentElement.attributes.length - 1].value); + next.documentElement.removeAttribute(next.documentElement.attributes[next.documentElement.attributes.length - 1].name); + } + live.body.parentElement.replaceChild(next.body, live.body); + } + function persist() { + let persist = document.createElement("link"); + persist.rel = "location"; + persist.target = JSON.stringify(self.location); + document.head.appendChild(persist); + } + self.addEventListener("DOMContentLoaded", function() { + if (localStorage[key] !== state) return; + persist(); + }); + self.addEventListener("popstate", function(event) { + if (localStorage[key] !== state) return; + const link = JSON.parse(document.querySelector('link[rel="location"]').target); + const url = event.target.location; + const hashed = link.pathname === url.pathname; + if (hashed) return; + fetch(url, "GET", function(http) { + filter(url.href, http); + persist(); + self.document.dispatchEvent(new CustomEvent("URLChangedCustomEvent", { + bubbles: true + })); + }); + }); + self.addEventListener("click", function(event) { + if (localStorage[key] !== state) return; + const links = document.querySelectorAll("a"); + for(let i1 = 0; i1 < links.length; i1++){ + const active = links[i1].contains(event.target); + const change = self.location.href !== links[i1].href; + const view = links[i1].attributes.hasOwnProperty("download") === false; + const local = self.location.origin === links[i1].origin && links[i1].target !== "_self"; + const hashed = self.location.pathname === links[i1].pathname && links[i1].href.includes("#"); + if (active && local && change && view && hashed === false) { + event.preventDefault(); + const url = links[i1].href; + links[i1].style.cursor = "wait"; + fetch(url, "GET", function(http) { + links[i1].removeAttribute("style"); + if (filter(url, http) === false) return self.location.href = url; + history.pushState({}, "", links[i1].href); + persist(); + self.document.dispatchEvent(new CustomEvent("URLChangedCustomEvent", { + bubbles: true + })); + }); + } + } + }); +})(); (function() { const relative = new Intl.RelativeTimeFormat("en", { localeMatcher: "best fit", diff --git a/static/js/domfilter.ts b/static/js/domfilter.ts new file mode 100644 index 0000000..a81529b --- /dev/null +++ b/static/js/domfilter.ts @@ -0,0 +1,145 @@ +/** + * DOM Filter Copyright (C) 2024 Thedro Neely + * License: AGPL | https://www.gnu.org/licenses/agpl-3.0.txt + */ + +(function () { + type millisecond = number; + const timeout: millisecond = 300; + + const state = "on"; + const key = "config.speed.fast"; + + function fetch(url, method, callback) { + const http = new XMLHttpRequest(); + http.onreadystatechange = function () { + if (callback && http.readyState === 4) { + if (http.status === 200) callback(http); + else { + console.error("ERROR: Unable to navigate", http); + self.location.href = url; + } + } + }; + http.open(method, url); + http.timeout = timeout; + http.send(); + return http; + } + + function styles(node) { + return (node.nodeName === "LINK") && node.rel && node.rel.includes("stylesheet"); + } + + function scripts(node) { + return (node.nodeName === "SCRIPT") && node.hasAttribute("src"); + } + + function filter(url, http) { + let live = document; + let node = live.head.childNodes.length; + let next = (new DOMParser()).parseFromString(http.responseText, "text/html"); + + if (next.doctype === null || !http.getResponseHeader("content-type").includes("text/html")) return false; + + while (node--) { + if (styles(live.head.childNodes[node]) || scripts(live.head.childNodes[node])) { + // console.log("INFO: Persist:", live.head.childNodes[node]); + } else { + live.head.removeChild(live.head.childNodes[node]); + } + } + + while (next.head.firstChild) { + if (styles(next.head.firstChild) || scripts(next.head.firstChild)) { + next.head.removeChild(next.head.firstChild); + } else { + live.head.appendChild(next.head.firstChild); + } + } + + while (live.documentElement.attributes.length > 0) { + live.documentElement.removeAttribute( + live.documentElement.attributes[live.documentElement.attributes.length - 1].name, + ); + } + + while (next.documentElement.attributes.length > 0) { + live.documentElement.setAttribute( + next.documentElement.attributes[next.documentElement.attributes.length - 1].name, + next.documentElement.attributes[next.documentElement.attributes.length - 1].value, + ); + next.documentElement.removeAttribute( + next.documentElement.attributes[next.documentElement.attributes.length - 1].name, + ); + } + + live.body.parentElement.replaceChild(next.body, live.body); + } + + function persist() { + let persist = document.createElement("link"); + persist.rel = "location"; + persist.target = JSON.stringify(self.location); + document.head.appendChild(persist); + } + + self.addEventListener("DOMContentLoaded", function () { + if (localStorage[key] !== state) return; + persist(); + }); + + self.addEventListener("popstate", function (event) { + if (localStorage[key] !== state) return; + const link = JSON.parse(document.querySelector('link[rel="location"]').target); + const url = event.target.location; + const hashed = link.pathname === url.pathname; + if (hashed) return; + fetch(url, "GET", function (http) { + filter(url.href, http); + persist(); + self.document.dispatchEvent(new CustomEvent("URLChangedCustomEvent", { bubbles: true })); + }); + }); + + self.addEventListener("click", function (event) { + if (localStorage[key] !== state) return; + const links = document.querySelectorAll("a"); + for (let i = 0; i < links.length; i++) { + const active = links[i].contains(event.target); + const change = self.location.href !== links[i].href; + const view = links[i].attributes.hasOwnProperty("download") === false; + const local = self.location.origin === links[i].origin && links[i].target !== "_self"; + const hashed = self.location.pathname === links[i].pathname && links[i].href.includes("#"); + if (active && local && change && view && (hashed === false)) { + event.preventDefault(); + const url = links[i].href; + links[i].style.cursor = "wait"; + fetch(url, "GET", function (http) { + links[i].removeAttribute("style") + if (filter(url, http) === false) return self.location.href = url; + history.pushState({}, "", links[i].href); + persist(); + self.document.dispatchEvent(new CustomEvent("URLChangedCustomEvent", { bubbles: true })); + }); + } + } + }); +})(); + +/* + * Copyright (C) 2024 Thedro Neely + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . +*/ diff --git a/static/js/index.ts b/static/js/index.ts index 4b0951c..517445c 100644 --- a/static/js/index.ts +++ b/static/js/index.ts @@ -7,6 +7,7 @@ import "./fixedsearch.ts"; import "./autoplay.ts"; import "./hoverfix.ts"; import "./forms.ts"; +import "./domfilter.ts"; import "./timeago.ts"; console.log("INFO: Surface Control Complete"); -- cgit v1.2.3