forked from OpenWF/SpaceNinjaServer
318 lines
10 KiB
JavaScript
318 lines
10 KiB
JavaScript
(function () {
|
|
let head_include = document.body == null,
|
|
style = document.createElement("style");
|
|
style.textContent = `[data-route]:not(.route-visible){display:none}`;
|
|
document.head.appendChild(style);
|
|
|
|
class EventEmitter {
|
|
constructor() {
|
|
this.event_handlers = {};
|
|
}
|
|
|
|
on(event_name, func) {
|
|
if (typeof func != "function") {
|
|
throw "Event handler has to be a function.";
|
|
}
|
|
this.event_handlers[event_name] = func;
|
|
return this;
|
|
}
|
|
|
|
off(event_name) {
|
|
delete this.event_handlers[event_name];
|
|
return this;
|
|
}
|
|
|
|
fire(event_name, args) {
|
|
if (event_name in this.event_handlers) {
|
|
this.event_handlers[event_name].call(this, args);
|
|
}
|
|
return this;
|
|
}
|
|
}
|
|
|
|
class Route extends EventEmitter {
|
|
constructor(overlay, elm, paths) {
|
|
super();
|
|
this.overlay = overlay;
|
|
this.elm = elm;
|
|
this.paths = paths;
|
|
this.title = undefined;
|
|
if (elm.hasAttribute("data-title")) {
|
|
this.title = this.elm.getAttribute("data-title");
|
|
this.elm.removeAttribute("data-title");
|
|
} else if (document.querySelector("title") != null) {
|
|
this.title = document.querySelector("title").textContent;
|
|
} else {
|
|
this.title = this.paths[0];
|
|
}
|
|
}
|
|
|
|
get element() {
|
|
return this.elm;
|
|
}
|
|
|
|
isCurrent() {
|
|
return this.elm.classList.contains("route-current");
|
|
}
|
|
|
|
isVisible() {
|
|
return this.elm.classList.contains("route-visible");
|
|
}
|
|
}
|
|
|
|
class MultiRoute extends Route {
|
|
constructor(overlay, elm, paths_data) {
|
|
if (overlay) {
|
|
paths_data = paths_data.substr(9);
|
|
}
|
|
let paths = [];
|
|
paths_data.split(",").forEach(name => {
|
|
paths.push(name.trim());
|
|
});
|
|
super(overlay, elm, paths);
|
|
}
|
|
|
|
getCanonicalPath() {
|
|
if (this.paths[0].substr(0, 1) == "/") {
|
|
return this.paths[0];
|
|
}
|
|
if (this.paths.length > 1) {
|
|
return this.paths[1];
|
|
}
|
|
return "/" + this.paths[0];
|
|
}
|
|
}
|
|
|
|
class StandardRoute extends MultiRoute {
|
|
constructor(overlay, elm, paths) {
|
|
super(overlay, elm, elm.getAttribute("data-route"));
|
|
}
|
|
}
|
|
|
|
class RegexRoute extends Route {
|
|
constructor(overlay, elm) {
|
|
let regexp = elm.getAttribute("data-route").substr(2);
|
|
if (overlay) {
|
|
regexp = regexp.substr(9);
|
|
}
|
|
super(overlay, elm, [regexp]);
|
|
this.regex = new RegExp(regexp);
|
|
}
|
|
|
|
getArgs(path) {
|
|
if (path === undefined) {
|
|
path = single.getCurrentPath();
|
|
}
|
|
let res = this.regex.exec(path);
|
|
if (res && res.length > 0) {
|
|
return res;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
class SingleApp extends EventEmitter {
|
|
constructor() {
|
|
super();
|
|
this.routes = [];
|
|
this.routes_populated = false;
|
|
if (!head_include) {
|
|
this.populateRoutes();
|
|
}
|
|
window.onpopstate = event => {
|
|
event.preventDefault();
|
|
single.loadRoute();
|
|
};
|
|
this.timeouts = [];
|
|
this.intervals = [];
|
|
}
|
|
|
|
populateRoutes() {
|
|
if (this.routes_populated) {
|
|
return;
|
|
}
|
|
document.body.querySelectorAll("[data-route]").forEach(elm => {
|
|
let data = elm.getAttribute("data-route"),
|
|
overlay = false;
|
|
if (data.substr(0, 9) == "overlay: ") {
|
|
data = data.substr(9);
|
|
overlay = true;
|
|
}
|
|
if (data.substr(0, 2) == "~ ") {
|
|
this.routes.push(new RegexRoute(overlay, elm));
|
|
} else {
|
|
this.routes.push(new StandardRoute(overlay, elm));
|
|
}
|
|
});
|
|
if (this.routes.length == 0) {
|
|
console.error("[single.js] You need to define at least one route");
|
|
}
|
|
this.routes.forEach(route => {
|
|
route.paths.forEach(path => {
|
|
for (let i = 0; i < this.routes; i++) {
|
|
if (this.routes[i] !== route && this.routes[i].paths.indexOf(path) > -1) {
|
|
console.error("[single.js] Duplicate path: " + path);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
document.body.addEventListener("click", event => {
|
|
let elm = event.target;
|
|
while (elm && !(elm instanceof HTMLAnchorElement)) {
|
|
elm = elm.parentNode;
|
|
}
|
|
if (
|
|
elm instanceof HTMLAnchorElement &&
|
|
!elm.hasAttribute("target") &&
|
|
elm.hasAttribute("href") &&
|
|
elm.getAttribute("href").substr(0, 1) == "/"
|
|
) {
|
|
event.preventDefault();
|
|
single.loadRoute(new URL(elm.href));
|
|
}
|
|
});
|
|
this.routes_populated = true;
|
|
}
|
|
|
|
getRoute(route) {
|
|
this.populateRoutes();
|
|
let is_elm = route instanceof HTMLElement;
|
|
if (is_elm) {
|
|
if (!route.hasAttribute("data-route")) {
|
|
throw "Invalid route element: " + route;
|
|
}
|
|
route = route.getAttribute("data-route");
|
|
if (route.substr(0, 9) == "overlay: ") {
|
|
route = route.substr(9);
|
|
}
|
|
if (route.substr(0, 2) == "~ ") {
|
|
route = route.substr(2);
|
|
} else {
|
|
route = route.split(",")[0];
|
|
}
|
|
} else {
|
|
if (route.substr(0, 9) == "overlay: ") {
|
|
route = route.substr(9);
|
|
}
|
|
if (route.substr(0, 2) == "~ ") {
|
|
route = route.substr(2);
|
|
}
|
|
}
|
|
for (let i = 0; i < this.routes.length; i++) {
|
|
if (this.routes[i].paths.indexOf(route) > -1) {
|
|
return this.routes[i];
|
|
}
|
|
}
|
|
if (!is_elm) {
|
|
return this.getRoutes(route)[0];
|
|
}
|
|
}
|
|
|
|
getRoutes(route) {
|
|
let routes = [];
|
|
try {
|
|
document.querySelectorAll(route).forEach(elm => {
|
|
try {
|
|
let route = this.getRoute(elm);
|
|
if (route) {
|
|
routes.push(route);
|
|
}
|
|
} catch (ignored) {}
|
|
});
|
|
} catch (ignored) {}
|
|
return routes;
|
|
}
|
|
|
|
loadRoute(path) {
|
|
this.populateRoutes();
|
|
this.timeouts.forEach(clearTimeout);
|
|
this.intervals.forEach(clearInterval);
|
|
if (path === undefined) {
|
|
path = new URL(location.href);
|
|
} else if (typeof path == "string" && path.substr(0, 1) == "/") {
|
|
path = new URL(location.protocol + location.hostname + path);
|
|
}
|
|
let route,
|
|
args = false,
|
|
urlextra = "";
|
|
if (path instanceof URL) {
|
|
urlextra = path.search + path.hash;
|
|
path = decodeURIComponent(path.pathname);
|
|
}
|
|
for (let i = 0; i < this.routes.length; i++) {
|
|
if (this.routes[i] instanceof RegexRoute) {
|
|
args = this.routes[i].getArgs(path);
|
|
if (args !== false) {
|
|
route = this.routes[i];
|
|
break;
|
|
}
|
|
} else if (this.routes[i].paths.indexOf(path) > -1) {
|
|
route = this.routes[i];
|
|
break;
|
|
}
|
|
}
|
|
if (route === undefined) {
|
|
route = this.getRoute("404");
|
|
if (route === null) {
|
|
route = this.routes[0];
|
|
path = route.getCanonicalPath();
|
|
}
|
|
}
|
|
if (path.substr(0, 1) != "/") {
|
|
path = route.getCanonicalPath();
|
|
}
|
|
if (args === false) {
|
|
args = [path];
|
|
}
|
|
route.fire("beforeload", args);
|
|
this.fire("route_beforeload", {
|
|
route: route,
|
|
args: args
|
|
});
|
|
this.routes.forEach(r => {
|
|
if (r !== route) {
|
|
r.elm.classList.remove("route-current");
|
|
if (!route.overlay || r.overlay) {
|
|
r.elm.classList.remove("route-visible");
|
|
}
|
|
}
|
|
});
|
|
route.elm.classList.add("route-current", "route-visible");
|
|
path += urlextra;
|
|
if (this.getCurrentPath() != path) {
|
|
history.pushState({}, route.title, path);
|
|
}
|
|
document.querySelector("title").textContent = route.title;
|
|
this.fire("route_load", {
|
|
route: route,
|
|
args: args
|
|
});
|
|
route.fire("load", args);
|
|
}
|
|
|
|
getCurrentRoute() {
|
|
return this.getRoute(".route-current");
|
|
}
|
|
|
|
getCurrentPath() {
|
|
return location.pathname + location.search + location.hash;
|
|
}
|
|
|
|
setTimeout(f, i) {
|
|
this.timeouts.push(window.setTimeout(f, i));
|
|
}
|
|
|
|
setInterval(f, i) {
|
|
this.intervals.push(window.setInterval(f, i));
|
|
}
|
|
}
|
|
|
|
console.assert(!("single" in window));
|
|
window.single = new SingleApp();
|
|
if (["interactive", "complete"].indexOf(document.readyState) > -1) {
|
|
window.single.loadRoute();
|
|
} else {
|
|
document.addEventListener("DOMContentLoaded", () => window.single.loadRoute());
|
|
}
|
|
})();
|