Adrian Vollmer ac664c27a1 Avoid manually triggering DOMContentLoaded
It confuses scripts when it is triggered twice. Instead of passing the
global context, we simply write it into the HTML of the iframe.
We need to encode it in base64 to avoid context confusion (e.g. `</script>`
inside the JSON of the global context).
2024-04-21 15:37:23 +02:00

331 lines
11 KiB
JavaScript

const iFrameId = 'zundler-iframe';
var set_favicon = function(href) {
if (!href) {return;}
var favicon = document.createElement("link");
favicon.setAttribute('rel', 'shortcut icon');
href = normalize_path(href);
const file = window.global_context.file_tree[href];
if (!file) {return;}
if (file.mime_type == 'image/svg+xml') {
favicon.setAttribute('href', 'data:' + file.mime_type + ';charset=utf-8;base64,' + btoa(file.data));
favicon.setAttribute('type', file.mime_type);
} else {
if (file.base64encoded) {
favicon.setAttribute('href', 'data:' + file.mime_type + ';base64,' + file.data);
}
}
document.head.appendChild(favicon);
};
var createIframe = function() {
var iframe = document.getElementById(iFrameId);
if (iframe) { iframe.remove() };
iframe = document.createElement("iframe");
window.document.body.prepend(iframe);
iframe.setAttribute('src', '#');
iframe.setAttribute('name', iFrameId);
iframe.setAttribute('id', iFrameId);
// iframe.style.display = 'none';
return iframe;
}
var retrieve_file = function(path) {
// console.log("Retrieving file: " + path);
var file_tree = window.global_context.file_tree;
var file = file_tree[path];
if (!file) {
console.warn("File not found: " + path);
return "";
} else {
return file;
}
};
var is_virtual = function(url) {
// Return true if the url should be retrieved from the virtual file tree
var _url = url.toString().toLowerCase();
return (! (
_url == "" ||
_url[0] == "#" ||
_url.startsWith('https:/') ||
_url.startsWith('http:/') ||
_url.startsWith('data:') ||
_url.startsWith('about:srcdoc') ||
_url.startsWith('blob:')
));
};
var split_url = function(url) {
// Return a list of three elements: path, GET parameters, anchor
var anchor = url.split('#')[1] || "";
var get_parameters = url.split('#')[0].split('?')[1] || "";
var path = url.split('#')[0];
path = path.split('?')[0];
let result = [path, get_parameters, anchor];
// console.log("Split URL", url, result);
return result;
};
var prepare = function(html) {
function unicodeToBase64(string) {
const utf8EncodedString = unescape(encodeURIComponent(string));
return btoa(utf8EncodedString);
}
var parser = new DOMParser();
var doc = parser.parseFromString(html, "text/html");
const scriptTag = doc.createElement("script");
// Convert JSON object to b64 because it contain all kinds of
// problematic characters: `, ", ', &, </script>, ...
// atob is insufficient, because it only deals with ASCII - we have
// unicode
var serializedGC = unicodeToBase64(JSON.stringify(window.global_context));
scriptTag.textContent = `
function base64ToUnicode(base64String) {
const utf8EncodedString = atob(base64String);
return decodeURIComponent(escape(utf8EncodedString));
}
window.global_context = JSON.parse(base64ToUnicode("${serializedGC}"));
`;
doc.head.prepend(scriptTag);
embed_js(doc);
embed_css(doc);
embed_imgs(doc);
fix_links(doc);
fix_forms(doc);
return doc.documentElement.outerHTML;
}
var embed_js = function(doc) {
Array.from(doc.querySelectorAll("script")).forEach( oldScript => {
const newScript = doc.createElement("script");
Array.from(oldScript.attributes).forEach( attr => {
newScript.setAttribute(attr.name, attr.value);
});
try {
if (newScript.hasAttribute('src') && is_virtual(newScript.getAttribute('src'))) {
var src = newScript.getAttribute('src');
let [path, get_parameters, anchor] = split_url(src);
path = normalize_path(path);
console.debug("Embed script: " + path);
var src = retrieve_file(path).data + ' \n//# sourceURL=' + path;
newScript.appendChild(doc.createTextNode(src));
newScript.removeAttribute('src');
oldScript.parentNode.replaceChild(newScript, oldScript);
}
} catch (e) {
// Make sure all scripts are loaded
console.error("Caught error in " + oldScript.getAttribute("src"), e);
}
});
}
var embed_css = function(doc) {
Array.from(doc.querySelectorAll("link")).forEach( link => {
if (link.getAttribute('rel') == 'stylesheet' && link.getAttribute("href")) {
const style = doc.createElement("style");
var href = link.getAttribute('href');
let [path, get_parameters, anchor] = split_url(href);
path = normalize_path(path);
style.textContent = retrieve_file(path).data;
link.replaceWith(style);
};
});
};
var fix_links = function(doc) {
Array.from(doc.querySelectorAll("a")).forEach( a => {
fix_link(a);
});
};
var fix_link = function(a) {
if (is_virtual(a.getAttribute('href'))) {
// a.addEventListener('click', virtual_click);
a.setAttribute("onclick", "virtual_click(event)");
} else if (a.getAttribute('href').startsWith('#')) {
a.setAttribute('href', "about:srcdoc" + a.getAttribute('href'))
} else if (!a.getAttribute('href').startsWith('about:srcdoc')) {
// External links should open in a new tab. Browsers block links to
// sites of different origin within an iframe for security reasons.
a.setAttribute('target', "_blank");
}
};
var fix_form = function(form) {
var href = form.getAttribute('action');
if (is_virtual(href) && form.getAttribute('method').toLowerCase() == 'get') {
// form.addEventListener('submit', virtual_click);
form.setAttribute("onsubmit", "virtual_click(event)");
}
};
var fix_forms = function(doc) {
Array.from(doc.querySelectorAll("form")).forEach( form => {
fix_form(form);
});
};
var embed_img = function(img) {
if (img.hasAttribute('src')) {
const src = img.getAttribute('src');
if (is_virtual(src)) {
var path = normalize_path(src);
const file = retrieve_file(path);
const mime_type = file.mime_type;
if (mime_type == 'image/svg+xml') {
img.setAttribute('src', "data:image/svg+xml;charset=utf-8;base64, " + btoa(file.data));
} else {
img.setAttribute('src', `data:${mime_type};base64, ${file.data}`);
}
};
};
};
var embed_imgs = function(doc) {
Array.from(doc.querySelectorAll("img")).forEach( img => {
embed_img(img);
});
};
var load_virtual_page = (function (path, get_params, anchor) {
// fill the iframe with the new page
// return True if it worked
// return False if loading indicator should be removed right away
const file = window.global_context.file_tree[path];
var iframe = createIframe();
if (!file) {
console.error("File not found:", path, get_params, anchor);
return false;
}
const data = file.data;
window.global_context.get_parameters = get_params;
if (file.mime_type == 'text/html') {
window.global_context.current_path = path;
window.global_context.anchor = anchor;
const html = prepare(data);
iframe.setAttribute("srcdoc", html);
window.history.pushState({path, get_params, anchor}, '', '#');
return true;
} else {
let blob = new Blob([data], {type: file.mime_type})
var url = URL.createObjectURL(blob)
var myWindow = window.open(url, "_blank");
return false;
}
});
var normalize_path = function(path) {
// TODO remove redundant definition of this function (in inject.js)
// make relative paths absolute
var result = window.global_context.current_path;
result = result.split('/');
result.pop();
result = result.concat(path.split('/'));
// resolve relative directories
var array = [];
Array.from(result).forEach( component => {
if (component == '..') {
if (array) {
array.pop();
}
} else if (component == '.') {
} else {
if (component) { array.push(component); }
}
});
result = array.join('/');
// console.log(`Normalized path: ${path} -> ${result} (@${window.global_context.current_path})`);
return result;
};
window.onload = function() {
// Set up message listener
window.addEventListener("message", (evnt) => {
console.log("Received message in parent", evnt);
var iframe = document.getElementById(iFrameId);
if (evnt.data.action == 'ready') {
// iframe is ready to receive the global_context
iframe.contentWindow.postMessage({
action: "set_data",
argument: window.global_context,
}, "*");
} else if (evnt.data.action == 'set_title') {
// iframe has finished loading and sent us its title
// parent sets the title and responds with the global_context object
window.document.title = evnt.data.argument.title;
set_favicon(evnt.data.argument.favicon);
} else if (evnt.data.action == 'virtual_click') {
// user has clicked on a link in the iframe
show_loading_indictator();
var loaded = load_virtual_page(
evnt.data.argument.path,
evnt.data.argument.get_parameters,
evnt.data.argument.anchor,
);
if (!loaded) {
hide_loading_indictator();
}
} else if (evnt.data.action == 'show_iframe') {
// iframe finished fixing the document and is ready to be shown;
hide_loading_indictator();
iframe.contentWindow.postMessage({
action: "scroll_to_anchor",
}, "*");
}
}, false);
// Set up history event listener
window.addEventListener("popstate", (evnt) => {
load_virtual_page(evnt.state.path, evnt.state.get_params, evnt.state.anchor);
});
// Load first page
load_virtual_page(window.global_context.current_path, "", "");
}
var show_loading_indictator = function() {
var iframe = document.getElementById(iFrameId);
iframe.remove()
var loading = document.getElementById('loading-indicator');
loading.style.display = '';
}
var hide_loading_indictator = function() {
var iframe = document.getElementById(iFrameId);
iframe.style.display = '';
var loading = document.getElementById('loading-indicator');
loading.style.display = 'none';
}