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`
${this.identities.map( (x) => html` ` )}
`; } else if ( this.credentials?.session?.name && this.credentials.session.name !== 'guest' ) { return html` `; } else { return html` `; } } else { return html`login`; } } /** * TODOC * @returns */ render_permissions() { if (this.show_permissions) { return html`
This app has the following permissions:
${Object.keys(this.permissions).map( (key) => html`
${key}: ${this.permissions[key] ? 'โœ… Allowed' : 'โŒ Denied'}
` )}
`; } } clear_error() { this.status = {}; } /** * TODOC * @returns */ render() { let self = this; return html`
(this.show_expanded = !this.show_expanded)} >๐Ÿ˜Ž ['name', 'number'].indexOf(x[0]) == -1) .map((x) => `\n* ${x[0]}: ${x[1]}`)} >${this.version?.number} TF apps edit (self.show_permissions = !self.show_permissions)} >๐ŸŽ›๏ธ ${this.render_permissions()} ${this.status?.message && !this.status.is_error ? html`
${this.status.message}
` : undefined} ${Object.keys(this.spark_lines) .sort() .map((x) => this.spark_lines[x])} ${this.render_identity()}
${this.status?.is_error ? html`
×
ERROR:

${this.status.message}

` : 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`
this.file_click(file)} > ${file}
`; } /** * 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`
${Object.keys(this.files) .sort() .map((x) => self.render_file(x))}
Drop File(s)
`; } } 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`
self.set_expanded(false)}> Files ยซ
` : html`
self.set_expanded(true)} > ยป
`; let content = html` openFile(event.detail.file)} >
`; return html`
${expander} ${this.expanded ? content : undefined}
`; } } 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``; } } /** * 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` ${this.lines.map((x) => this.render_line(x))} ${this.dataset.emoji}${max} `; } } 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(); } });