forked from cory/tildefriends
1877 lines
42 KiB
JavaScript
1877 lines
42 KiB
JavaScript
import {LitElement, html, css, svg} from '/lit/lit-all.min.js';
|
|
|
|
let cm6;
|
|
let gSocket;
|
|
|
|
let gCurrentFile;
|
|
let gFiles = {};
|
|
let gApp = {files: {}, emoji: '📦'};
|
|
let gEditor;
|
|
let gOriginalInput;
|
|
|
|
let kErrorColor = '#dc322f';
|
|
let kDisconnectColor = '#f00';
|
|
let kStatusColor = '#fff';
|
|
|
|
// Functions that server-side app code can call through the app object.
|
|
const k_api = {
|
|
setDocument: {args: ['content'], func: api_setDocument},
|
|
postMessage: {args: ['message'], func: api_postMessage},
|
|
error: {args: ['error'], func: api_error},
|
|
localStorageSet: {args: ['key', 'value'], func: api_localStorageSet},
|
|
localStorageGet: {args: ['key'], func: api_localStorageGet},
|
|
requestPermission: {args: ['permission', 'id'], func: api_requestPermission},
|
|
print: {args: ['...'], func: api_print},
|
|
setHash: {args: ['hash'], func: api_setHash},
|
|
};
|
|
|
|
// TODO(tasiaiso): this is only used once, move it down ?
|
|
const k_global_style = css`
|
|
a:link {
|
|
color: #268bd2;
|
|
}
|
|
|
|
a:visited {
|
|
color: #6c71c4;
|
|
}
|
|
|
|
a:hover {
|
|
color: #859900;
|
|
}
|
|
|
|
a:active {
|
|
color: #2aa198;
|
|
}
|
|
`;
|
|
|
|
/**
|
|
* Class that represents the top bar
|
|
*/
|
|
class TfNavigationElement extends LitElement {
|
|
static get properties() {
|
|
return {
|
|
credentials: {type: Object},
|
|
permissions: {type: Object},
|
|
show_permissions: {type: Boolean},
|
|
status: {type: Object},
|
|
spark_lines: {type: Object},
|
|
version: {type: Object},
|
|
show_version: {type: Boolean},
|
|
identity: {type: String},
|
|
identities: {type: Array},
|
|
names: {type: Object},
|
|
};
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
this.permissions = {};
|
|
this.show_permissions = false;
|
|
this.status = {};
|
|
this.spark_lines = {};
|
|
this.identities = [];
|
|
this.names = {};
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} event
|
|
*/
|
|
toggle_edit(event) {
|
|
event.preventDefault();
|
|
if (editing()) {
|
|
closeEditor();
|
|
} else {
|
|
edit();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} key
|
|
*/
|
|
reset_permission(key) {
|
|
send({action: 'resetPermission', permission: key});
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} key
|
|
* @param {*} options
|
|
* @returns
|
|
*/
|
|
get_spark_line(key, options) {
|
|
if (!this.spark_lines[key]) {
|
|
let spark_line = document.createElement('tf-sparkline');
|
|
spark_line.title = key;
|
|
spark_line.classList.add('w3-bar-item');
|
|
spark_line.classList.add('w3-hide-small');
|
|
spark_line.style.paddingRight = '0';
|
|
if (options) {
|
|
if (options.max) {
|
|
spark_line.max = options.max;
|
|
}
|
|
}
|
|
this.spark_lines[key] = spark_line;
|
|
this.requestUpdate();
|
|
}
|
|
return this.spark_lines[key];
|
|
}
|
|
|
|
set_active_identity(id) {
|
|
send({action: 'setActiveIdentity', identity: id});
|
|
this.renderRoot.getElementById('id_dropdown').classList.remove('w3-show');
|
|
}
|
|
|
|
create_identity(event) {
|
|
if (confirm('Are you sure you want to create a new identity?')) {
|
|
send({action: 'createIdentity'});
|
|
}
|
|
}
|
|
|
|
toggle_id_dropdown() {
|
|
this.renderRoot.getElementById('id_dropdown').classList.toggle('w3-show');
|
|
}
|
|
|
|
edit_profile() {
|
|
window.location.href = '/~core/ssb/#' + this.identity;
|
|
}
|
|
|
|
logout() {
|
|
window.location.href = `/login/logout?return=${encodeURIComponent(url() + hash())}`;
|
|
}
|
|
|
|
render_identity() {
|
|
let self = this;
|
|
|
|
if (this?.credentials?.session?.name) {
|
|
if (this.identities?.length) {
|
|
return html`
|
|
<link type="text/css" rel="stylesheet" href="/static/w3.css" />
|
|
<div class="w3-dropdown-click w3-right" style="max-width: 100%">
|
|
<button
|
|
class="w3-button w3-rest w3-cyan"
|
|
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap; max-width: 100%"
|
|
id="identity"
|
|
@click=${self.toggle_id_dropdown}
|
|
>
|
|
${self.names[this.identity]}▾
|
|
</button>
|
|
<div
|
|
id="id_dropdown"
|
|
class="w3-dropdown-content w3-bar-block w3-card-4"
|
|
style="max-width: 100%; right: 0"
|
|
>
|
|
<button
|
|
class="w3-bar-item w3-button w3-border"
|
|
@click=${() => (window.location.href = '/~core/identity')}
|
|
>
|
|
Manage Identities...
|
|
</button>
|
|
<button
|
|
class="w3-bar-item w3-button w3-border"
|
|
@click=${self.edit_profile}
|
|
>
|
|
Edit Profile...
|
|
</button>
|
|
${this.identities.map(
|
|
(x) => html`
|
|
<button
|
|
class="w3-bar-item w3-button ${x === self.identity
|
|
? 'w3-cyan'
|
|
: ''}"
|
|
@click=${() => self.set_active_identity(x)}
|
|
style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap"
|
|
>
|
|
${self.names[x]}${self.names[x] === x ? '' : html` - ${x}`}
|
|
</button>
|
|
`
|
|
)}
|
|
<button
|
|
class="w3-bar-item w3-button w3-border"
|
|
id="logout"
|
|
@click=${self.logout}
|
|
>
|
|
Logout ${this.credentials.session.name}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
} else if (
|
|
this.credentials?.session?.name &&
|
|
this.credentials.session.name !== 'guest'
|
|
) {
|
|
return html`
|
|
<link type="text/css" rel="stylesheet" href="/static/w3.css" />
|
|
<button
|
|
class="w3-bar-item w3-button w3-right w3-cyan"
|
|
id="logout"
|
|
@click=${self.logout}
|
|
>
|
|
Logout ${this.credentials.session.name}
|
|
</button>
|
|
<button
|
|
id="create_identity"
|
|
@click=${this.create_identity}
|
|
class="w3-button w3-mobile w3-red w3-right"
|
|
>
|
|
Create an Identity
|
|
</button>
|
|
`;
|
|
} else {
|
|
return html`
|
|
<button
|
|
class="w3-bar-item w3-button w3-right w3-cyan"
|
|
id="logout"
|
|
@click=${self.logout}
|
|
>
|
|
Logout ${this.credentials.session.name}
|
|
</button>
|
|
`;
|
|
}
|
|
} else {
|
|
return html`<a
|
|
class="w3-bar-item w3-cyan w3-right"
|
|
id="login"
|
|
href="/login?return=${url() + hash()}"
|
|
>login</a
|
|
>`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @returns
|
|
*/
|
|
render_permissions() {
|
|
if (this.show_permissions) {
|
|
return html`
|
|
<link type="text/css" rel="stylesheet" href="/static/w3.css" />
|
|
<div
|
|
style="position: absolute; top: 0; padding: 0; margin: 0; z-index: 100; display: flex; justify-content: center; width: 100%"
|
|
>
|
|
<div
|
|
style="background-color: #444; padding: 1em; margin: 0 auto; border-left: 4px solid #fff; border-right: 4px solid #fff; border-bottom: 4px solid #fff"
|
|
>
|
|
<div>This app has the following permissions:</div>
|
|
${Object.keys(this.permissions).map(
|
|
(key) => html`
|
|
<div>
|
|
<span>${key}</span>:
|
|
${this.permissions[key] ? '✅ Allowed' : '❌ Denied'}
|
|
<button
|
|
@click=${() => this.reset_permission(key)}
|
|
class="w3-button w3-red"
|
|
>
|
|
Reset
|
|
</button>
|
|
</div>
|
|
`
|
|
)}
|
|
<button
|
|
@click=${() => (this.show_permissions = false)}
|
|
class="w3-button w3-blue"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
clear_error() {
|
|
this.status = {};
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @returns
|
|
*/
|
|
render() {
|
|
let self = this;
|
|
return html`
|
|
<link type="text/css" rel="stylesheet" href="/static/w3.css" />
|
|
<style>
|
|
${k_global_style} .tooltip {
|
|
position: absolute;
|
|
z-index: 1;
|
|
display: none;
|
|
border: 1px solid black;
|
|
padding: 4px;
|
|
color: black;
|
|
background: white;
|
|
}
|
|
|
|
.tooltip_parent:hover .tooltip {
|
|
display: inline-block;
|
|
}
|
|
</style>
|
|
<div class="w3-black w3-bar">
|
|
<span
|
|
class="w3-bar-item"
|
|
style="cursor: pointer"
|
|
@click=${() => (this.show_version = !this.show_version)}
|
|
>😎</span
|
|
>
|
|
<span
|
|
class="w3-bar-item"
|
|
style=${'white-space: nowrap' +
|
|
(this.show_version ? '' : '; display: none')}
|
|
title=${this.version?.name +
|
|
' ' +
|
|
Object.entries(this.version || {})
|
|
.filter((x) => ['name', 'number'].indexOf(x[0]) == -1)
|
|
.map((x) => `\n* ${x[0]}: ${x[1]}`)}
|
|
>${this.version?.number}</span
|
|
>
|
|
<a
|
|
class="w3-bar-item"
|
|
accesskey="h"
|
|
@mouseover=${set_access_key_title}
|
|
data-tip="Open home app."
|
|
href="/"
|
|
style="color: #fff; white-space: nowrap"
|
|
>TF</a
|
|
>
|
|
<a
|
|
class="w3-bar-item"
|
|
accesskey="a"
|
|
@mouseover=${set_access_key_title}
|
|
data-tip="Open apps list."
|
|
href="/~core/apps/"
|
|
>apps</a
|
|
>
|
|
<a
|
|
class="w3-bar-item"
|
|
accesskey="e"
|
|
@mouseover=${set_access_key_title}
|
|
data-tip="Toggle the app editor."
|
|
href="#"
|
|
@click=${this.toggle_edit}
|
|
>edit</a
|
|
>
|
|
<a
|
|
class="w3-bar-item"
|
|
accesskey="p"
|
|
@mouseover=${set_access_key_title}
|
|
data-tip="View and change permissions."
|
|
href="#"
|
|
@click=${() => (self.show_permissions = !self.show_permissions)}
|
|
>🎛️</a
|
|
>
|
|
${this.render_permissions()}
|
|
${this.status?.message && !this.status.is_error
|
|
? html`
|
|
<link type="text/css" rel="stylesheet" href="/static/w3.css" />
|
|
<div
|
|
class="w3-bar-item"
|
|
style="color: ${this.status.color ?? kStatusColor}"
|
|
>
|
|
${this.status.message}
|
|
</div>
|
|
`
|
|
: undefined}
|
|
${Object.keys(this.spark_lines)
|
|
.sort()
|
|
.map((x) => this.spark_lines[x])}
|
|
${this.render_identity()}
|
|
</div>
|
|
${this.status?.is_error
|
|
? html`
|
|
<link type="text/css" rel="stylesheet" href="/static/w3.css" />
|
|
<div class="w3-model w3-animate-top" style="position: absolute; left: 50%; transform: translate(-50%); z-index: 1">
|
|
<dijv class="w3-modal-content w3-card-4" style="display: block; padding: 1em">
|
|
<span @click=${self.clear_error} class="w3-button w3-display-topright">×</span>
|
|
<div style="color: ${this.status.color ?? kErrorColor}"><b>ERROR:</b><p style="white-space: pre">${this.status.message}</p></div>
|
|
</div>
|
|
</div>
|
|
`
|
|
: undefined}
|
|
`;
|
|
}
|
|
}
|
|
|
|
customElements.define('tf-navigation', TfNavigationElement);
|
|
|
|
/**
|
|
* TODOC
|
|
*/
|
|
class TfFilesElement extends LitElement {
|
|
static get properties() {
|
|
return {
|
|
current: {type: String},
|
|
files: {type: Object},
|
|
dropping: {type: Number},
|
|
};
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
this.files = {};
|
|
this.dropping = 0;
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} file
|
|
*/
|
|
file_click(file) {
|
|
this.dispatchEvent(
|
|
new CustomEvent('file_click', {
|
|
detail: {
|
|
file: file,
|
|
},
|
|
bubbles: true,
|
|
composed: true,
|
|
})
|
|
);
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} file
|
|
* @returns
|
|
*/
|
|
render_file(file) {
|
|
let classes = ['file'];
|
|
if (file == this.current) {
|
|
classes.push('current');
|
|
}
|
|
if (!this.files[file].clean) {
|
|
classes.push('dirty');
|
|
}
|
|
return html`<div
|
|
class="${classes.join(' ')}"
|
|
@click=${(x) => this.file_click(file)}
|
|
>
|
|
${file}
|
|
</div>`;
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} event
|
|
*/
|
|
async drop(event) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.dropping = 0;
|
|
for (let file of event.dataTransfer.files) {
|
|
let buffer = await file.arrayBuffer();
|
|
let text = new TextDecoder('latin1').decode(buffer);
|
|
gFiles[file.name] = {
|
|
doc: new cm6.EditorState.create({
|
|
doc: text,
|
|
extensions: cm6.extensions,
|
|
}),
|
|
buffer: buffer,
|
|
isNew: true,
|
|
};
|
|
gCurrentFile = file.name;
|
|
}
|
|
openFile(gCurrentFile);
|
|
updateFiles();
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} event
|
|
*/
|
|
drag_enter(event) {
|
|
this.dropping++;
|
|
event.preventDefault();
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} event
|
|
*/
|
|
drag_leave(event) {
|
|
this.dropping--;
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @returns
|
|
*/
|
|
render() {
|
|
let self = this;
|
|
return html`
|
|
<style>
|
|
div.file {
|
|
padding: 0.5em;
|
|
cursor: pointer;
|
|
}
|
|
div.file:hover {
|
|
background-color: #1a9188;
|
|
}
|
|
div.file::before {
|
|
content: '📄 ';
|
|
}
|
|
|
|
div.file.current {
|
|
font-weight: bold;
|
|
background-color: #2aa198;
|
|
}
|
|
|
|
div.file.dirty::after {
|
|
content: '*';
|
|
}
|
|
</style>
|
|
<div
|
|
@drop=${this.drop}
|
|
@dragenter=${this.drag_enter}
|
|
@dragleave=${this.drag_leave}
|
|
>
|
|
${Object.keys(this.files)
|
|
.sort()
|
|
.map((x) => self.render_file(x))}
|
|
</div>
|
|
<div
|
|
?hidden=${this.dropping == 0}
|
|
@drop=${this.drop}
|
|
@dragenter=${this.drag_enter}
|
|
@dragleave=${this.drag_leave}
|
|
style="text-align: center; vertical-align: middle; outline: 16px solid red; margin: -8px; background-color: rgba(255, 0, 0, 0.5); position: absolute; left: 16px; top: 16px; width: calc(100% - 16px); height: calc(100% - 16px); z-index: 1000"
|
|
>
|
|
Drop File(s)
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
customElements.define('tf-files', TfFilesElement);
|
|
|
|
/**
|
|
* TODOC
|
|
*/
|
|
class TfFilesPaneElement extends LitElement {
|
|
static get properties() {
|
|
return {
|
|
expanded: {type: Boolean},
|
|
current: {type: String},
|
|
files: {type: Object},
|
|
};
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
this.expanded = window.localStorage.getItem('files') != '0';
|
|
this.files = {};
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} expanded
|
|
*/
|
|
set_expanded(expanded) {
|
|
this.expanded = expanded;
|
|
window.localStorage.setItem('files', expanded ? '1' : '0');
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @returns
|
|
*/
|
|
render() {
|
|
let self = this;
|
|
let expander = this.expanded
|
|
? html`<div class="w3-button w3-bar-item w3-blue" style="flex: 0 0 auto; display: flex; flex-direction: row" @click=${() => self.set_expanded(false)}>
|
|
<span style="flex: 1 1" font-weight: bold; text-align: center; flex: 1">Files</span>
|
|
<span style="flex: 0 0">«</span>
|
|
</div>`
|
|
: html`<div
|
|
class="w3-button w3-bar-item w3-blue"
|
|
@click=${() => self.set_expanded(true)}
|
|
>
|
|
»
|
|
</div>`;
|
|
let content = html`
|
|
<tf-files
|
|
style="flex: 1 1; overflow: auto"
|
|
.files=${self.files}
|
|
current=${self.current}
|
|
@file_click=${(event) => openFile(event.detail.file)}
|
|
></tf-files>
|
|
<div>
|
|
<button
|
|
class="w3-bar-item w3-button w3-blue"
|
|
style="width: 100%; flex: 0 0"
|
|
@click=${() => newFile()}
|
|
accesskey="n"
|
|
@mouseover=${set_access_key_title}
|
|
data-tip="Add a new, empty file to the app"
|
|
>
|
|
📄 New File
|
|
</button>
|
|
</div>
|
|
<div>
|
|
<button
|
|
class="w3-bar-item w3-button w3-blue"
|
|
style="width: 100%; flex: 0 0"
|
|
@click=${() => removeFile()}
|
|
accesskey="r"
|
|
@mouseover=${set_access_key_title}
|
|
data-tip="Remove the selected file from the app"
|
|
>
|
|
🚮 Remove File
|
|
</button>
|
|
</div>
|
|
`;
|
|
return html`
|
|
<link type="text/css" rel="stylesheet" href="/static/w3.css" />
|
|
<div style="display: flex; flex-direction: column; height: 100%">
|
|
${expander} ${this.expanded ? content : undefined}
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
customElements.define('tf-files-pane', TfFilesPaneElement);
|
|
|
|
/**
|
|
* TODOC
|
|
*/
|
|
class TfSparkLineElement extends LitElement {
|
|
static get properties() {
|
|
return {
|
|
lines: {type: Array},
|
|
min: {type: Number},
|
|
max: {type: Number},
|
|
};
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
this.min = 0;
|
|
this.max = 1.0;
|
|
this.lines = [];
|
|
this.k_values_max = 100;
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} key
|
|
* @param {*} value
|
|
*/
|
|
append(key, value) {
|
|
let line = null;
|
|
for (let it of this.lines) {
|
|
if (it.name == key) {
|
|
line = it;
|
|
break;
|
|
}
|
|
}
|
|
if (!line) {
|
|
const k_colors = ['#0f0', '#88f', '#ff0', '#f0f', '#0ff', '#f00', '#888'];
|
|
line = {
|
|
name: key,
|
|
style: k_colors[this.lines.length % k_colors.length],
|
|
values: Array(this.k_values_max).fill(0),
|
|
};
|
|
this.lines.push(line);
|
|
}
|
|
if (line.values.length >= this.k_values_max) {
|
|
line.values.shift();
|
|
}
|
|
line.values.push(value);
|
|
this.requestUpdate();
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} line
|
|
* @returns
|
|
*/
|
|
render_line(line) {
|
|
if (line?.values?.length >= 2) {
|
|
let max = Math.max(this.max, ...line.values);
|
|
let points = [].concat(
|
|
...line.values.map((x, i) => [
|
|
(50.0 * i) / (line.values.length - 1),
|
|
10.0 - (10.0 * (x - this.min)) / (max - this.min),
|
|
])
|
|
);
|
|
return svg`<polyline points=${points.join(' ')} stroke=${line.style} fill="none"/>`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @returns
|
|
*/
|
|
render() {
|
|
let max =
|
|
Math.round(
|
|
10.0 *
|
|
Math.max(
|
|
...this.lines.map((line) => line.values[line.values.length - 1])
|
|
)
|
|
) / 10.0;
|
|
return html`
|
|
<svg
|
|
style="max-width: 7.5em; margin: 0; padding: 0; background: #000; height: 1em"
|
|
viewBox="0 0 50 10"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
${this.lines.map((x) => this.render_line(x))}
|
|
<text x="0" y="1em" style="font: 8px sans-serif; fill: #fff">
|
|
${this.dataset.emoji}${max}
|
|
</text>
|
|
</svg>
|
|
`;
|
|
}
|
|
}
|
|
|
|
customElements.define('tf-sparkline', TfSparkLineElement);
|
|
|
|
// TODOC
|
|
window.addEventListener('keydown', function (event) {
|
|
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();
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} nodes
|
|
* @param {*} callback
|
|
* @returns
|
|
*/
|
|
function ensureLoaded(nodes, callback) {
|
|
if (!nodes.length) {
|
|
callback();
|
|
return;
|
|
}
|
|
|
|
let search = nodes.shift();
|
|
let head = document.head;
|
|
let found = false;
|
|
for (let i = 0; i < head.childNodes.length; i++) {
|
|
if (head.childNodes[i].tagName == search.tagName) {
|
|
let match = true;
|
|
for (let 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 {
|
|
let node = document.createElement(search.tagName);
|
|
node.onreadystatechange = node.onload = function () {
|
|
ensureLoaded(nodes, callback);
|
|
};
|
|
for (let attribute in search.attributes) {
|
|
node.setAttribute(attribute, search.attributes[attribute]);
|
|
}
|
|
head.insertBefore(node, head.firstChild);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @returns
|
|
*/
|
|
function editing() {
|
|
return document.getElementById('editPane').style.display != 'none';
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @returns
|
|
*/
|
|
function is_edit_only() {
|
|
return window.location.search == '?editonly=1' || window.innerWidth < 1024;
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @returns
|
|
*/
|
|
async function edit() {
|
|
if (editing()) {
|
|
return;
|
|
}
|
|
|
|
window.localStorage.setItem('editing', '1');
|
|
document.getElementById('editPane').style.display = 'flex';
|
|
document.getElementById('viewPane').style.display = is_edit_only()
|
|
? 'none'
|
|
: 'flex';
|
|
|
|
try {
|
|
if (!gEditor) {
|
|
cm6 = await import('/codemirror/cm6.js');
|
|
gEditor = cm6.TildeFriendsEditorView(document.getElementById('editor'));
|
|
}
|
|
gEditor.onDocChange = updateFiles;
|
|
await load();
|
|
} catch (error) {
|
|
alert(`${error.message}\n\n${error.stack}`);
|
|
closeEditor();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
*/
|
|
function trace() {
|
|
window.open(`/speedscope/#profileURL=${encodeURIComponent('/trace')}`);
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} name
|
|
* @returns
|
|
*/
|
|
function guessMode(name) {
|
|
return name.endsWith('.js')
|
|
? 'javascript'
|
|
: name.endsWith('.html')
|
|
? 'htmlmixed'
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} name
|
|
* @param {*} id
|
|
* @returns
|
|
*/
|
|
function loadFile(name, id) {
|
|
return fetch('/' + id + '/view')
|
|
.then(function (response) {
|
|
if (!response.ok) {
|
|
alert(
|
|
`Request failed for ${name}: ${response.status} ${response.statusText}`
|
|
);
|
|
return 'missing file!';
|
|
}
|
|
return response.text();
|
|
})
|
|
.then(function (text) {
|
|
gFiles[name].doc = cm6.EditorState.create({
|
|
doc: text,
|
|
extensions: cm6.extensions,
|
|
});
|
|
gFiles[name].original = gFiles[name].doc.doc.toString();
|
|
if (!Object.values(gFiles).some((x) => !x.doc)) {
|
|
openFile(Object.keys(gFiles).sort()[0]);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} path
|
|
* @returns
|
|
*/
|
|
async function load(path) {
|
|
let response = await fetch((path || url()) + 'view');
|
|
let json;
|
|
if (response.ok) {
|
|
json = await response.json();
|
|
} else if (response.status != 404) {
|
|
throw new Error(response.status + ' ' + response.statusText);
|
|
}
|
|
gFiles = {};
|
|
let isApp = false;
|
|
let promises = [];
|
|
|
|
if (json && json['type'] == 'tildefriends-app') {
|
|
isApp = true;
|
|
Object.keys(json['files']).forEach(function (name) {
|
|
gFiles[name] = {};
|
|
promises.push(loadFile(name, json['files'][name]));
|
|
});
|
|
if (Object.keys(json['files']).length == 0) {
|
|
document.getElementById('editPane').style.display = 'flex';
|
|
}
|
|
gApp = json;
|
|
gApp.emoji = gApp.emoji || '📦';
|
|
document.getElementById('icon').innerHTML = gApp.emoji;
|
|
}
|
|
if (!isApp) {
|
|
document.getElementById('editPane').style.display = 'flex';
|
|
let text = '// New script.\n';
|
|
gCurrentFile = 'app.js';
|
|
gFiles[gCurrentFile] = {
|
|
doc: cm6.EditorState.create({doc: text, extensions: cm6.extensions}),
|
|
};
|
|
openFile(gCurrentFile);
|
|
}
|
|
return Promise.all(promises);
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
*/
|
|
function closeEditor() {
|
|
window.localStorage.setItem('editing', '0');
|
|
document.getElementById('editPane').style.display = 'none';
|
|
document.getElementById('viewPane').style.display = 'flex';
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @returns
|
|
*/
|
|
function explodePath() {
|
|
return /^\/~([^\/]+)\/([^\/]+)(.*)/.exec(window.location.pathname);
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} save_to
|
|
* @returns
|
|
*/
|
|
function save(save_to) {
|
|
document.getElementById('save').disabled = true;
|
|
if (gCurrentFile) {
|
|
gFiles[gCurrentFile].doc = gEditor.state;
|
|
if (
|
|
!gFiles[gCurrentFile].isNew &&
|
|
!gFiles[gCurrentFile].doc.doc.toString() == gFiles[gCurrentFile].original
|
|
) {
|
|
delete gFiles[gCurrentFile].buffer;
|
|
}
|
|
}
|
|
|
|
let save_path = save_to;
|
|
if (!save_path) {
|
|
let name = document.getElementById('name');
|
|
if (name && name.value) {
|
|
save_path = name.value;
|
|
} else {
|
|
save_path = url();
|
|
}
|
|
}
|
|
|
|
let promises = [];
|
|
for (let name of Object.keys(gFiles)) {
|
|
let file = gFiles[name];
|
|
if (!file.isNew && file.doc.doc.toString() == file.original) {
|
|
continue;
|
|
}
|
|
|
|
delete file.id;
|
|
delete file.isNew;
|
|
promises.push(
|
|
fetch('/save', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/binary',
|
|
},
|
|
body: file.buffer ?? file.doc.doc.toString(),
|
|
})
|
|
.then(function (response) {
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
'Saving "' +
|
|
name +
|
|
'": ' +
|
|
response.status +
|
|
' ' +
|
|
response.statusText
|
|
);
|
|
}
|
|
return response.text();
|
|
})
|
|
.then(function (text) {
|
|
file.id = text;
|
|
if (file.id.charAt(0) == '/') {
|
|
file.id = file.id.substr(1);
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
return Promise.all(promises)
|
|
.then(function () {
|
|
let app = {
|
|
type: 'tildefriends-app',
|
|
files: Object.fromEntries(
|
|
Object.keys(gFiles).map((x) => [x, gFiles[x].id || gApp.files[x]])
|
|
),
|
|
emoji: gApp.emoji || '📦',
|
|
};
|
|
Object.values(gFiles).forEach(function (file) {
|
|
delete file.id;
|
|
});
|
|
gApp = JSON.parse(JSON.stringify(app));
|
|
|
|
return fetch(save_path + 'save', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(app),
|
|
}).then(function (response) {
|
|
if (!response.ok) {
|
|
throw new Error(response.status + ' ' + response.statusText);
|
|
}
|
|
|
|
if (save_path != window.location.pathname) {
|
|
alert('Saved to ' + save_path + '.');
|
|
} else {
|
|
reconnect(save_path);
|
|
}
|
|
});
|
|
})
|
|
.catch(function (error) {
|
|
alert(error);
|
|
})
|
|
.finally(function () {
|
|
document.getElementById('save').disabled = false;
|
|
Object.values(gFiles).forEach(function (file) {
|
|
file.original = file.doc.doc.toString();
|
|
});
|
|
updateFiles();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
*/
|
|
function changeIcon() {
|
|
let value = prompt('Enter a new app icon emoji:');
|
|
if (value !== undefined) {
|
|
gApp.emoji = value || '📦';
|
|
document.getElementById('icon').innerHTML = gApp.emoji;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
*/
|
|
function deleteApp() {
|
|
let name = document.getElementById('name');
|
|
let path = name && name.value ? name.value : url();
|
|
|
|
if (confirm(`Are you sure you want to delete the app '${path}'?`)) {
|
|
fetch(path + 'delete')
|
|
.then(function (response) {
|
|
if (!response.ok) {
|
|
throw new Error(response.status + ' ' + response.statusText);
|
|
}
|
|
alert('Deleted.');
|
|
})
|
|
.catch(function (error) {
|
|
alert(error);
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @returns
|
|
*/
|
|
function url() {
|
|
let hash = window.location.href.indexOf('#');
|
|
let question = window.location.href.indexOf('?');
|
|
let 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;
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @returns
|
|
*/
|
|
function hash() {
|
|
return window.location.hash != '#' ? window.location.hash : '';
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} content
|
|
*/
|
|
function api_setDocument(content) {
|
|
let iframe = document.getElementById('document');
|
|
iframe.srcdoc = content;
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} message
|
|
*/
|
|
function api_postMessage(message) {
|
|
let iframe = document.getElementById('document');
|
|
iframe.contentWindow.postMessage(message, '*');
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} error
|
|
*/
|
|
function api_error(error) {
|
|
if (error) {
|
|
if (typeof error == 'string') {
|
|
setStatusMessage('⚠️ ' + error, kErrorColor);
|
|
} else {
|
|
setStatusMessage('⚠️ ' + error.message + '\n' + error.stack, kErrorColor);
|
|
}
|
|
}
|
|
console.log('error', error);
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} key
|
|
* @param {*} value
|
|
*/
|
|
function api_localStorageSet(key, value) {
|
|
window.localStorage.setItem('app:' + key, value);
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} key
|
|
* @returns
|
|
*/
|
|
function api_localStorageGet(key) {
|
|
return window.localStorage.getItem('app:' + key);
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} permission
|
|
* @param {*} id
|
|
* @returns
|
|
*/
|
|
function api_requestPermission(permission, id) {
|
|
let outer = document.createElement('div');
|
|
outer.classList.add('permissions');
|
|
|
|
let container = document.createElement('div');
|
|
container.classList.add('permissions_contents');
|
|
|
|
let div = document.createElement('div');
|
|
div.appendChild(
|
|
document.createTextNode('This app is requesting the following permission:')
|
|
);
|
|
let span = document.createElement('span');
|
|
span.style = 'font-weight: bold';
|
|
span.appendChild(document.createTextNode(permission));
|
|
div.appendChild(span);
|
|
container.appendChild(div);
|
|
|
|
div = document.createElement('div');
|
|
div.style = 'padding: 1em';
|
|
let check = document.createElement('input');
|
|
check.id = 'permissions_remember_check';
|
|
check.type = 'checkbox';
|
|
check.classList.add('w3-check');
|
|
check.classList.add('w3-blue');
|
|
div.appendChild(check);
|
|
let label = document.createElement('label');
|
|
label.htmlFor = check.id;
|
|
label.appendChild(document.createTextNode('Remember this decision.'));
|
|
div.appendChild(label);
|
|
container.appendChild(div);
|
|
|
|
const k_options = [
|
|
{
|
|
id: 'allow',
|
|
text: '✅ Allow',
|
|
grant: ['allow once', 'allow'],
|
|
},
|
|
{
|
|
id: 'deny',
|
|
text: '❌ Deny',
|
|
grant: ['deny once', 'deny'],
|
|
},
|
|
];
|
|
|
|
return new Promise(function (resolve, reject) {
|
|
div = document.createElement('div');
|
|
for (let option of k_options) {
|
|
let button = document.createElement('button');
|
|
button.classList.add('w3-button');
|
|
button.classList.add('w3-blue');
|
|
button.innerText = option.text;
|
|
button.id = option.id;
|
|
button.onclick = function () {
|
|
resolve(option.grant[check.checked ? 1 : 0]);
|
|
document.body.removeChild(outer);
|
|
};
|
|
div.appendChild(button);
|
|
}
|
|
container.appendChild(div);
|
|
outer.appendChild(container);
|
|
|
|
document.body.appendChild(outer);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
*/
|
|
function api_print() {
|
|
console.log('app>', ...arguments);
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} hash
|
|
*/
|
|
function api_setHash(hash) {
|
|
window.location.hash = hash;
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} message
|
|
*/
|
|
function _receive_websocket_message(message) {
|
|
if (message && message.action == 'session') {
|
|
setStatusMessage('🟢 Executing...', kStatusColor);
|
|
let navigation = document.getElementsByTagName('tf-navigation')[0];
|
|
navigation.credentials = message.credentials;
|
|
navigation.identities = message.identities;
|
|
navigation.identity = message.identity;
|
|
navigation.names = message.names;
|
|
} else if (message && message.action == 'permissions') {
|
|
let navigation = document.getElementsByTagName('tf-navigation')[0];
|
|
navigation.permissions = message.permissions ?? {};
|
|
} else if (message && message.action == 'identities') {
|
|
let navigation = document.getElementsByTagName('tf-navigation')[0];
|
|
navigation.identities = message.identities;
|
|
navigation.identity = message.identity;
|
|
navigation.names = message.names;
|
|
} else if (message && message.action == 'ready') {
|
|
setStatusMessage(null);
|
|
if (window.location.hash) {
|
|
send({event: 'hashChange', hash: window.location.hash});
|
|
}
|
|
document.getElementsByTagName('tf-navigation')[0].version = message.version;
|
|
document.getElementById('viewPane').style.display = message.edit_only
|
|
? 'none'
|
|
: 'flex';
|
|
send({action: 'enableStats', enabled: true});
|
|
} else if (message && message.action == 'ping') {
|
|
send({action: 'pong'});
|
|
} else if (message && message.action == 'stats') {
|
|
let now = new Date().getTime();
|
|
for (let key of Object.keys(message.stats)) {
|
|
const k_groups = {
|
|
rpc_in: {group: 'rpc', name: 'in'},
|
|
rpc_out: {group: 'rpc', name: 'out'},
|
|
|
|
cpu_percent: {group: 'cpu', name: 'main'},
|
|
thread_percent: {group: 'cpu', name: 'work'},
|
|
|
|
arena_percent: {group: 'memory', name: 'm'},
|
|
js_malloc_percent: {group: 'memory', name: 'js'},
|
|
memory_percent: {group: 'memory', name: 'tot'},
|
|
sqlite3_memory_percent: {group: 'memory', name: 'sql'},
|
|
tf_malloc_percent: {group: 'memory', name: 'tf'},
|
|
tls_malloc_percent: {group: 'memory', name: 'tls'},
|
|
uv_malloc_percent: {group: 'memory', name: 'uv'},
|
|
|
|
messages_stored: {group: 'store', name: 'messages'},
|
|
blobs_stored: {group: 'store', name: 'blobs'},
|
|
|
|
socket_count: {group: 'socket', name: 'total'},
|
|
socket_open_count: {group: 'socket', name: 'open'},
|
|
|
|
import_count: {group: 'functions', name: 'imports'},
|
|
export_count: {group: 'functions', name: 'exports'},
|
|
};
|
|
const k_colors = ['#0f0', '#88f', '#ff0', '#f0f', '#0ff', '#f00', '#888'];
|
|
let graph_key = k_groups[key]?.group || key;
|
|
if (['cpu', 'rpc', 'store', 'memory'].indexOf(graph_key) != -1) {
|
|
let line = document
|
|
.getElementsByTagName('tf-navigation')[0]
|
|
.get_spark_line(graph_key, {max: 100});
|
|
line.dataset.emoji = {
|
|
cpu: '💻',
|
|
rpc: '🔁',
|
|
store: '💾',
|
|
memory: '🐏',
|
|
}[graph_key];
|
|
line.append(key, message.stats[key]);
|
|
}
|
|
}
|
|
} else if (message && message.message === 'tfrpc' && message.method) {
|
|
let api = k_api[message.method];
|
|
let id = message.id;
|
|
let params = message.params;
|
|
if (api) {
|
|
Promise.resolve(api.func(...params))
|
|
.then(function (result) {
|
|
send({
|
|
message: 'tfrpc',
|
|
id: id,
|
|
result: result,
|
|
});
|
|
})
|
|
.catch(function (error) {
|
|
send({
|
|
message: 'tfrpc',
|
|
id: id,
|
|
error: error,
|
|
});
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} message
|
|
* @param {*} color
|
|
*/
|
|
function setStatusMessage(message, color) {
|
|
document.getElementsByTagName('tf-navigation')[0].status = {
|
|
message: message,
|
|
color: color,
|
|
is_error: color == kErrorColor,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} value
|
|
*/
|
|
function send(value) {
|
|
try {
|
|
if (gSocket && gSocket.readyState == gSocket.OPEN) {
|
|
gSocket.send(JSON.stringify(value));
|
|
}
|
|
} catch (error) {
|
|
setStatusMessage('🤷 Send failed: ' + error.toString(), kErrorColor);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} sourceData
|
|
* @param {*} maxWidth
|
|
* @param {*} maxHeight
|
|
* @param {*} callback
|
|
*/
|
|
function fixImage(sourceData, maxWidth, maxHeight, callback) {
|
|
let result = sourceData;
|
|
let image = new Image();
|
|
image.crossOrigin = 'anonymous';
|
|
image.referrerPolicy = 'no-referrer';
|
|
image.onload = function () {
|
|
if (image.width > maxWidth || image.height > maxHeight) {
|
|
let downScale = Math.min(
|
|
maxWidth / image.width,
|
|
maxHeight / image.height
|
|
);
|
|
let canvas = document.createElement('canvas');
|
|
canvas.width = image.width * downScale;
|
|
canvas.height = image.height * downScale;
|
|
let 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;
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} image
|
|
*/
|
|
function sendImage(image) {
|
|
fixImage(image, 320, 240, function (result) {
|
|
send({image: result});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
*/
|
|
function hashChange() {
|
|
send({event: 'hashChange', hash: window.location.hash});
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
*/
|
|
function focus() {
|
|
if (gSocket && gSocket.readyState == gSocket.CLOSED) {
|
|
connectSocket();
|
|
} else {
|
|
send({event: 'focus'});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
*/
|
|
function blur() {
|
|
if (gSocket && gSocket.readyState == gSocket.OPEN) {
|
|
send({event: 'blur'});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} event
|
|
*/
|
|
function message(event) {
|
|
if (
|
|
event.data &&
|
|
event.data.event == 'resizeMe' &&
|
|
event.data.width &&
|
|
event.data.height
|
|
) {
|
|
let 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 if (event.data && event.data.action == 'storeBlob') {
|
|
fetch('/save', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/binary',
|
|
},
|
|
body: event.data.blob.buffer,
|
|
})
|
|
.then(function (response) {
|
|
if (!response.ok) {
|
|
throw new Error(response.status + ' ' + response.statusText);
|
|
}
|
|
return response.text();
|
|
})
|
|
.then(function (text) {
|
|
let iframe = document.getElementById('document');
|
|
iframe.contentWindow.postMessage(
|
|
{
|
|
storeBlobComplete: {
|
|
name: event.data.blob.name,
|
|
path: text,
|
|
type: event.data.blob.type,
|
|
context: event.data.context,
|
|
},
|
|
},
|
|
'*'
|
|
);
|
|
});
|
|
} else {
|
|
send({event: 'message', message: event.data});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} path
|
|
*/
|
|
function reconnect(path) {
|
|
let oldSocket = gSocket;
|
|
gSocket = null;
|
|
if (oldSocket) {
|
|
oldSocket.onopen = null;
|
|
oldSocket.onclose = null;
|
|
oldSocket.onmessage = null;
|
|
oldSocket.close();
|
|
}
|
|
connectSocket(path);
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} 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);
|
|
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);
|
|
let connect_path = path ?? window.location.pathname;
|
|
gSocket.send(
|
|
JSON.stringify({
|
|
action: 'hello',
|
|
path: connect_path,
|
|
url: window.location.href,
|
|
edit_only: editing() && is_edit_only(),
|
|
api: Object.entries(k_api).map(([key, value]) =>
|
|
[].concat([key], value.args)
|
|
),
|
|
})
|
|
);
|
|
};
|
|
gSocket.onmessage = function (event) {
|
|
_receive_websocket_message(JSON.parse(event.data));
|
|
};
|
|
gSocket.onclose = function (event) {
|
|
const k_codes = {
|
|
1000: 'Normal closure',
|
|
1001: 'Going away',
|
|
1002: 'Protocol error',
|
|
1003: 'Unsupported data',
|
|
1005: 'No status received',
|
|
1006: 'Abnormal closure',
|
|
1007: 'Invalid frame payload data',
|
|
1008: 'Policy violation',
|
|
1009: 'Message too big',
|
|
1010: 'Missing extension',
|
|
1011: 'Internal error',
|
|
1012: 'Service restart',
|
|
1013: 'Try again later',
|
|
1014: 'Bad gateway',
|
|
1015: 'TLS handshake',
|
|
};
|
|
setStatusMessage(
|
|
'🔴 Closed: ' + (k_codes[event.code] || event.code),
|
|
kDisconnectColor
|
|
);
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} name
|
|
*/
|
|
function openFile(name) {
|
|
let newDoc =
|
|
name && gFiles[name]
|
|
? gFiles[name].doc
|
|
: cm6.EditorState.create({doc: '', extensions: cm6.extensions});
|
|
let oldDoc = gEditor.state;
|
|
gEditor.setState(newDoc);
|
|
|
|
if (gFiles[gCurrentFile]) {
|
|
gFiles[gCurrentFile].doc = oldDoc;
|
|
if (
|
|
!gFiles[gCurrentFile].isNew &&
|
|
gFiles[gCurrentFile].doc.doc.toString() == oldDoc.doc.toString()
|
|
) {
|
|
delete gFiles[gCurrentFile].buffer;
|
|
}
|
|
}
|
|
gCurrentFile = name;
|
|
updateFiles();
|
|
gEditor.focus();
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
*/
|
|
function updateFiles() {
|
|
let files = document.getElementsByTagName('tf-files-pane')[0];
|
|
if (files) {
|
|
files.files = Object.fromEntries(
|
|
Object.keys(gFiles).map((file) => [
|
|
file,
|
|
{
|
|
clean:
|
|
(file == gCurrentFile
|
|
? gEditor.state.doc.toString()
|
|
: gFiles[file].doc.doc.toString()) == gFiles[file].original,
|
|
},
|
|
])
|
|
);
|
|
files.current = gCurrentFile;
|
|
}
|
|
gEditor.focus();
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} name
|
|
*/
|
|
function makeNewFile(name) {
|
|
gFiles[name] = {
|
|
doc: cm6.EditorState.create({extensions: cm6.extensions}),
|
|
};
|
|
openFile(name);
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
*/
|
|
function newFile() {
|
|
let name = prompt('Name of new file:', 'file.js');
|
|
if (name && !gFiles[name]) {
|
|
makeNewFile(name);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
*/
|
|
function removeFile() {
|
|
if (confirm('Remove ' + gCurrentFile + '?')) {
|
|
delete gFiles[gCurrentFile];
|
|
openFile(Object.keys(gFiles)[0]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
*/
|
|
async function appExport() {
|
|
let JsZip = (await import('/static/jszip.min.js')).default;
|
|
let owner = window.location.pathname.split('/')[1].replace('~', '');
|
|
let name = window.location.pathname.split('/')[2];
|
|
let zip = new JsZip();
|
|
zip.file(
|
|
`${name}.json`,
|
|
JSON.stringify({
|
|
type: 'tildefriends-app',
|
|
emoji: gApp.emoji || '📦',
|
|
})
|
|
);
|
|
for (let file of Object.keys(gFiles)) {
|
|
zip.file(
|
|
`${name}/${file}`,
|
|
gFiles[file].buffer ?? gFiles[file].doc.doc.toString()
|
|
);
|
|
}
|
|
let content = await zip.generateAsync({
|
|
type: 'base64',
|
|
compression: 'DEFLATE',
|
|
});
|
|
let a = document.createElement('a');
|
|
a.href = `data:application/zip;base64,${content}`;
|
|
a.download = `${owner}_${name}.zip`;
|
|
a.click();
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
* @param {*} name
|
|
* @param {*} file
|
|
* @returns
|
|
*/
|
|
async function save_file_to_blob_id(name, file) {
|
|
console.log(`Saving ${name}.`);
|
|
let response = await fetch('/save', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/binary',
|
|
},
|
|
body: file,
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
'Saving "' + name + '": ' + response.status + ' ' + response.statusText
|
|
);
|
|
}
|
|
let blob_id = await response.text();
|
|
if (blob_id.charAt(0) == '/') {
|
|
blob_id = blob_id.substr(1);
|
|
}
|
|
return blob_id;
|
|
}
|
|
|
|
/**
|
|
* TODOC
|
|
*/
|
|
async function appImport() {
|
|
let JsZip = (await import('/static/jszip.min.js')).default;
|
|
let input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.click();
|
|
input.onchange = async function () {
|
|
try {
|
|
for (let file of input.files) {
|
|
if (file.type != 'application/zip') {
|
|
console.log(`This does not look like a .zip (${file.type}).`);
|
|
continue;
|
|
}
|
|
let buffer = new Uint8Array(await file.arrayBuffer());
|
|
console.log(
|
|
'ZIP',
|
|
file.name,
|
|
file.type,
|
|
buffer,
|
|
buffer?.byteLength,
|
|
buffer?.length
|
|
);
|
|
let zip = new JsZip();
|
|
await zip.loadAsync(buffer);
|
|
let app_object;
|
|
let app_name;
|
|
for (let [name, object] of Object.entries(zip.files)) {
|
|
if (name.endsWith('.json') && name.indexOf('/') == -1) {
|
|
try {
|
|
let parsed = JSON.parse(await object.async('text'));
|
|
if (parsed.type == 'tildefriends-app') {
|
|
app_object = parsed;
|
|
app_name = name.substring(0, name.length - '.json'.length);
|
|
break;
|
|
}
|
|
} catch (e) {
|
|
console.log(e);
|
|
}
|
|
}
|
|
}
|
|
if (app_object) {
|
|
app_object.files = {};
|
|
for (let [name, object] of Object.entries(zip.files)) {
|
|
if (!name.startsWith(app_name + '/') || name.endsWith('/')) {
|
|
continue;
|
|
}
|
|
app_object.files[name.substring(app_name.length + '/'.length)] =
|
|
await save_file_to_blob_id(
|
|
name,
|
|
await object.async('arrayBuffer')
|
|
);
|
|
}
|
|
let path =
|
|
'/' +
|
|
(await save_file_to_blob_id(
|
|
`${app_name}.json`,
|
|
JSON.stringify(app_object)
|
|
)) +
|
|
'/';
|
|
console.log('Redirecting to:', path);
|
|
window.location.pathname = path;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
alert(e.toString());
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
*
|
|
*/
|
|
async function sourcePretty() {
|
|
let prettier = (await import('/prettier/standalone.mjs')).default;
|
|
let babel = (await import('/prettier/babel.mjs')).default;
|
|
let estree = (await import('/prettier/estree.mjs')).default;
|
|
let source = gEditor.state.doc.toString();
|
|
let formatted = await prettier.format(source, {
|
|
parser: 'babel',
|
|
plugins: [babel, estree],
|
|
trailingComma: 'es5',
|
|
useTabs: true,
|
|
semi: true,
|
|
singleQuote: true,
|
|
bracketSpacing: false,
|
|
});
|
|
if (source !== formatted) {
|
|
gEditor.dispatch({
|
|
changes: {
|
|
from: 0,
|
|
to: gEditor.state.doc.length,
|
|
insert: formatted,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
function toggleVisibleWhitespace() {
|
|
let editor_style = document.getElementById('editor_style');
|
|
/*
|
|
* There is likely a better way to do this, but stomping on the CSS was
|
|
* the easiest to wrangle at the time.
|
|
*/
|
|
if (editor_style.innerHTML.length) {
|
|
editor_style.innerHTML = '';
|
|
window.localStorage.setItem('visible_whitespace', '0');
|
|
} else {
|
|
editor_style.innerHTML = css`
|
|
.cm-trailingSpace {
|
|
background-color: unset !important;
|
|
}
|
|
.cm-highlightTab {
|
|
background-image: unset !important;
|
|
}
|
|
.cm-highlightSpace:before {
|
|
content: unset !important;
|
|
}
|
|
`;
|
|
window.localStorage.setItem('visible_whitespace', '1');
|
|
}
|
|
}
|
|
|
|
// TODOC
|
|
window.addEventListener('load', function () {
|
|
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;
|
|
document
|
|
.getElementById('closeEditor')
|
|
.addEventListener('click', () => closeEditor());
|
|
document.getElementById('save').addEventListener('click', () => save());
|
|
document.getElementById('icon').addEventListener('click', () => changeIcon());
|
|
document
|
|
.getElementById('delete')
|
|
.addEventListener('click', () => deleteApp());
|
|
document
|
|
.getElementById('export')
|
|
.addEventListener('click', () => appExport());
|
|
document
|
|
.getElementById('import')
|
|
.addEventListener('click', () => appImport());
|
|
document
|
|
.getElementById('pretty')
|
|
.addEventListener('click', () => sourcePretty());
|
|
document
|
|
.getElementById('whitespace')
|
|
.addEventListener('click', () => toggleVisibleWhitespace());
|
|
document
|
|
.getElementById('trace_button')
|
|
.addEventListener('click', function (event) {
|
|
event.preventDefault();
|
|
trace();
|
|
});
|
|
connectSocket(window.location.pathname);
|
|
|
|
if (window.localStorage.getItem('editing') == '1') {
|
|
edit();
|
|
} else {
|
|
closeEditor();
|
|
}
|
|
if (window.localStorage.getItem('visible_whitespace') == '1') {
|
|
toggleVisibleWhitespace();
|
|
}
|
|
});
|