Files
leporello-js/src/index.js

302 lines
7.6 KiB
JavaScript
Raw Normal View History

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-07-02 20:22:41 +03:00
import {
open_dir,
close_dir,
init_window_service_worker
} from './filesystem.js'
import {examples_promise} from './examples.js'
const EXAMPLE = `function fib(n) {
if(n == 0 || n == 1) {
return n
} else {
return fib(n - 1) + fib(n - 2)
}
}
2022-09-10 02:48:13 +08:00
2023-07-02 20:22:41 +03:00
fib(6)
`
2022-09-10 02:48:13 +08:00
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) => {
// TODO err.message
w.onerror = (msg, src, lineNum, colNum, err) => {
if(err?.__ignore) {
return
}
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-10-26 13:11:51 +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 => {
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
}
const on_window_load = w => {
init_window_service_worker(w)
exec(
2023-07-11 18:24:28 +03:00
'open_app_window',
new Set(Object.getOwnPropertyNames(w))
)
}
2022-10-26 13:11:51 +08:00
// By default run code in hidden iframe, until user explicitly opens visible
// window
let iframe
const open_run_iframe = (state) => {
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-07-11 18:24:28 +03:00
// for app_window, do not set unhandled rejection, because having rejected
2023-01-17 11:48:01 +08:00
// promises in user code is normal condition
set_error_handler(iframe.contentWindow, false)
2023-07-11 18:24:28 +03:00
globalThis.app_window = iframe.contentWindow
init_app_window(globalThis.app_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
2023-07-11 18:24:28 +03:00
export const open_app_window = state => {
// TODO set_error_handler? Or we dont need to set_error_handler for child
// window because error is always caught by parent window handler?
2023-07-11 18:24:28 +03:00
globalThis.app_window.close()
globalThis.app_window = open(get_html_url(state))
init_app_window(globalThis.app_window)
2023-07-02 19:42:44 +03:00
}
2022-11-26 02:56:32 +08:00
2023-07-11 18:24:28 +03:00
const init_app_window = w => {
2022-11-26 02:56:32 +08:00
const is_loaded = () => {
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()
on_window_load(w)
2022-11-26 02:56:32 +08:00
} else {
w.addEventListener('load', () => {
2022-11-26 02:56:32 +08:00
add_unload_handler()
// 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?
on_window_load(w)
2022-11-26 02:56:32 +08:00
})
}
}
const add_unload_handler = () => {
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-11 18:24:28 +03:00
if(w.closed && w == globalThis.app_window) {
// If by that time w.closed was set to true, then page was
// closed. Get back to using iframe
2023-07-11 18:24:28 +03:00
globalThis.app_window = iframe.contentWindow
reload_app_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
}
2023-07-11 18:24:28 +03:00
export const reload_app_window = state => {
// TODO after window location reload, open_app_window command will be fired.
// Maybe we should have separate commands for open_app_window and
// reload_app_window?
globalThis.app_window.location = get_html_url(state)
2022-11-28 20:53:35 +08:00
}
2023-07-02 20:22:41 +03:00
2022-09-10 02:48:13 +08:00
2023-07-02 20:22:41 +03:00
const get_entrypoint_settings = () => {
const params = new URLSearchParams(window.location.search)
const entrypoint = null
?? params.get('entrypoint')
?? localStorage.entrypoint
?? ''
return {
current_module: params.get('entrypoint') ?? localStorage.current_module ?? '',
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')
}
2023-07-02 20:22:41 +03:00
open_dir(true).then(dir => {
exec('load_dir', dir, true, get_entrypoint_settings())
})
}
2023-07-02 20:22:41 +03:00
export const close_directory = async () => {
close_dir()
exec('load_dir', await examples_promise, false, 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
2023-07-13 20:03:33 +03:00
export const init = async (container, _COMMANDS) => {
2022-11-26 01:25:31 +08:00
COMMANDS = _COMMANDS
set_error_handler(window)
2022-09-10 02:48:13 +08:00
2023-07-13 20:03:33 +03:00
const default_module = {'': localStorage.code || EXAMPLE}
let initial_state
const project_dir = await open_dir(false)
if(project_dir == null) {
initial_state = {
project_dir: await examples_promise,
files: default_module,
has_file_system_access: false,
}
} else {
initial_state = {
project_dir,
files: default_module,
has_file_system_access: true,
}
}
2022-11-28 20:53:35 +08:00
2023-07-13 20:03:33 +03:00
state = COMMANDS.get_initial_state(
{
...initial_state,
on_deferred_call: (...args) => exec('on_deferred_call', ...args)
},
2023-07-13 20:03:33 +03:00
get_entrypoint_settings(),
)
2022-11-28 22:02:37 +08:00
2023-07-13 20:03:33 +03: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
2023-07-13 20:03:33 +03:00
render_initial_state(ui, state)
2022-11-28 22:02:37 +08:00
2023-07-13 20:03:33 +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
}