Some checks are pending
		
		
	
	Build Tilde Friends / Build-All (push) Waiting to run
				
			
		
			
				
	
	
		
			1884 lines
		
	
	
		
			42 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			1884 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_expanded: {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.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"
 | 
						|
						>
 | 
						|
							<div
 | 
						|
								style="position: fixed; left: 0; right: 0; top: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.25); z-index: -100"
 | 
						|
								@click=${self.toggle_id_dropdown}
 | 
						|
							></div>
 | 
						|
							<button
 | 
						|
								class="w3-bar-item w3-button w3-border"
 | 
						|
								@click=${() => (window.location.href = '/~core/identity')}
 | 
						|
							>
 | 
						|
								Manage Identities...
 | 
						|
							</button>
 | 
						|
							<button
 | 
						|
								id="edit_profile"
 | 
						|
								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_expanded = !this.show_expanded)}
 | 
						|
					>😎</span
 | 
						|
				>
 | 
						|
				<span
 | 
						|
					class="w3-bar-item"
 | 
						|
					style=${'white-space: nowrap' +
 | 
						|
					(this.show_expanded ? '' : '; 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}
 | 
						|
				<span class=${this.show_expanded ? '' : 'w3-hide-small'}>
 | 
						|
					${Object.keys(this.spark_lines)
 | 
						|
						.sort()
 | 
						|
						.map((x) => this.spark_lines[x])}
 | 
						|
				</span>
 | 
						|
				${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 id="close_error" @click=${self.clear_error} class="w3-button w3-display-topright">×</span>
 | 
						|
							<div style="color: ${this.status.color ?? kErrorColor}"><b>ERROR:</b><p id="error" 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);
 | 
						|
	div.appendChild(document.createTextNode(' '));
 | 
						|
	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';
 | 
						|
	} 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 prettier_html = (await import('/prettier/html.mjs')).default;
 | 
						|
	let source = gEditor.state.doc.toString();
 | 
						|
	let formatted = await prettier.format(source, {
 | 
						|
		parser: gCurrentFile?.toLowerCase()?.endsWith('.html') ? 'html' : 'babel',
 | 
						|
		plugins: [babel, estree, prettier_html],
 | 
						|
		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 {
 | 
						|
				background-image: 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();
 | 
						|
	}
 | 
						|
});
 |