tildefriends/core/client.js

758 lines
20 KiB
JavaScript
Raw Normal View History

"use strict";
var gSocket;
var gCredentials;
var gCurrentFile;
var gFiles = {};
var gApp = {files: {}};
var gEditor;
var gSplit;
var gStats = {};
var gGraph;
var kErrorColor = "#dc322f";
var kStatusColor = "#fff";
window.addEventListener("keydown", function(event) {
if (event.keyCode == 69 && event.altKey) {
if (!editing()) {
edit();
event.preventDefault();
}
} else if (event.keyCode == 83 && (event.altKey || event.ctrlKey)) {
if (editing()) {
save();
event.preventDefault();
}
} else if (event.keyCode == 66 && event.altKey) {
if (editing()) {
closeEditor();
event.preventDefault();
}
}
});
function ensureLoaded(nodes, callback) {
if (!nodes.length) {
callback();
return;
}
var search = nodes.shift();
var head = document.head;
var found = false;
for (var i = 0; i < head.childNodes.length; i++) {
if (head.childNodes[i].tagName == search.tagName) {
var match = true;
for (var attribute in search.attributes) {
if (head.childNodes[i].attributes[attribute].value != search.attributes[attribute]) {
match = false;
}
}
if (match) {
found = true;
break;
}
}
}
if (found) {
ensureLoaded(nodes, callback);
} else {
var node = document.createElement(search.tagName);
node.onreadystatechange = node.onload = function() {
ensureLoaded(nodes, callback);
};
for (var attribute in search.attributes) {
node.setAttribute(attribute, search.attributes[attribute]);
}
head.insertBefore(node, head.firstChild);
}
}
function editing() {
return document.getElementById("editPane").style.display != 'none';
}
function edit() {
if (editing()) {
return;
}
closeStats();
gSplit = Split(['#editPane', '#viewPane'], {minSize: 0});
ensureLoaded([
{tagName: "script", attributes: {src: "/static/codemirror/codemirror.min.js"}},
{tagName: "link", attributes: {rel: "stylesheet", href: "/static/codemirror/base16-dark.min.css"}},
{tagName: "link", attributes: {rel: "stylesheet", href: "/static/codemirror/matchesonscrollbar.min.css"}},
{tagName: "link", attributes: {rel: "stylesheet", href: "/static/codemirror/dialog.min.css"}},
{tagName: "link", attributes: {rel: "stylesheet", href: "/static/codemirror/codemirror.min.css"}},
{tagName: "script", attributes: {src: "/static/codemirror/trailingspace.min.js"}},
{tagName: "script", attributes: {src: "/static/codemirror/dialog.min.js"}},
{tagName: "script", attributes: {src: "/static/codemirror/search.min.js"}},
{tagName: "script", attributes: {src: "/static/codemirror/searchcursor.min.js"}},
{tagName: "script", attributes: {src: "/static/codemirror/jump-to-line.min.js"}},
{tagName: "script", attributes: {src: "/static/codemirror/matchesonscrollbar.min.js"}},
{tagName: "script", attributes: {src: "/static/codemirror/annotatescrollbar.min.js"}},
{tagName: "script", attributes: {src: "/static/codemirror/javascript.min.js"}},
{tagName: "script", attributes: {src: "/static/codemirror/css.min.js"}},
{tagName: "script", attributes: {src: "/static/codemirror/xml.min.js"}},
{tagName: "script", attributes: {src: "/static/codemirror/htmlmixed.min.js"}},
], function() {
load();
});
}
function trace() {
var request = new XMLHttpRequest();
request.addEventListener("loadend", function() {
if (request.status == 200) {
/* The trace is loaded. */
var perfetto = window.open('/perfetto/');
var done = false;
if (perfetto) {
function message_handler(message) {
if (message.data == 'PONG') {
perfetto.postMessage({
perfetto: {
buffer: request.response,
title: 'Tilde Friends Trace',
url: window.location.href,
}
}, '*');
done = true;
}
}
window.addEventListener('message', message_handler);
function ping_perfetto() {
perfetto.postMessage('PING', window.location.origin);
if (!done && !perfetto.closed) {
setTimeout(ping_perfetto, 50);
} else {
window.removeEventListener('message', message_handler);
}
}
setTimeout(ping_perfetto, 50);
} else {
alert("Unable to open perfetto.");
}
} else {
alert("Failed to load trace: " + request.status + ".");
}
});
request.addEventListener("error", function() {
alert("Error loading trace.");
});
request.addEventListener("timeout", function() {
alert("Timed out loading trace.");
});
request.addEventListener("abort", function() {
alert("Loading trace aborted.");
});
request.responseType = 'arraybuffer';
request.open("GET", "/trace");
request.send();
}
function stats() {
closeEditor();
gSplit = Split(['#statsPane', '#viewPane'], {minSize: 0});
document.getElementById("statsPane").style.display = 'flex';
gGraph = new SmoothieChart({ grid: { strokeStyle: 'rgb(125, 0, 0)', fillStyle: 'rgb(60, 0, 0)', lineWidth: 1, millisPerLine: 10000, verticalSections: 6 } });
gStats = {};
gGraph.streamTo(document.getElementById('graph'), 10000);
}
function guessMode(name) {
return name.endsWith(".js") ? "javascript" :
name.endsWith(".html") ? "htmlmixed" :
null;
}
function loadFile(name, id) {
var request = new XMLHttpRequest();
request.addEventListener("loadend", function() {
if (request.status == 200) {
gFiles[name].doc = new CodeMirror.Doc(request.responseText, guessMode(name));
if (!Object.values(gFiles).some(x => !x.doc)) {
document.getElementById("editPane").style.display = 'flex';
openFile(Object.keys(gFiles).sort()[0]);
}
}
});
request.addEventListener("error", function() {
alert("Error loading source.");
closeEditor();
});
request.addEventListener("timeout", function() {
alert("Timed out loading source.");
closeEditor();
});
request.addEventListener("abort", function() {
alert("Loading source aborted.");
closeEditor();
});
request.open("GET", "/" + id + "/view");
request.send();
}
function load() {
var request = new XMLHttpRequest();
request.addEventListener("loadend", function() {
if (request.status == 200 || request.status == 404) {
if (!gEditor) {
gEditor = CodeMirror.fromTextArea(document.getElementById("editor"), {
'theme': 'base16-dark',
'lineNumbers': true,
'tabSize': 4,
'indentUnit': 4,
'indentWithTabs': true,
'showTrailingSpace': true,
});
gEditor.on('changes', function() {
updateFiles();
});
}
gFiles = {};
var text;
var isApp = false;
if (request.status == 200) {
text = request.responseText;
try {
var json = JSON.parse(text);
if (json && json['type'] == 'tildefriends-app') {
isApp = true;
Object.keys(json['files']).forEach(function(name) {
gFiles[name] = {};
loadFile(name, json['files'][name]);
});
if (Object.keys(json['files']).length == 0) {
document.getElementById("editPane").style.display = 'flex';
}
gApp = JSON.parse(text);
}
} catch {
}
}
if (!isApp) {
document.getElementById("editPane").style.display = 'flex';
if (!text) {
text = '// New script.\n';
}
gCurrentFile = 'app.js';
gFiles[gCurrentFile] = {
doc: new CodeMirror.Doc(text, guessMode(gCurrentFile)),
};
openFile(gCurrentFile);
}
}
});
request.addEventListener("error", function() {
alert("Error loading source.");
closeEditor();
});
request.addEventListener("timeout", function() {
alert("Timed out loading source.");
closeEditor();
});
request.addEventListener("abort", function() {
alert("Loading source aborted.");
closeEditor();
});
request.open("GET", url() + "view");
request.send();
}
function closeStats() {
document.getElementById("statsPane").style.display = 'none';
if (gSplit) {
gSplit.destroy();
gSplit = undefined;
}
}
function closeEditor() {
document.getElementById("editPane").style.display = 'none';
if (gSplit) {
gSplit.destroy();
gSplit = undefined;
}
}
function explodePath() {
return /^\/~([^\/]+)\/([^\/]+)(.*)/.exec(window.location.pathname);
}
function save() {
document.getElementById("save").disabled = true;
if (gCurrentFile) {
gFiles[gCurrentFile].doc = gEditor.getDoc();
}
var run = document.getElementById("run").checked;
var appFinished = function(success) {
document.getElementById("save").disabled = false;
Object.values(gFiles).forEach(function(file) {
file.generation = file.doc.changeGeneration();
});
updateFiles();
}
var always = function() {
var anyUnfinished = Object.values(gFiles).some(x => x.request);
var anyUnsaved = Object.values(gFiles).some(x => !x.doc.isClean(x.generation) && !x.id);
if (!anyUnfinished && !anyUnsaved) {
var app = {
type: "tildefriends-app",
files: Object.fromEntries(Object.keys(gFiles).map(x => [x, gFiles[x].id || gApp.files[x]])),
};
Object.values(gFiles).forEach(function(file) { delete file.id; });
gApp = JSON.parse(JSON.stringify(app));
var request = new XMLHttpRequest();
request.addEventListener("error", function() {
alert("Error saving: " + request.responseText);
appFinished(false);
});
request.addEventListener("loadend", function() {
if (request.status == 200) {
var latest = document.getElementById('latest');
latest.href = request.responseText;
latest.style.visibility = request.responseText != window.location.path ? 'visible' : 'hidden';
if (run) {
if (request.responseText) {
reconnect(request.responseText);
} else {
reconnect(window.location.path);
}
}
appFinished(true);
} else {
alert("Unable to save: " + request.responseText);
appFinished(false);
}
});
request.addEventListener("timeout", function() {
alert("Timed out saving: " + request.responseText);
appFinished(false);
});
request.addEventListener("abort", function() {
alert("Save aborted: " + request.responseText);
appFinished(false);
});
var saveTo = null;
var name = document.getElementById("name");
if (name && name.value) {
saveTo = name.value + "save";
} else {
saveTo = url() + "save";
}
request.open("POST", saveTo, true);
request.setRequestHeader("Content-Type", "text/json");
request.send(JSON.stringify(app));
} else if (!anyUnfinished) {
appFinished(false);
}
};
var anySkipped = false;
Object.values(gFiles).forEach(function(file) {
if (file.doc.isClean(file.generation)) {
anySkipped = true;
return;
}
delete file.id;
file.request = new XMLHttpRequest();
file.request.addEventListener("error", function() {
alert("Error saving: " + file.request.responseText);
file.request = null;
always();
});
file.request.addEventListener("loadend", function() {
if (file.request.status == 200) {
file.id = file.request.responseText;
if (file.id.charAt(0) == '/') {
file.id = file.id.substr(1);
}
} else {
alert("Unable to save: " + file.request.responseText);
}
file.request = null;
always();
});
file.request.addEventListener("timeout", function() {
alert("Timed out saving: " + file.request.responseText);
file.request = null;
always();
});
file.request.addEventListener("abort", function() {
alert("Save aborted: " + file.request.responseText);
file.request = null;
always();
});
file.request.open("POST", "/save", true);
file.request.setRequestHeader("Content-Type", "text/plain");
file.request.send(file.doc.getValue());
});
if (anySkipped) {
always();
}
}
function url() {
var hash = window.location.href.indexOf('#');
var question = window.location.href.indexOf('?');
var end = -1;
if (hash != -1 && (hash < end || end == -1))
{
end = hash;
}
if (question != -1 && (question < end || end == -1))
{
end = question;
}
return end != -1 ? window.location.href.substring(0, end) : window.location.href;
}
function hash() {
return window.location.hash != "#" ? window.location.hash : "";
}
function receive(message) {
if (message && message.action == "session") {
setStatusMessage("...Executing...", kStatusColor, true);
gCredentials = message.credentials;
updateLogin();
} else if (message && message.action == "ready") {
setStatusMessage(null);
if (window.location.hash) {
send({event: "hashChange", hash: window.location.hash});
}
} else if (message && message.action == "setDocument") {
var iframe = document.getElementById("document");
iframe.srcdoc = message.content;
} else if (message && message.action == "postMessage") {
var iframe = document.getElementById("document");
iframe.contentWindow.postMessage(message.message, "*");
} else if (message && message.action == "ping") {
gSocket.send(JSON.stringify({action: "pong"}));
} else if (message && message.action == "error") {
if (message.error) {
if (typeof(message.error) == 'string') {
setStatusMessage(message.error, '#f00', false);
} else {
setStatusMessage(message.error.message + '\n' + message.error.stack, '#f00', false);
}
}
console.log('error', message);
} else if (message && message.action == "stats") {
if (gGraph) {
var now = new Date().getTime();
for (var key of Object.keys(message.stats)) {
if (!gStats[key]) {
gStats[key] = new TimeSeries();
gGraph.addTimeSeries(gStats[key]);
}
gStats[key].append(now, message.stats[key]);
}
}
}
}
function keyEvent(event) {
send({
event: "key",
type: event.type,
which: event.which,
keyCode: event.keyCode,
charCode: event.charCode,
character: String.fromCharCode(event.keyCode || event.which),
altKey: event.altKey,
});
}
function setStatusMessage(message, color, keep) {
var node = document.getElementById("status");
if (!keep) {
while (node.firstChild) {
node.removeChild(node.firstChild);
}
}
if (message) {
node.appendChild(document.createTextNode(message));
node.setAttribute("style", "display: inline-block; vertical-align: top; white-space: pre; color: " + (color || kErrorColor));
}
}
function send(value) {
try {
gSocket.send(JSON.stringify(value));
} catch (error) {
setStatusMessage("Send failed: " + error.toString(), kErrorColor);
}
}
function updateLogin() {
var login = document.getElementById("login");
while (login.firstChild) {
login.removeChild(login.firstChild);
}
var a = document.createElement("a");
if (gCredentials && gCredentials.session) {
a.appendChild(document.createTextNode("logout " + gCredentials.session.name));
a.setAttribute("href", "/login/logout?return=" + encodeURIComponent(url() + hash()));
} else {
a.appendChild(document.createTextNode("login"));
a.setAttribute("href", "/login?return=" + encodeURIComponent(url() + hash()));
}
login.appendChild(a);
}
var gOriginalInput;
function dragHover(event) {
event.stopPropagation();
event.preventDefault();
var input = document.getElementById("input");
if (event.type == "dragover") {
if (!input.classList.contains("drop")) {
input.classList.add("drop");
gOriginalInput = input.value;
input.value = "drop file to upload";
}
} else {
input.classList.remove("drop");
input.value = gOriginalInput;
}
}
function fixImage(sourceData, maxWidth, maxHeight, callback) {
var result = sourceData;
var image = new Image();
image.crossOrigin = "anonymous";
image.referrerPolicy = "no-referrer";
image.onload = function() {
if (image.width > maxWidth || image.height > maxHeight) {
var downScale = Math.min(maxWidth / image.width, maxHeight / image.height);
var canvas = document.createElement("canvas");
canvas.width = image.width * downScale;
canvas.height = image.height * downScale;
var context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height);
image.width = canvas.width;
image.height = canvas.height;
context.drawImage(image, 0, 0, image.width, image.height);
result = canvas.toDataURL();
}
callback(result);
};
image.src = sourceData;
}
function sendImage(image) {
fixImage(image, 320, 240, function(result) {
send({image: result});
});
}
function fileDropRead(event) {
sendImage(event.target.result);
}
function fileDrop(event) {
dragHover(event);
var done = false;
if (!done) {
var files = event.target.files || event.dataTransfer.files;
for (var i = 0; i < files.length; i++) {
var file = files[i];
if (file.type.substring(0, "image/".length) == "image/") {
var reader = new FileReader();
reader.onloadend = fileDropRead;
reader.readAsDataURL(file);
done = true;
}
}
}
if (!done) {
var html = event.dataTransfer.getData("text/html");
var match = /<img.*src="([^"]+)"/.exec(html);
if (match) {
sendImage(match[1]);
done = true;
}
}
if (!done) {
var text = event.dataTransfer.getData("text/plain");
if (text) {
send(text);
done = true;
}
}
}
function enableDragDrop() {
var body = document.body;
body.addEventListener("dragover", dragHover);
body.addEventListener("dragleave", dragHover);
body.addEventListener("drop", fileDrop);
}
function hashChange() {
send({event: 'hashChange', hash: window.location.hash});
}
function focus() {
if (gSocket && gSocket.readyState == gSocket.CLOSED) {
connectSocket();
} else {
send({event: "focus"});
}
}
function blur() {
if (gSocket && gSocket.readyState == gSocket.OPEN) {
send({event: "blur"});
}
}
function message(event) {
if (event.data && event.data.event == "resizeMe" && event.data.width && event.data.height) {
var iframe = document.getElementById("iframe_" + event.data.name);
iframe.setAttribute("width", event.data.width);
iframe.setAttribute("height", event.data.height);
} else if (event.data && event.data.action == "setHash") {
window.location.hash = event.data.hash;
} else {
send({event: "message", message: event.data});
}
}
function reconnect(path) {
let oldSocket = gSocket;
gSocket = null
oldSocket.onopen = null;
oldSocket.onclose = null;
oldSocket.onmessage = null;
oldSocket.close();
connectSocket(path);
}
function connectSocket(path) {
if (!gSocket || gSocket.readyState != gSocket.OPEN) {
if (gSocket) {
gSocket.onopen = null;
gSocket.onclose = null;
gSocket.onmessage = null;
gSocket.close();
}
setStatusMessage("Connecting...", kStatusColor, false);
gSocket = new WebSocket(
(window.location.protocol == "https:" ? "wss://" : "ws://")
+ window.location.hostname
+ (window.location.port.length ? ":" + window.location.port : "")
+ "/app/socket");
gSocket.onopen = function() {
setStatusMessage("...Authenticating...", kStatusColor, true);
gSocket.send(JSON.stringify({
action: "hello",
path: path,
api: [
['setDocument', 'content'],
['postMessage', 'message'],
['error', 'error'],
],
}));
}
gSocket.onmessage = function(event) {
receive(JSON.parse(event.data));
}
gSocket.onclose = function(event) {
setStatusMessage("Connection closed with code " + event.code, kErrorColor);
}
}
}
function openFile(name) {
var newDoc = (name && gFiles[name]) ? gFiles[name].doc : new CodeMirror.Doc("", guessMode(name));
var oldDoc = gEditor.swapDoc(newDoc);
if (gFiles[gCurrentFile]) {
gFiles[gCurrentFile].doc = oldDoc;
}
gCurrentFile = name;
updateFiles();
gEditor.focus();
}
function onFileClicked(event) {
openFile(event.target.textContent);
}
function updateFiles() {
var node = document.getElementById("files");
while (node.firstChild) {
node.removeChild(node.firstChild);
}
for (var file of Object.keys(gFiles).sort()) {
var li = document.createElement("li");
li.onclick = onFileClicked;
li.appendChild(document.createTextNode(file));
if (file == gCurrentFile) {
li.classList.add("current");
}
if (!gFiles[file].doc.isClean(gFiles[file].generation)) {
li.classList.add("dirty");
}
node.appendChild(li);
}
gEditor.focus();
}
function makeNewFile(name) {
gFiles[name] = {
doc: new CodeMirror.Doc("", guessMode(name)),
generation: -1,
};
openFile(name);
}
function newFile() {
var name = prompt("Name of new file:", "file.js");
if (name && !gFiles[name]) {
makeNewFile(name);
}
}
function removeFile() {
if (confirm("Remove " + gCurrentFile + "?")) {
delete gFiles[gCurrentFile];
openFile(Object.keys(gFiles)[0]);
}
}
window.addEventListener("load", function() {
if (window.Notification) {
Notification.requestPermission();
}
window.addEventListener("hashchange", hashChange);
window.addEventListener("focus", focus);
window.addEventListener("blur", blur);
window.addEventListener("message", message, false);
window.addEventListener("online", connectSocket);
document.getElementById("name").value = window.location.pathname;
enableDragDrop();
connectSocket(window.location.pathname);
});