forked from cory/tildefriends
		
	
		
			
				
	
	
		
			1907 lines
		
	
	
		
			45 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			1907 lines
		
	
	
		
			45 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /**
 | |
|  * \file
 | |
|  * \defgroup tfclient Tilde Friends Client JS
 | |
|  * Tilde Friends client-side browser JavaScript.
 | |
|  * @{
 | |
|  */
 | |
| 
 | |
| /** \cond */
 | |
| import {LitElement, html, css, svg} from '/lit/lit-all.min.js';
 | |
| 
 | |
| let cm6;
 | |
| 
 | |
| let g_socket;
 | |
| let g_current_file;
 | |
| let g_files = {};
 | |
| let g_app = {files: {}, emoji: '📦'};
 | |
| let g_editor;
 | |
| let g_unloading;
 | |
| 
 | |
| let k_color_error = '#dc322f';
 | |
| let k_color_disconnect = '#f00';
 | |
| let k_color_status = '#fff';
 | |
| /** \endcond */
 | |
| 
 | |
| /** 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},
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * Class that represents the top bar
 | |
|  */
 | |
| class TfNavigationElement extends LitElement {
 | |
| 	/**
 | |
| 	 * Get Lit Html properties.
 | |
| 	 * @return The properties.
 | |
| 	 */
 | |
| 	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},
 | |
| 		};
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Create a TfNavigationElement instance.
 | |
| 	 */
 | |
| 	constructor() {
 | |
| 		super();
 | |
| 		this.permissions = {};
 | |
| 		this.show_permissions = false;
 | |
| 		this.status = {};
 | |
| 		this.spark_lines = {};
 | |
| 		this.identities = [];
 | |
| 		this.names = {};
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Toggle editor visibility.
 | |
| 	 * @param event The HTML event.
 | |
| 	 */
 | |
| 	toggle_edit(event) {
 | |
| 		event.preventDefault();
 | |
| 		if (editing()) {
 | |
| 			closeEditor();
 | |
| 		} else {
 | |
| 			edit();
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Remove a stored permission.
 | |
| 	 * @param key The permission to reset.
 | |
| 	 */
 | |
| 	reset_permission(key) {
 | |
| 		send({action: 'resetPermission', permission: key});
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Get or create a spark line.
 | |
| 	 * @param key The spark line identifier.
 | |
| 	 * @param options Spark line options.
 | |
| 	 * @return A spark line HTML element.
 | |
| 	 */
 | |
| 	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 the active SSB identity for the current application.
 | |
| 	 * @param id The identity.
 | |
| 	 */
 | |
| 	set_active_identity(id) {
 | |
| 		send({action: 'setActiveIdentity', identity: id});
 | |
| 		this.renderRoot.getElementById('id_dropdown').classList.remove('w3-show');
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Create a new SSB identity.
 | |
| 	 */
 | |
| 	create_identity() {
 | |
| 		if (confirm('Are you sure you want to create a new identity?')) {
 | |
| 			send({action: 'createIdentity'});
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Toggle visibility of the ID dropdown.
 | |
| 	 */
 | |
| 	toggle_id_dropdown() {
 | |
| 		this.renderRoot.getElementById('id_dropdown').classList.toggle('w3-show');
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Edit the current identity's SSB profile.
 | |
| 	 */
 | |
| 	edit_profile() {
 | |
| 		window.location.href = '/~core/ssb/#' + this.identity;
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Sign out of the current Tilde Friends user.
 | |
| 	 */
 | |
| 	logout() {
 | |
| 		window.location.href = `/login/logout?return=${encodeURIComponent(url() + hash())}`;
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Render the identity dropdown.
 | |
| 	 * @return Lit HTML.
 | |
| 	 */
 | |
| 	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"
 | |
| 							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
 | |
| 			>`;
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Render the permissions popup.
 | |
| 	 * @return Lit HTML.
 | |
| 	 */
 | |
| 	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"
 | |
| 										id=${'permission_reset:' + key}
 | |
| 									>
 | |
| 										Reset
 | |
| 									</button>
 | |
| 								</div>
 | |
| 							`
 | |
| 						)}
 | |
| 						<button
 | |
| 							@click=${() => (this.show_permissions = false)}
 | |
| 							class="w3-button w3-blue"
 | |
| 							id="permissions_close"
 | |
| 						>
 | |
| 							Close
 | |
| 						</button>
 | |
| 					</div>
 | |
| 				</div>
 | |
| 			`;
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Clear the current error.
 | |
| 	 */
 | |
| 	clear_error() {
 | |
| 		this.status = {};
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Render the navigation bar.
 | |
| 	 * @return Lit HTML.
 | |
| 	 */
 | |
| 	render() {
 | |
| 		let self = this;
 | |
| 		const k_global_style = css`
 | |
| 			a:link {
 | |
| 				color: #268bd2;
 | |
| 			}
 | |
| 
 | |
| 			a:visited {
 | |
| 				color: #6c71c4;
 | |
| 			}
 | |
| 
 | |
| 			a:hover {
 | |
| 				color: #859900;
 | |
| 			}
 | |
| 
 | |
| 			a:active {
 | |
| 				color: #2aa198;
 | |
| 			}
 | |
| 		`;
 | |
| 		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 ?? k_color_status}"
 | |
| 							>
 | |
| 								${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 ?? k_color_error}"><b>ERROR:</b><p id="error" style="white-space: pre">${this.status.message}</p></div>
 | |
| 						</div>
 | |
| 					</div>
 | |
| 					`
 | |
| 				: undefined}
 | |
| 		`;
 | |
| 	}
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Create a tf-navigation element.
 | |
|  */
 | |
| customElements.define('tf-navigation', TfNavigationElement);
 | |
| 
 | |
| /**
 | |
|  * A file in the files sidebar.
 | |
|  */
 | |
| class TfFilesElement extends LitElement {
 | |
| 	/**
 | |
| 	 * LitElement properties.
 | |
| 	 * @return The properties.
 | |
| 	 */
 | |
| 	static get properties() {
 | |
| 		return {
 | |
| 			current: {type: String},
 | |
| 			files: {type: Object},
 | |
| 			dropping: {type: Number},
 | |
| 			drop_target: {type: String},
 | |
| 		};
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Create a TfFilesElement instance.
 | |
| 	 */
 | |
| 	constructor() {
 | |
| 		super();
 | |
| 		this.files = {};
 | |
| 		this.dropping = 0;
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Select a clicked file.
 | |
| 	 * @param file The file.
 | |
| 	 */
 | |
| 	file_click(file) {
 | |
| 		this.dispatchEvent(
 | |
| 			new CustomEvent('file_click', {
 | |
| 				detail: {
 | |
| 					file: file,
 | |
| 				},
 | |
| 				bubbles: true,
 | |
| 				composed: true,
 | |
| 			})
 | |
| 		);
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Render a single file in the file list.
 | |
| 	 * @param file The file.
 | |
| 	 * @return Lit HTML.
 | |
| 	 */
 | |
| 	render_file(file) {
 | |
| 		let classes = ['file'];
 | |
| 		if (file == this.current) {
 | |
| 			classes.push('current');
 | |
| 		}
 | |
| 		if (!this.files[file].clean) {
 | |
| 			classes.push('dirty');
 | |
| 		}
 | |
| 		if (this.drop_target == file) {
 | |
| 			classes.push('drop');
 | |
| 		}
 | |
| 		return html`<div
 | |
| 			class="${classes.join(' ')}"
 | |
| 			@click=${(x) => this.file_click(file)}
 | |
| 		>
 | |
| 			${file}
 | |
| 		</div>`;
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Create a file entry for a dropped file.
 | |
| 	 * @param event The event.
 | |
| 	 */
 | |
| 	async drop(event) {
 | |
| 		event.preventDefault();
 | |
| 		event.stopPropagation();
 | |
| 		this.dropping = 0;
 | |
| 		this.drop_target = undefined;
 | |
| 		for (let file of event.dataTransfer.files) {
 | |
| 			let buffer = await file.arrayBuffer();
 | |
| 			let text = new TextDecoder('latin1').decode(buffer);
 | |
| 			g_files[file.name] = {
 | |
| 				doc: cm6.EditorState.create({
 | |
| 					doc: text,
 | |
| 					extensions: cm6.extensions,
 | |
| 				}),
 | |
| 				buffer: buffer,
 | |
| 				isNew: true,
 | |
| 			};
 | |
| 			g_current_file = file.name;
 | |
| 		}
 | |
| 		openFile(g_current_file);
 | |
| 		updateFiles();
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Called when a file starts being dragged over the file.
 | |
| 	 * @param event The event.
 | |
| 	 */
 | |
| 	drag_enter(event) {
 | |
| 		this.dropping++;
 | |
| 		this.drop_target = event.srcElement.innerText.trim();
 | |
| 		event.preventDefault();
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Called when a file stops being dragged over the file.
 | |
| 	 * @param event The event.
 | |
| 	 */
 | |
| 	drag_leave(event) {
 | |
| 		this.dropping--;
 | |
| 		if (this.dropping == 0) {
 | |
| 			this.drop_target = undefined;
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Called when a file is being dragged over the file.
 | |
| 	 * @param event The event.
 | |
| 	 */
 | |
| 	drag_over(event) {
 | |
| 		event.preventDefault();
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Render the file.
 | |
| 	 * @return Lit HTML.
 | |
| 	 */
 | |
| 	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.drop {
 | |
| 					border: 4px solid red;
 | |
| 				}
 | |
| 
 | |
| 				div.file.dirty::after {
 | |
| 					content: '*';
 | |
| 				}
 | |
| 			</style>
 | |
| 			<div
 | |
| 				@drop=${this.drop}
 | |
| 				@dragenter=${this.drag_enter}
 | |
| 				@dragleave=${this.drag_leave}
 | |
| 				@dragover=${this.drag_over}
 | |
| 			>
 | |
| 				${Object.keys(this.files)
 | |
| 					.sort()
 | |
| 					.map((x) => self.render_file(x))}
 | |
| 			</div>
 | |
| 		`;
 | |
| 	}
 | |
| }
 | |
| 
 | |
| customElements.define('tf-files', TfFilesElement);
 | |
| 
 | |
| /**
 | |
|  * The files pane element.
 | |
|  */
 | |
| class TfFilesPaneElement extends LitElement {
 | |
| 	/**
 | |
| 	 * Get Lit Html properties.
 | |
| 	 * @return The properties.
 | |
| 	 */
 | |
| 	static get properties() {
 | |
| 		return {
 | |
| 			expanded: {type: Boolean},
 | |
| 			current: {type: String},
 | |
| 			files: {type: Object},
 | |
| 		};
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Create a TfFilesPaneElement instance.
 | |
| 	 */
 | |
| 	constructor() {
 | |
| 		super();
 | |
| 		this.expanded = window.localStorage.getItem('files') != '0';
 | |
| 		this.files = {};
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Set whether the files pane is expanded.
 | |
| 	 * @param expanded Whether the files pane is expanded.
 | |
| 	 */
 | |
| 	set_expanded(expanded) {
 | |
| 		this.expanded = expanded;
 | |
| 		window.localStorage.setItem('files', expanded ? '1' : '0');
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Render the files pane element.
 | |
| 	 * @return Lit HTML.
 | |
| 	 */
 | |
| 	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);
 | |
| 
 | |
| /**
 | |
|  * A tiny graph.
 | |
|  */
 | |
| 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;
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Add a data point to the graph.
 | |
| 	 * @param key The line to which the point applies.
 | |
| 	 * @param value The numeric value of the data point.
 | |
| 	 */
 | |
| 	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();
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Render a single series line.
 | |
| 	 * @param line The line data.
 | |
| 	 * @return Lit HTML.
 | |
| 	 */
 | |
| 	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"/>`;
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Render the graph.
 | |
| 	 * @return Lit HTML.
 | |
| 	 */
 | |
| 	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);
 | |
| 
 | |
| /**
 | |
|  *  A keyboard key is pressed down.
 | |
|  */
 | |
| 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();
 | |
| 		}
 | |
| 	}
 | |
| });
 | |
| 
 | |
| /**
 | |
|  * Make sure a set of dependencies are loaded
 | |
|  * @param nodes An array of descriptions of dependencies to load.
 | |
|  * @param callback Called when all dependencies are loaded.
 | |
|  */
 | |
| 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);
 | |
| 	}
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Check whether the editior is currently visible.
 | |
|  * @return true if the editor is visible.
 | |
|  */
 | |
| function editing() {
 | |
| 	return document.getElementById('editPane').style.display != 'none';
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Check whether only the editor is visible and the app is hidden.
 | |
|  * @return true if the editor is visible and the app is not.
 | |
|  */
 | |
| function is_edit_only() {
 | |
| 	return window.location.search == '?editonly=1' || window.innerWidth < 1024;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Show the editor.
 | |
|  */
 | |
| 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 (!g_editor) {
 | |
| 			cm6 = await import('/codemirror/cm6.js');
 | |
| 			g_editor = cm6.TildeFriendsEditorView(document.getElementById('editor'));
 | |
| 		}
 | |
| 		g_editor.onDocChange = updateFiles;
 | |
| 		await load();
 | |
| 	} catch (error) {
 | |
| 		alert(`${error.message}\n\n${error.stack}`);
 | |
| 		closeEditor();
 | |
| 	}
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Open a performance trace.
 | |
|  */
 | |
| function trace() {
 | |
| 	window.open(`/speedscope/#profileURL=${encodeURIComponent('/trace')}`);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Load a single file.
 | |
|  * @param name The name by which the file is known.
 | |
|  * @param id The file's ID.
 | |
|  * @return A promise resolved with the file's contents.
 | |
|  */
 | |
| 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) {
 | |
| 			g_files[name].doc = cm6.EditorState.create({
 | |
| 				doc: text,
 | |
| 				extensions: cm6.extensions,
 | |
| 			});
 | |
| 			g_files[name].original = g_files[name].doc.doc.toString();
 | |
| 			if (!Object.values(g_files).some((x) => !x.doc)) {
 | |
| 				openFile(Object.keys(g_files).sort()[0]);
 | |
| 			}
 | |
| 		});
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Load files for the app.
 | |
|  * @param path The app path to load.
 | |
|  * @return A promise resolved when the app is laoded.
 | |
|  */
 | |
| 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);
 | |
| 	}
 | |
| 	g_files = {};
 | |
| 	let isApp = false;
 | |
| 	let promises = [];
 | |
| 
 | |
| 	if (json && json['type'] == 'tildefriends-app') {
 | |
| 		isApp = true;
 | |
| 		Object.keys(json['files']).forEach(function (name) {
 | |
| 			g_files[name] = {};
 | |
| 			promises.push(loadFile(name, json['files'][name]));
 | |
| 		});
 | |
| 		if (Object.keys(json['files']).length == 0) {
 | |
| 			document.getElementById('editPane').style.display = 'flex';
 | |
| 		}
 | |
| 		g_app = json;
 | |
| 		g_app.emoji = g_app.emoji || '📦';
 | |
| 		document.getElementById('icon').innerHTML = g_app.emoji;
 | |
| 	}
 | |
| 	if (!isApp) {
 | |
| 		document.getElementById('editPane').style.display = 'flex';
 | |
| 		let text = '// New script.\n';
 | |
| 		g_current_file = 'app.js';
 | |
| 		g_files[g_current_file] = {
 | |
| 			doc: cm6.EditorState.create({doc: text, extensions: cm6.extensions}),
 | |
| 		};
 | |
| 		openFile(g_current_file);
 | |
| 	}
 | |
| 	return Promise.all(promises);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Hide the editor.
 | |
|  */
 | |
| function closeEditor() {
 | |
| 	window.localStorage.setItem('editing', '0');
 | |
| 	document.getElementById('editPane').style.display = 'none';
 | |
| 	document.getElementById('viewPane').style.display = 'flex';
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Save the app.
 | |
|  * @param save_to An optional path to which to save the app.
 | |
|  * @return A promise resoled when the app is saved.
 | |
|  */
 | |
| function save(save_to) {
 | |
| 	document.getElementById('save').disabled = true;
 | |
| 	if (g_current_file) {
 | |
| 		g_files[g_current_file].doc = g_editor.state;
 | |
| 		if (
 | |
| 			!g_files[g_current_file].isNew &&
 | |
| 			!g_files[g_current_file].doc.doc.toString() ==
 | |
| 				g_files[g_current_file].original
 | |
| 		) {
 | |
| 			delete g_files[g_current_file].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(g_files)) {
 | |
| 		let file = g_files[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(g_files).map((x) => [x, g_files[x].id || g_app.files[x]])
 | |
| 				),
 | |
| 				emoji: g_app.emoji || '📦',
 | |
| 			};
 | |
| 			Object.values(g_files).forEach(function (file) {
 | |
| 				delete file.id;
 | |
| 			});
 | |
| 			g_app = 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 if (!g_files['app.js']) {
 | |
| 					window.location.reload();
 | |
| 				} else {
 | |
| 					reconnect(save_path);
 | |
| 				}
 | |
| 			});
 | |
| 		})
 | |
| 		.catch(function (error) {
 | |
| 			alert(error);
 | |
| 		})
 | |
| 		.finally(function () {
 | |
| 			document.getElementById('save').disabled = false;
 | |
| 			Object.values(g_files).forEach(function (file) {
 | |
| 				file.original = file.doc.doc.toString();
 | |
| 			});
 | |
| 			updateFiles();
 | |
| 		});
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Prompt to set the app icon.
 | |
|  */
 | |
| function changeIcon() {
 | |
| 	let value = prompt('Enter a new app icon emoji:');
 | |
| 	if (value !== undefined) {
 | |
| 		g_app.emoji = value || '📦';
 | |
| 		document.getElementById('icon').innerHTML = g_app.emoji;
 | |
| 	}
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Prompt to delete the current app.
 | |
|  */
 | |
| 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);
 | |
| 			});
 | |
| 	}
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Get the current app URL.
 | |
|  * @return The app URL.
 | |
|  */
 | |
| 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;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Get the window hash without the lone '#' if it is empty.
 | |
|  * @return The hash.
 | |
|  */
 | |
| function hash() {
 | |
| 	return window.location.hash != '#' ? window.location.hash : '';
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Set the iframe document contents.
 | |
|  * @param content The contents.
 | |
|  */
 | |
| function api_setDocument(content) {
 | |
| 	let iframe = document.getElementById('document');
 | |
| 	iframe.srcdoc = content;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Send a message to the sandboxed iframe.
 | |
|  * @param message The message.
 | |
|  */
 | |
| function api_postMessage(message) {
 | |
| 	let iframe = document.getElementById('document');
 | |
| 	iframe.contentWindow.postMessage(message, '*');
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Show an error.
 | |
|  * @param error The error.
 | |
|  */
 | |
| function api_error(error) {
 | |
| 	if (error) {
 | |
| 		if (typeof error == 'string') {
 | |
| 			setStatusMessage('⚠️ ' + error, k_color_error);
 | |
| 		} else {
 | |
| 			setStatusMessage(
 | |
| 				'⚠️ ' + error.message + '\n' + error.stack,
 | |
| 				k_color_error
 | |
| 			);
 | |
| 		}
 | |
| 	}
 | |
| 	console.log('error', error);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  et a value in local storage.
 | |
|  * @param key The key.
 | |
|  * @param value The value.
 | |
|  */
 | |
| function api_localStorageSet(key, value) {
 | |
| 	window.localStorage.setItem('app:' + key, value);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Get a value from local storage.
 | |
|  * @param key The key.
 | |
|  * @return The value.
 | |
|  */
 | |
| function api_localStorageGet(key) {
 | |
| 	return window.localStorage.getItem('app:' + key);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Request a permission
 | |
|  * @param permission The permission to request.
 | |
|  * @param id The id requeesting the permission.
 | |
|  * @return A promise fulfilled if the permission was granted.
 | |
|  */
 | |
| 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);
 | |
| 	});
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Log from the app to the console.
 | |
|  */
 | |
| function api_print() {
 | |
| 	console.log('app>', ...arguments);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Set the window's location hash.
 | |
|  * @param hash The new hash.
 | |
|  */
 | |
| function api_setHash(hash) {
 | |
| 	window.location.hash = hash;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Process an incoming WebSocket message.
 | |
|  * @param message The message.
 | |
|  */
 | |
| function _receive_websocket_message(message) {
 | |
| 	if (message && message.action == 'session') {
 | |
| 		setStatusMessage('🟢 Executing...', k_color_status);
 | |
| 		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.action === '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({
 | |
| 						action: 'tfrpc',
 | |
| 						id: id,
 | |
| 						result: result,
 | |
| 					});
 | |
| 				})
 | |
| 				.catch(function (error) {
 | |
| 					send({
 | |
| 						action: 'tfrpc',
 | |
| 						id: id,
 | |
| 						error: error,
 | |
| 					});
 | |
| 				});
 | |
| 		}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Set the status message.
 | |
|  * @param message The message.
 | |
|  * @param color The message's color.
 | |
|  */
 | |
| function setStatusMessage(message, color) {
 | |
| 	document.getElementsByTagName('tf-navigation')[0].status = {
 | |
| 		message: message,
 | |
| 		color: color,
 | |
| 		is_error: color == k_color_error,
 | |
| 	};
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Send a message to the app.
 | |
|  * @param value The message.
 | |
|  */
 | |
| function send(value) {
 | |
| 	try {
 | |
| 		if (g_socket && g_socket.readyState == g_socket.OPEN) {
 | |
| 			g_socket.send(JSON.stringify(value));
 | |
| 		}
 | |
| 	} catch (error) {
 | |
| 		setStatusMessage('🤷 Send failed: ' + error.toString(), k_color_error);
 | |
| 	}
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Notify the app of the window hash changing.
 | |
|  */
 | |
| function hashChange() {
 | |
| 	send({event: 'hashChange', hash: window.location.hash});
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Make sure the app is connected on window focus, and notify the app.
 | |
|  */
 | |
| function focus() {
 | |
| 	if (g_socket && g_socket.readyState == g_socket.CLOSED) {
 | |
| 		connectSocket();
 | |
| 	} else {
 | |
| 		send({event: 'focus'});
 | |
| 	}
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Notify the app of lost focus.
 | |
|  */
 | |
| function blur() {
 | |
| 	send({event: 'blur'});
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Handle a message.
 | |
|  * @param event The message.
 | |
|  */
 | |
| 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});
 | |
| 	}
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Reconnect the WebSocket.
 | |
|  * @param path The path to which the WebSocket should be connected.
 | |
|  */
 | |
| function reconnect(path) {
 | |
| 	let oldSocket = g_socket;
 | |
| 	g_socket = null;
 | |
| 	if (oldSocket) {
 | |
| 		oldSocket.onopen = null;
 | |
| 		oldSocket.onclose = null;
 | |
| 		oldSocket.onmessage = null;
 | |
| 		oldSocket.close();
 | |
| 	}
 | |
| 	connectSocket(path);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Connect the WebSocket.
 | |
|  * @param path The path to which to connect.
 | |
|  */
 | |
| function connectSocket(path) {
 | |
| 	if (!g_socket || g_socket.readyState != g_socket.OPEN) {
 | |
| 		if (g_socket) {
 | |
| 			g_socket.onopen = null;
 | |
| 			g_socket.onclose = null;
 | |
| 			g_socket.onmessage = null;
 | |
| 			g_socket.close();
 | |
| 		}
 | |
| 		setStatusMessage('⚪ Connecting...', k_color_status);
 | |
| 		g_socket = new WebSocket(
 | |
| 			(window.location.protocol == 'https:' ? 'wss://' : 'ws://') +
 | |
| 				window.location.hostname +
 | |
| 				(window.location.port.length ? ':' + window.location.port : '') +
 | |
| 				'/app/socket'
 | |
| 		);
 | |
| 		g_socket.onopen = function () {
 | |
| 			setStatusMessage('🟡 Authenticating...', k_color_status);
 | |
| 			let connect_path = path ?? window.location.pathname;
 | |
| 			g_socket.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)
 | |
| 					),
 | |
| 				})
 | |
| 			);
 | |
| 		};
 | |
| 		g_socket.onmessage = function (event) {
 | |
| 			_receive_websocket_message(JSON.parse(event.data));
 | |
| 		};
 | |
| 		g_socket.onclose = function (event) {
 | |
| 			if (g_unloading) {
 | |
| 				setStatusMessage('⚪ Closing...', k_color_status);
 | |
| 			} else {
 | |
| 				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),
 | |
| 					k_color_disconnect
 | |
| 				);
 | |
| 			}
 | |
| 		};
 | |
| 	}
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Open a file by name.
 | |
|  * @param name The file to open.
 | |
|  */
 | |
| function openFile(name) {
 | |
| 	let newDoc =
 | |
| 		name && g_files[name]
 | |
| 			? g_files[name].doc
 | |
| 			: cm6.EditorState.create({doc: '', extensions: cm6.extensions});
 | |
| 	let oldDoc = g_editor.state;
 | |
| 	g_editor.setState(newDoc);
 | |
| 
 | |
| 	if (g_files[g_current_file]) {
 | |
| 		g_files[g_current_file].doc = oldDoc;
 | |
| 		if (
 | |
| 			!g_files[g_current_file].isNew &&
 | |
| 			g_files[g_current_file].doc.doc.toString() == oldDoc.doc.toString()
 | |
| 		) {
 | |
| 			delete g_files[g_current_file].buffer;
 | |
| 		}
 | |
| 	}
 | |
| 	g_current_file = name;
 | |
| 	updateFiles();
 | |
| 	g_editor.focus();
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Refresh the files list.
 | |
|  */
 | |
| function updateFiles() {
 | |
| 	let files = document.getElementsByTagName('tf-files-pane')[0];
 | |
| 	if (files) {
 | |
| 		files.files = Object.fromEntries(
 | |
| 			Object.keys(g_files).map((file) => [
 | |
| 				file,
 | |
| 				{
 | |
| 					clean:
 | |
| 						(file == g_current_file
 | |
| 							? g_editor.state.doc.toString()
 | |
| 							: g_files[file].doc.doc.toString()) == g_files[file].original,
 | |
| 				},
 | |
| 			])
 | |
| 		);
 | |
| 		files.current = g_current_file;
 | |
| 	}
 | |
| 	g_editor.focus();
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Create a new file with the given name.
 | |
|  * @param name The file's name.
 | |
|  */
 | |
| function makeNewFile(name) {
 | |
| 	g_files[name] = {
 | |
| 		doc: cm6.EditorState.create({extensions: cm6.extensions}),
 | |
| 	};
 | |
| 	openFile(name);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Prompt to create a new file.
 | |
|  */
 | |
| function newFile() {
 | |
| 	let name = prompt('Name of new file:', 'file.js');
 | |
| 	if (name && !g_files[name]) {
 | |
| 		makeNewFile(name);
 | |
| 	}
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Prompt to remove a file.
 | |
|  */
 | |
| function removeFile() {
 | |
| 	if (confirm('Remove ' + g_current_file + '?')) {
 | |
| 		delete g_files[g_current_file];
 | |
| 		openFile(Object.keys(g_files)[0]);
 | |
| 	}
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Export the app to a zip file, which is downloaded by the browser.
 | |
|  */
 | |
| 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: g_app.emoji || '📦',
 | |
| 		})
 | |
| 	);
 | |
| 	for (let file of Object.keys(g_files)) {
 | |
| 		zip.file(
 | |
| 			`${name}/${file}`,
 | |
| 			g_files[file].buffer ?? g_files[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();
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Save a file.
 | |
|  * @param name The file to svae.
 | |
|  * @param file The file contents.
 | |
|  * @return A promise resolved with the blob ID of the saved file.
 | |
|  */
 | |
| 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;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Prompt to import an app from a zip file.
 | |
|  */
 | |
| 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());
 | |
| 		}
 | |
| 	};
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Prettify the current source file.
 | |
|  */
 | |
| 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 = g_editor.state.doc.toString();
 | |
| 	let formatted = await prettier.format(source, {
 | |
| 		parser: g_current_file?.toLowerCase()?.endsWith('.html') ? 'html' : 'babel',
 | |
| 		plugins: [babel, estree, prettier_html],
 | |
| 		trailingComma: 'es5',
 | |
| 		useTabs: true,
 | |
| 		semi: true,
 | |
| 		singleQuote: true,
 | |
| 		bracketSpacing: false,
 | |
| 	});
 | |
| 	if (source !== formatted) {
 | |
| 		g_editor.dispatch({
 | |
| 			changes: {
 | |
| 				from: 0,
 | |
| 				to: g_editor.state.doc.length,
 | |
| 				insert: formatted,
 | |
| 			},
 | |
| 		});
 | |
| 	}
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Toggle visible whitespace.
 | |
|  */
 | |
| 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');
 | |
| 	}
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Register event handlers and connect the WebSocket on load.
 | |
|  */
 | |
| 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);
 | |
| 	window.addEventListener('beforeunload', function () {
 | |
| 		g_unloading = true;
 | |
| 	});
 | |
| 	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();
 | |
| 	}
 | |
| });
 | |
| 
 | |
| /** @} */
 |