diff --git a/index.html b/index.html
index 749b4c0..bb774e2 100644
--- a/index.html
+++ b/index.html
@@ -56,7 +56,7 @@
.editor_container:focus-within,
.bottom:focus-within,
.files_container:focus-within,
- .help_dialog {
+ dialog {
outline: none;
box-shadow: 1px 1px 6px 3px var(--shadow_color);
}
@@ -314,13 +314,8 @@
/* status */
- /*
- .request_fullscreen {
- margin-left: auto;
- }
- */
-
.statusbar {
+ margin-bottom: 0px;
grid-area: statusbar;
display: flex;
flex-direction: row;
@@ -384,8 +379,41 @@
margin: 0em 0.5em;
}
- .help_dialog[open] {
+ .share_button, .upload_button {
border: none;
+ color: white;
+ background: rgb(23 166 236);
+ }
+
+ .share_button {
+ font-size: 1.2em;
+ margin-left: 1em;
+ margin-right: 0.5em;
+ }
+
+ .share_button[disabled] {
+ background: grey;
+ }
+
+
+ .share_dialog input, .share_dialog button {
+ font-size: 1.2em;
+ }
+
+ .share_dialog button {
+ padding: 5px;
+ height: 2em;
+ }
+
+ dialog {
+ border: none;
+ }
+
+ dialog::backdrop {
+ background-color: rgb(225 244 253 / 80%);
+ }
+
+ .help_dialog[open] {
display: flex;
flex-direction: column;
justify-content: center;
@@ -396,10 +424,6 @@
background-color: white;
}
- .help_dialog::backdrop {
- background-color: rgb(225 244 253 / 80%);
- }
-
.help {
padding: 2em;
border-spacing: 5px;
diff --git a/src/cmd.js b/src/cmd.js
index 0eb304a..e7ade81 100644
--- a/src/cmd.js
+++ b/src/cmd.js
@@ -248,12 +248,13 @@ const input = (state, code, index) => {
set_cursor_position({...state, files}, index),
[state.current_module]
)
- const effect_save = next.current_module == ''
- ? {type: 'save_to_localstorage', args: ['code', code]}
- : {type: 'write', args: [
+ const effect_save = {
+ type: 'write',
+ args: [
next.current_module,
next.files[next.current_module],
- ]}
+ ]
+ }
return {state: next, effects: [effect_save]}
}
diff --git a/src/editor/share_dialog.js b/src/editor/share_dialog.js
new file mode 100644
index 0000000..12cc6b4
--- /dev/null
+++ b/src/editor/share_dialog.js
@@ -0,0 +1,92 @@
+import {el} from './domutils.js'
+import {save_share} from '../share.js'
+import {get_state} from '../index.js'
+
+export class ShareDialog {
+ constructor() {
+ this.el = el('dialog', 'share_dialog',
+ this.upload_begin = el('p', '',
+ el('p', '',
+ 'This button will upload your scratch file to the cloud for sharing with others.'),
+ el('ul', '',
+ el('li', '',
+ 'Please ensure that no personal data or confidential information is included.'
+ ),
+ el('li', '',
+ 'Avoid including copyrighted materials.'
+ ),
+ ),
+ el('span', {style: 'color: red'},
+ 'Caution: Once shared, files cannot be deleted.'
+ ),
+ this.upload_buttons = el('p', {style: 'text-align: center'},
+ el('button', {
+ 'class': 'upload_button',
+ click: () => this.upload()
+ },
+ "Upload"
+ ),
+ this.cancel_button = el('button', {
+ style: 'margin-left: 1em',
+ click: () => this.cancel()
+ },
+ "Cancel"
+ )
+ ),
+ ),
+ this.uploading = el('span', {style: 'display: none'},
+ "Uploading..."
+ ),
+ this.upload_finish = el('p', {style: 'display: none'},
+ el('p', '',
+ el('p', {style: `
+ text-align: center;
+ margin-bottom: 1em;
+ font-size: 1.2em
+ `}, 'Upload successful'),
+ this.url_share = el('input', {
+ type: 'text',
+ readonly: true,
+ style: 'min-width: 30em',
+ }),
+ this.copy_button = el('button', {
+ click: () => this.copy(),
+ style: 'margin-left: 1em',
+ }, 'Copy URL')
+ ),
+ this.close_button = el('button', {
+ style: 'display: block; margin: auto',
+ click: () => this.cancel(),
+ }, 'Close'),
+ )
+ )
+ }
+
+ async upload() {
+ this.uploading.style.display = ''
+ this.upload_begin.style.display = 'none'
+ try {
+ const id = await save_share(get_state().files[''])
+ this.url = new URL(window.location)
+ this.url.searchParams.append('share_id', id)
+ this.url_share.value = this.url
+ this.upload_finish.style.display = ''
+ } catch(e) {
+ alert(e.message)
+ this.upload_begin.style.display = ''
+ } finally {
+ this.uploading.style.display = 'none'
+ }
+ }
+
+ copy() {
+ this.url_share.select()
+ document.execCommand('copy')
+ }
+
+ cancel() {
+ this.upload_finish.style.display = 'none'
+ this.upload_begin.style.display = ''
+ this.el.close()
+ }
+}
diff --git a/src/editor/ui.js b/src/editor/ui.js
index 692b071..ec2687a 100644
--- a/src/editor/ui.js
+++ b/src/editor/ui.js
@@ -4,6 +4,7 @@ import {Files} from './files.js'
import {CallTree} from './calltree.js'
import {Logs} from './logs.js'
import {IO_Trace} from './io_trace.js'
+import {ShareDialog} from './share_dialog.js'
import {el} from './domutils.js'
export class UI {
@@ -129,7 +130,12 @@ export class UI {
href: 'https://github.com/leporello-js/leporello-js',
target: '__blank',
}, 'Github'),
+ el('button', {
+ 'class': 'share_button',
+ 'click': () => this.share_dialog.showModal(),
+ }, 'Share'),
this.help_dialog = this.render_help(),
+ this.share_dialog = new ShareDialog().el,
)
))
)
diff --git a/src/effects.js b/src/effects.js
index 48eb077..623d3c0 100644
--- a/src/effects.js
+++ b/src/effects.js
@@ -299,8 +299,32 @@ export const EFFECTS = {
localStorage[key] = value
},
- write: (state, [name, contents], ui) => {
- if(state.has_file_system_access) {
+ write: (state, [name, contents], ui, prev_state) => {
+ if(name == '') {
+ const share_id = new URL(window.location).searchParams.get('share_id')
+ if(share_id == null) {
+ localStorage['code'] = contents
+ } else {
+ const key = 'share_' + share_id
+ if(localStorage['code'] == prev_state.files['']) {
+ /*
+ If scratch code is the same with share code, then update both
+
+ Imagine the following scenario:
+
+ - User shares code. URL is replaced with ?share_id=XXX
+ - He keeps working on code
+ - He closes browser tab and on the next day he opens app.leporello.tech
+ - His work is lost (actually, he can still access it with
+ ?share_id=XXX, but that not obvious
+
+ To prevent that, we keep updating scratch code after sharing
+ */
+ localStorage['code'] = contents
+ }
+ localStorage[key] = contents
+ }
+ } else if(state.has_file_system_access) {
write_file(name, contents)
} else {
write_example(name, contents)
diff --git a/src/index.js b/src/index.js
index a227209..39fcec2 100644
--- a/src/index.js
+++ b/src/index.js
@@ -6,6 +6,7 @@ import {
init_window_service_worker
} from './filesystem.js'
import {examples, examples_dir_promise} from './examples.js'
+import {get_share} from './share.js'
const EXAMPLE = `function fib(n) {
if(n == 0 || n == 1) {
@@ -178,41 +179,51 @@ export const init = async (container, _COMMANDS) => {
set_error_handler(window)
- const default_module = {'': localStorage.code || EXAMPLE}
+ let files = {'': localStorage.code || EXAMPLE}
let initial_state, entrypoint_settings
const project_dir = await open_dir(false)
let example
if(project_dir == null) {
/*
- extract example from URL params and delete it
+ extract example_id from URL params and delete it (because we dont want to
+ persist in on refresh)
*/
const params = new URLSearchParams(window.location.search)
const example_path = params.get('example')
- params.delete('example')
- globalThis.history.replaceState(
- null,
- null,
- '/' + params.toString() + window.location.hash
- )
+ const nextURL = new URL(window.location)
+ nextURL.searchParams.delete('example')
+ history.replaceState(null, null, nextURL.href)
example = examples.find(e => e.path == example_path)
- entrypoint_settings = example == null
- ? get_entrypoint_settings()
- : {
+
+ if(example == null) {
+ const shared_code = await get_share()
+ if(shared_code == null) {
+ entrypoint_settings = get_entrypoint_settings()
+ } else {
+ files = {'': shared_code}
+ entrypoint_settings = {
+ current_module: '',
+ entrypoint: '',
+ }
+ }
+ } else {
+ entrypoint_settings = {
current_module: example.entrypoint,
entrypoint: example.entrypoint,
}
+ }
initial_state = {
project_dir: await examples_dir_promise,
- files: default_module,
+ files,
has_file_system_access: false,
}
} else {
entrypoint_settings = get_entrypoint_settings()
initial_state = {
project_dir,
- files: default_module,
+ files,
has_file_system_access: true,
}
}
@@ -306,7 +317,7 @@ export const exec = (cmd, ...args) => {
} else {
console.log('apply effect', e.type, ...(e.args ?? []))
}
- EFFECTS[e.type](nextstate, e.args, ui)
+ EFFECTS[e.type](nextstate, e.args, ui, state)
})
}
}, nextstate)
diff --git a/src/share.js b/src/share.js
new file mode 100644
index 0000000..4078d65
--- /dev/null
+++ b/src/share.js
@@ -0,0 +1,74 @@
+const PROJECT_ID = 'leporello-js'
+const URL_BASE = `https://firebasestorage.googleapis.com/v0/b/${PROJECT_ID}.appspot.com/o/`
+
+// see https://stackoverflow.com/a/48161723/795038
+async function sha256(message) {
+ // encode as UTF-8
+ const msgBuffer = new TextEncoder().encode(message);
+
+ // hash the message
+ const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
+
+ // convert ArrayBuffer to Array
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
+
+ // convert bytes to hex string
+ const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
+ return hashHex;
+}
+
+async function upload_share(text) {
+ const id = (await sha256(text))
+ // Truncate to 20 bytes, like in git
+ .slice(0, 40)
+ const blob = new Blob([text], { type: 'text/plain' })
+ const formData = new FormData()
+ formData.append('file', blob)
+ const response = await fetch(URL_BASE + id, {
+ method: 'POST',
+ body: formData
+ })
+ if(!response.ok) {
+ const json = await response.json()
+ const message = json?.error?.message
+ throw new Error('Failed to upload: ' + message)
+ }
+ return id
+}
+
+async function download_share(id) {
+ const response = await fetch(URL_BASE + id + '?alt=media')
+ if(!response.ok) {
+ const json = await response.json()
+ const message = json?.error?.message
+ throw new Error('Failed to fetch: ' + message)
+ }
+ return response.text()
+}
+
+export async function get_share() {
+ const params = new URLSearchParams(window.location.search)
+ const share_id = params.get('share_id')
+ if(share_id == null) {
+ return null
+ }
+
+ const shared_code = localStorage['share_' + share_id]
+ if(shared_code != null) {
+ return shared_code
+ }
+
+ try {
+ return await download_share(share_id)
+ } catch(e) {
+ alert(e.message)
+ return null
+ }
+}
+
+export async function save_share(text) {
+ const share_id = await upload_share(text)
+ const nextURL = new URL(window.location)
+ nextURL.searchParams.set('share_id', share_id)
+ history.replaceState(null, null, nextURL.href)
+}