2022-09-10 02:48:13 +08:00
|
|
|
import {UI} from './editor/ui.js'
|
2023-06-11 00:04:42 +03:00
|
|
|
import {EFFECTS, render_initial_state, apply_side_effects} from './effects.js'
|
2023-06-19 12:51:04 +03:00
|
|
|
import {load_dir, init_window_service_worker} from './filesystem.js'
|
2022-09-10 02:48:13 +08:00
|
|
|
|
|
|
|
|
const EXAMPLE = `const fib = n =>
|
|
|
|
|
n == 0 || n == 1
|
|
|
|
|
? n
|
|
|
|
|
: fib(n - 1) + fib(n - 2)
|
|
|
|
|
fib(6)`
|
|
|
|
|
|
2022-11-28 23:12:55 +08:00
|
|
|
|
2023-01-17 11:48:01 +08:00
|
|
|
const set_error_handler = (w, with_unhandled_rejection = true) => {
|
2022-11-08 16:22:45 +08:00
|
|
|
// TODO err.message
|
|
|
|
|
w.onerror = (msg, src, lineNum, colNum, err) => {
|
2023-02-14 18:19:26 +08:00
|
|
|
if(err?.__ignore) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2022-11-08 16:22:45 +08:00
|
|
|
ui.set_status(msg)
|
|
|
|
|
}
|
2023-01-17 11:48:01 +08:00
|
|
|
if(with_unhandled_rejection) {
|
|
|
|
|
w.addEventListener('unhandledrejection', (event) => {
|
|
|
|
|
ui.set_status(event.reason)
|
|
|
|
|
})
|
|
|
|
|
}
|
2022-11-08 16:22:45 +08:00
|
|
|
}
|
2022-10-26 13:11:51 +08:00
|
|
|
|
2023-01-18 16:30:44 +08:00
|
|
|
// Fake directory, http requests to this directory intercepted by service_worker
|
|
|
|
|
export const FILES_ROOT = new URL('./__leporello_files', globalThis.location)
|
|
|
|
|
|
2022-11-28 20:53:35 +08:00
|
|
|
const get_html_url = state => {
|
2023-01-18 16:30:44 +08:00
|
|
|
const base = FILES_ROOT + '/'
|
2022-11-28 20:53:35 +08:00
|
|
|
return state.html_file == ''
|
2022-11-28 23:12:55 +08:00
|
|
|
? base + '__leporello_blank.html'
|
|
|
|
|
: base + state.html_file + '?leporello'
|
2022-11-28 20:53:35 +08:00
|
|
|
}
|
|
|
|
|
|
2023-07-02 23:35:21 +03:00
|
|
|
const on_window_load = w => {
|
|
|
|
|
init_window_service_worker(w)
|
2023-06-19 12:51:04 +03:00
|
|
|
exec(
|
|
|
|
|
'open_run_window',
|
2023-07-02 23:35:21 +03:00
|
|
|
new Set(Object.getOwnPropertyNames(w))
|
2023-06-19 12:51:04 +03:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2022-10-26 13:11:51 +08:00
|
|
|
// By default run code in hidden iframe, until user explicitly opens visible
|
|
|
|
|
// window
|
2023-07-02 23:35:21 +03:00
|
|
|
let iframe
|
2023-06-19 12:51:04 +03:00
|
|
|
const open_run_iframe = (state) => {
|
2023-07-02 23:35:21 +03:00
|
|
|
iframe = document.createElement('iframe')
|
2022-11-28 20:53:35 +08:00
|
|
|
iframe.src = get_html_url(state)
|
2022-10-26 13:11:51 +08:00
|
|
|
iframe.setAttribute('hidden', '')
|
|
|
|
|
document.body.appendChild(iframe)
|
2023-01-17 11:48:01 +08:00
|
|
|
// for run_window, do not set unhandled rejection, because having rejected
|
|
|
|
|
// promises in user code is normal condition
|
|
|
|
|
set_error_handler(iframe.contentWindow, false)
|
2022-11-28 20:53:35 +08:00
|
|
|
globalThis.run_window = iframe.contentWindow
|
2023-07-02 23:35:21 +03:00
|
|
|
init_run_window(globalThis.run_window)
|
2022-11-28 20:53:35 +08:00
|
|
|
}
|
2022-10-26 13:11:51 +08:00
|
|
|
|
2022-11-26 02:56:32 +08:00
|
|
|
// Open another browser window so user can interact with application
|
|
|
|
|
// TODO test in another browsers
|
2022-11-28 20:53:35 +08:00
|
|
|
export const open_run_window = state => {
|
2023-02-14 18:19:26 +08:00
|
|
|
// TODO set_error_handler? Or we dont need to set_error_handler for child
|
|
|
|
|
// window because error is always caught by parent window handler?
|
2022-11-26 02:56:32 +08:00
|
|
|
globalThis.run_window.close()
|
2023-07-02 19:42:44 +03:00
|
|
|
globalThis.run_window = open(get_html_url(state))
|
2023-07-02 23:35:21 +03:00
|
|
|
init_run_window(globalThis.run_window)
|
2023-07-02 19:42:44 +03:00
|
|
|
}
|
2022-11-26 02:56:32 +08:00
|
|
|
|
2023-07-02 23:35:21 +03:00
|
|
|
const init_run_window = w => {
|
2022-11-26 02:56:32 +08:00
|
|
|
|
|
|
|
|
const is_loaded = () => {
|
2023-07-02 23:35:21 +03:00
|
|
|
const nav = w.performance.getEntriesByType("navigation")[0]
|
2022-11-26 02:56:32 +08:00
|
|
|
return nav != null && nav.loadEventEnd > 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const add_load_handler = () => {
|
|
|
|
|
/*
|
|
|
|
|
Wait until 'load event', then set unload handler. The page after
|
|
|
|
|
window.open seems to go through these steps:
|
|
|
|
|
|
|
|
|
|
- about:blank gets opened
|
|
|
|
|
- Real URL get opened
|
|
|
|
|
- 'unload' event for about:blank page
|
|
|
|
|
- 'load event for real URL
|
|
|
|
|
|
|
|
|
|
if we set unload handler right now, then it will be fired for unload
|
|
|
|
|
event for about:blank page
|
|
|
|
|
*/
|
|
|
|
|
if(is_loaded()) {
|
|
|
|
|
// Already loaded
|
|
|
|
|
add_unload_handler()
|
2023-07-02 23:35:21 +03:00
|
|
|
on_window_load(w)
|
2022-11-26 02:56:32 +08:00
|
|
|
} else {
|
2023-07-02 23:35:21 +03:00
|
|
|
w.addEventListener('load', () => {
|
2022-11-26 02:56:32 +08:00
|
|
|
add_unload_handler()
|
2023-06-19 12:51:04 +03:00
|
|
|
// Wait until `load` event before executing code, because service worker that
|
|
|
|
|
// is responsible for loading external modules seems not working until `load`
|
|
|
|
|
// event fired. TODO: better register SW explicitly and don't rely on
|
|
|
|
|
// already registered SW?
|
2023-07-02 23:35:21 +03:00
|
|
|
on_window_load(w)
|
2022-11-26 02:56:32 +08:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const add_unload_handler = () => {
|
2023-07-02 23:35:21 +03:00
|
|
|
w.addEventListener('unload', (e) => {
|
2022-11-26 02:56:32 +08:00
|
|
|
// Set timeout to 100ms because it takes some time for page to get closed
|
|
|
|
|
// after triggering 'unload' event
|
|
|
|
|
setTimeout(() => {
|
2023-07-02 23:35:21 +03:00
|
|
|
if(w.closed && w == globalThis.run_window) {
|
|
|
|
|
// If by that time w.closed was set to true, then page was
|
|
|
|
|
// closed. Get back to using iframe
|
|
|
|
|
globalThis.run_window = iframe.contentWindow
|
|
|
|
|
reload_run_window(get_state())
|
2022-11-26 02:56:32 +08:00
|
|
|
} else {
|
|
|
|
|
add_load_handler()
|
|
|
|
|
}
|
|
|
|
|
}, 100)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
add_load_handler()
|
2022-10-26 01:05:52 +08:00
|
|
|
}
|
|
|
|
|
|
2022-11-28 20:53:35 +08:00
|
|
|
export const reload_run_window = state => {
|
|
|
|
|
// TODO after window location reload, open_run_window command will be fired.
|
|
|
|
|
// Maybe we should have separate commands for open_run_window and
|
|
|
|
|
// reload_run_window?
|
|
|
|
|
globalThis.run_window.location = get_html_url(state)
|
|
|
|
|
}
|
|
|
|
|
|
2022-09-10 02:48:13 +08:00
|
|
|
const read_modules = async () => {
|
|
|
|
|
const default_module = {'': localStorage.code || EXAMPLE}
|
|
|
|
|
const project_dir = await load_dir(false)
|
|
|
|
|
if(project_dir == null) {
|
|
|
|
|
// Single anonymous module
|
|
|
|
|
return {
|
|
|
|
|
files: default_module,
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
return {
|
|
|
|
|
project_dir,
|
|
|
|
|
files: default_module,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-06-22 15:56:14 +03:00
|
|
|
const get_entrypoint_settings = () => ({
|
|
|
|
|
current_module: localStorage.current_module ?? '',
|
|
|
|
|
entrypoint: localStorage.entrypoint ?? '',
|
|
|
|
|
html_file: localStorage.html_file ?? '',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const open_directory = () => {
|
|
|
|
|
if(globalThis.showDirectoryPicker == null) {
|
|
|
|
|
throw new Error('Your browser is not supporting File System Access API')
|
|
|
|
|
}
|
|
|
|
|
load_dir(true).then(dir => {
|
|
|
|
|
exec('load_dir', dir, get_entrypoint_settings())
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2022-11-26 01:25:31 +08:00
|
|
|
let COMMANDS
|
2022-09-10 02:48:13 +08:00
|
|
|
let ui
|
|
|
|
|
let state
|
|
|
|
|
|
2022-11-26 01:25:31 +08:00
|
|
|
export const init = (container, _COMMANDS) => {
|
|
|
|
|
COMMANDS = _COMMANDS
|
|
|
|
|
|
2022-11-08 16:22:45 +08:00
|
|
|
set_error_handler(window)
|
2022-09-10 02:48:13 +08:00
|
|
|
|
|
|
|
|
read_modules().then(initial_state => {
|
2022-11-28 20:53:35 +08:00
|
|
|
|
2023-06-22 15:56:14 +03:00
|
|
|
state = COMMANDS.get_initial_state(
|
|
|
|
|
{
|
|
|
|
|
...initial_state,
|
|
|
|
|
on_deferred_call: (...args) => exec('on_deferred_call', ...args)
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
get_entrypoint_settings(),
|
|
|
|
|
)
|
2022-11-28 22:02:37 +08:00
|
|
|
|
2022-09-10 02:48:13 +08:00
|
|
|
// Expose state for debugging
|
|
|
|
|
globalThis.__state = state
|
|
|
|
|
ui = new UI(container, state)
|
|
|
|
|
// Expose for debugging
|
|
|
|
|
globalThis.__ui = ui
|
2022-11-28 22:02:37 +08:00
|
|
|
|
2022-09-10 02:48:13 +08:00
|
|
|
render_initial_state(ui, state)
|
2022-11-28 22:02:37 +08:00
|
|
|
|
2023-06-19 12:51:04 +03:00
|
|
|
open_run_iframe(state)
|
2022-09-10 02:48:13 +08:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const get_state = () => state
|
|
|
|
|
|
2023-06-10 23:44:43 +03:00
|
|
|
export const with_code_execution = (action, state = get_state()) => {
|
|
|
|
|
/*
|
|
|
|
|
supress is_recording_deferred_calls while rendering, because rendering may
|
|
|
|
|
call toJSON(), which can call trigger deferred call (see lodash.js lazy
|
|
|
|
|
chaining)
|
|
|
|
|
*/
|
|
|
|
|
if(state.eval_cxt != null) {
|
|
|
|
|
state.eval_cxt.is_recording_deferred_calls = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
return action()
|
|
|
|
|
} finally {
|
|
|
|
|
if(state.eval_cxt != null) {
|
|
|
|
|
state.eval_cxt.is_recording_deferred_calls = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2022-09-10 02:48:13 +08:00
|
|
|
export const exec = (cmd, ...args) => {
|
|
|
|
|
if(cmd == 'input' || cmd == 'write') {
|
|
|
|
|
// Do not print file to console
|
|
|
|
|
console.log('exec', cmd)
|
|
|
|
|
} else {
|
|
|
|
|
console.log('exec', cmd, ...args)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const comm = cmd.split('.').reduce(
|
|
|
|
|
(comm, segment) => comm?.[segment],
|
|
|
|
|
COMMANDS
|
|
|
|
|
)
|
|
|
|
|
if(comm == null) {
|
|
|
|
|
throw new Error('command ' + cmd + ' + not found')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = comm(state, ...args)
|
|
|
|
|
console.log('nextstate', result)
|
|
|
|
|
|
|
|
|
|
let nextstate, effects
|
|
|
|
|
if(result.state != null) {
|
|
|
|
|
({state: nextstate, effects} = result)
|
|
|
|
|
} else {
|
|
|
|
|
nextstate = result
|
|
|
|
|
effects = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sanity check
|
2022-11-28 22:02:37 +08:00
|
|
|
if(state?.current_module == null) {
|
2022-09-10 02:48:13 +08:00
|
|
|
console.error('command did not return state, returned', result)
|
|
|
|
|
throw new Error('illegal state')
|
|
|
|
|
}
|
|
|
|
|
|
2023-06-09 16:59:21 +03:00
|
|
|
|
2023-06-10 23:44:43 +03:00
|
|
|
with_code_execution(() => {
|
2023-06-11 00:04:42 +03:00
|
|
|
apply_side_effects(state, nextstate, cmd, ui);
|
2023-06-10 23:44:43 +03:00
|
|
|
|
|
|
|
|
if(effects != null) {
|
|
|
|
|
(Array.isArray(effects) ? effects : [effects]).forEach(e => {
|
|
|
|
|
if(e.type == 'write' || e.type == 'save_to_localstorage') {
|
|
|
|
|
// do not spam to console
|
|
|
|
|
console.log('apply effect', e.type)
|
|
|
|
|
} else {
|
|
|
|
|
console.log('apply effect', e.type, ...(e.args ?? []))
|
|
|
|
|
}
|
|
|
|
|
EFFECTS[e.type](nextstate, e.args, ui)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}, nextstate)
|
2022-09-10 02:48:13 +08:00
|
|
|
|
2023-06-09 16:59:21 +03:00
|
|
|
|
|
|
|
|
|
2022-09-10 02:48:13 +08:00
|
|
|
// Expose for debugging
|
|
|
|
|
globalThis.__prev_state = state
|
|
|
|
|
globalThis.__state = nextstate
|
|
|
|
|
state = nextstate
|
|
|
|
|
}
|