mirror of
https://github.com/leporello-js/leporello-js
synced 2026-01-13 21:14:28 -08:00
implement code snippet sharing
This commit is contained in:
48
index.html
48
index.html
@@ -56,7 +56,7 @@
|
|||||||
.editor_container:focus-within,
|
.editor_container:focus-within,
|
||||||
.bottom:focus-within,
|
.bottom:focus-within,
|
||||||
.files_container:focus-within,
|
.files_container:focus-within,
|
||||||
.help_dialog {
|
dialog {
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: 1px 1px 6px 3px var(--shadow_color);
|
box-shadow: 1px 1px 6px 3px var(--shadow_color);
|
||||||
}
|
}
|
||||||
@@ -314,13 +314,8 @@
|
|||||||
|
|
||||||
/* status */
|
/* status */
|
||||||
|
|
||||||
/*
|
|
||||||
.request_fullscreen {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
.statusbar {
|
.statusbar {
|
||||||
|
margin-bottom: 0px;
|
||||||
grid-area: statusbar;
|
grid-area: statusbar;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -384,8 +379,41 @@
|
|||||||
margin: 0em 0.5em;
|
margin: 0em 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help_dialog[open] {
|
.share_button, .upload_button {
|
||||||
border: none;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -396,10 +424,6 @@
|
|||||||
background-color: white;
|
background-color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help_dialog::backdrop {
|
|
||||||
background-color: rgb(225 244 253 / 80%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.help {
|
.help {
|
||||||
padding: 2em;
|
padding: 2em;
|
||||||
border-spacing: 5px;
|
border-spacing: 5px;
|
||||||
|
|||||||
@@ -248,12 +248,13 @@ const input = (state, code, index) => {
|
|||||||
set_cursor_position({...state, files}, index),
|
set_cursor_position({...state, files}, index),
|
||||||
[state.current_module]
|
[state.current_module]
|
||||||
)
|
)
|
||||||
const effect_save = next.current_module == ''
|
const effect_save = {
|
||||||
? {type: 'save_to_localstorage', args: ['code', code]}
|
type: 'write',
|
||||||
: {type: 'write', args: [
|
args: [
|
||||||
next.current_module,
|
next.current_module,
|
||||||
next.files[next.current_module],
|
next.files[next.current_module],
|
||||||
]}
|
]
|
||||||
|
}
|
||||||
return {state: next, effects: [effect_save]}
|
return {state: next, effects: [effect_save]}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
92
src/editor/share_dialog.js
Normal file
92
src/editor/share_dialog.js
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {Files} from './files.js'
|
|||||||
import {CallTree} from './calltree.js'
|
import {CallTree} from './calltree.js'
|
||||||
import {Logs} from './logs.js'
|
import {Logs} from './logs.js'
|
||||||
import {IO_Trace} from './io_trace.js'
|
import {IO_Trace} from './io_trace.js'
|
||||||
|
import {ShareDialog} from './share_dialog.js'
|
||||||
import {el} from './domutils.js'
|
import {el} from './domutils.js'
|
||||||
|
|
||||||
export class UI {
|
export class UI {
|
||||||
@@ -129,7 +130,12 @@ export class UI {
|
|||||||
href: 'https://github.com/leporello-js/leporello-js',
|
href: 'https://github.com/leporello-js/leporello-js',
|
||||||
target: '__blank',
|
target: '__blank',
|
||||||
}, 'Github'),
|
}, 'Github'),
|
||||||
|
el('button', {
|
||||||
|
'class': 'share_button',
|
||||||
|
'click': () => this.share_dialog.showModal(),
|
||||||
|
}, 'Share'),
|
||||||
this.help_dialog = this.render_help(),
|
this.help_dialog = this.render_help(),
|
||||||
|
this.share_dialog = new ShareDialog().el,
|
||||||
)
|
)
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
|
|||||||
28
src/effects.js
vendored
28
src/effects.js
vendored
@@ -299,8 +299,32 @@ export const EFFECTS = {
|
|||||||
localStorage[key] = value
|
localStorage[key] = value
|
||||||
},
|
},
|
||||||
|
|
||||||
write: (state, [name, contents], ui) => {
|
write: (state, [name, contents], ui, prev_state) => {
|
||||||
if(state.has_file_system_access) {
|
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)
|
write_file(name, contents)
|
||||||
} else {
|
} else {
|
||||||
write_example(name, contents)
|
write_example(name, contents)
|
||||||
|
|||||||
39
src/index.js
39
src/index.js
@@ -6,6 +6,7 @@ import {
|
|||||||
init_window_service_worker
|
init_window_service_worker
|
||||||
} from './filesystem.js'
|
} from './filesystem.js'
|
||||||
import {examples, examples_dir_promise} from './examples.js'
|
import {examples, examples_dir_promise} from './examples.js'
|
||||||
|
import {get_share} from './share.js'
|
||||||
|
|
||||||
const EXAMPLE = `function fib(n) {
|
const EXAMPLE = `function fib(n) {
|
||||||
if(n == 0 || n == 1) {
|
if(n == 0 || n == 1) {
|
||||||
@@ -178,41 +179,51 @@ export const init = async (container, _COMMANDS) => {
|
|||||||
|
|
||||||
set_error_handler(window)
|
set_error_handler(window)
|
||||||
|
|
||||||
const default_module = {'': localStorage.code || EXAMPLE}
|
let files = {'': localStorage.code || EXAMPLE}
|
||||||
let initial_state, entrypoint_settings
|
let initial_state, entrypoint_settings
|
||||||
const project_dir = await open_dir(false)
|
const project_dir = await open_dir(false)
|
||||||
let example
|
let example
|
||||||
if(project_dir == null) {
|
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 params = new URLSearchParams(window.location.search)
|
||||||
const example_path = params.get('example')
|
const example_path = params.get('example')
|
||||||
params.delete('example')
|
const nextURL = new URL(window.location)
|
||||||
globalThis.history.replaceState(
|
nextURL.searchParams.delete('example')
|
||||||
null,
|
history.replaceState(null, null, nextURL.href)
|
||||||
null,
|
|
||||||
'/' + params.toString() + window.location.hash
|
|
||||||
)
|
|
||||||
|
|
||||||
example = examples.find(e => e.path == example_path)
|
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,
|
current_module: example.entrypoint,
|
||||||
entrypoint: example.entrypoint,
|
entrypoint: example.entrypoint,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
initial_state = {
|
initial_state = {
|
||||||
project_dir: await examples_dir_promise,
|
project_dir: await examples_dir_promise,
|
||||||
files: default_module,
|
files,
|
||||||
has_file_system_access: false,
|
has_file_system_access: false,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
entrypoint_settings = get_entrypoint_settings()
|
entrypoint_settings = get_entrypoint_settings()
|
||||||
initial_state = {
|
initial_state = {
|
||||||
project_dir,
|
project_dir,
|
||||||
files: default_module,
|
files,
|
||||||
has_file_system_access: true,
|
has_file_system_access: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,7 +317,7 @@ export const exec = (cmd, ...args) => {
|
|||||||
} else {
|
} else {
|
||||||
console.log('apply effect', e.type, ...(e.args ?? []))
|
console.log('apply effect', e.type, ...(e.args ?? []))
|
||||||
}
|
}
|
||||||
EFFECTS[e.type](nextstate, e.args, ui)
|
EFFECTS[e.type](nextstate, e.args, ui, state)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, nextstate)
|
}, nextstate)
|
||||||
|
|||||||
74
src/share.js
Normal file
74
src/share.js
Normal file
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user