diff --git a/docs/examples/animated_fractal_tree/animated_fractal_tree.js b/docs/examples/animated_fractal_tree/animated_fractal_tree.js new file mode 100644 index 0000000..ef66717 --- /dev/null +++ b/docs/examples/animated_fractal_tree/animated_fractal_tree.js @@ -0,0 +1,68 @@ +// Original source: http://bricault.mit.edu/recursive-drawing +// Author: Sarah Bricault + +// Canvas setup +const canvas = document.createElement('canvas') +canvas.width = 700 +canvas.height = 700 +document.body.appendChild(canvas) +const ctx = canvas.getContext('2d') +ctx.translate(canvas.width / 2, canvas.height) + +// Draw a tree +await fractalTreeBasic({totalIterations: 10, basicLength: 10, rotate: 25}) + +function sleep() { + return new Promise(resolve => setTimeout(resolve, 3)) +} + +async function fractalTreeBasic({totalIterations, basicLength, rotate}) { + + // Draw the tree trunk + const trunkLength = basicLength * 2 * Math.pow(1.2, totalIterations + 1) + const width = Math.pow(totalIterations, 0.6) + + ctx.beginPath() + ctx.moveTo(0, 0) + ctx.lineTo(0, - trunkLength) + ctx.lineWidth = width + ctx.strokeStyle = 'black' + ctx.stroke() + + await drawBranch(90, [0, - trunkLength], totalIterations + 1) + + async function drawBranch(angle, startPoint, iterations) { + const len = basicLength * Math.pow(1.2, iterations) + + const width = Math.pow(iterations, 0.6) + + const red = Math.floor(255 - (iterations / totalIterations) * 255) + const green = 0 + const blue = Math.floor( 255 - (iterations / totalIterations) * 255) + const color = `rgb(${red}, ${green}, ${blue})` + + const x1 = startPoint[0] + const y1 = startPoint[1] + + const y2 = y1 - len * Math.sin((angle * Math.PI) / 180) + const x2 = x1 + len * Math.cos((angle * Math.PI) / 180) + + console.log('draw branch', x1, y1, x2, y2) + + ctx.beginPath() + ctx.moveTo(x1, y1) + ctx.lineTo(x2, y2) + ctx.lineWidth = width + ctx.strokeStyle = color + ctx.stroke() + + await sleep() + + if (iterations - 1 > 0) { + // draw left branch + await drawBranch(angle + rotate, [x2, y2], iterations - 1) + // draw right branch + await drawBranch(angle - rotate, [x2, y2], iterations - 1) + } + } +} diff --git a/docs/examples/canvas_animation_bubbles/bubbles.js b/docs/examples/canvas_animation_bubbles/bubbles.js new file mode 100644 index 0000000..50c0341 --- /dev/null +++ b/docs/examples/canvas_animation_bubbles/bubbles.js @@ -0,0 +1,98 @@ +// Original source: +// https://www.freecodecamp.org/news/how-to-create-animated-bubbles-with-html5-canvas-and-javascript/ + +const canvas = document.createElement('canvas') +canvas.style.backgroundColor = '#00b4ff' +document.body.appendChild(canvas) +canvas.width = window.innerWidth +canvas.height = window.innerHeight + +const context = canvas.getContext("2d") + +context.font = "30px Arial" +context.textAlign = 'center' +context.fillStyle = 'white' +context.fillText('Click to spawn bubbles', canvas.width/2, canvas.height/2) + +let circles = [] + +function draw(circle) { + context.beginPath() + context.arc(circle.x, circle.y, circle.radius, 0, 2 * Math.PI) + context.strokeStyle = `hsl(${circle.hue} 100% 50%)` + context.stroke() + + const gradient = context.createRadialGradient( + circle.x, + circle.y, + 1, + circle.x + 0.5, + circle.y + 0.5, + circle.radius + ) + + gradient.addColorStop(0.3, "rgba(255, 255, 255, 0.3)") + gradient.addColorStop(0.95, "#e7feff") + + context.fillStyle = gradient + context.fill() +} + +function move(circle, timeDelta) { + circle.x = circle.x + timeDelta*circle.dx + circle.y = circle.y - timeDelta*circle.dy +} + +let intervalId + +function startAnimation() { + if(intervalId == null) { + intervalId = setInterval(animate, 20) + } +} + +function stopAnimation() { + if(intervalId != null) { + clearInterval(intervalId) + intervalId = null + } +} + +let prevFrameTime + +const animate = () => { + const now = Date.now() + const timeDelta = prevFrameTime == null ? 0 : now - prevFrameTime + prevFrameTime = now + + if(circles.length == 0) { + return + } + + context.clearRect(0, 0, canvas.width, canvas.height) + + circles.forEach(circle => { + move(circle, timeDelta) + draw(circle) + }) +} + +const createCircles = (event) => { + startAnimation() + + circles = circles.concat(Array.from({length: 50}, () => ( + { + x: event.pageX, + y: event.pageY, + radius: Math.random() * 50, + dx: Math.random() * 0.3, + dy: Math.random() * 0.7, + hue: 200, + } + ))) +} + +canvas.addEventListener("click", createCircles) + +window.onfocus = startAnimation +window.onblur = stopAnimation diff --git a/docs/examples/fractal_tree/fractal_tree.js b/docs/examples/fractal_tree/fractal_tree.js new file mode 100644 index 0000000..f884446 --- /dev/null +++ b/docs/examples/fractal_tree/fractal_tree.js @@ -0,0 +1,62 @@ +// Original source: http://bricault.mit.edu/recursive-drawing +// Author: Sarah Bricault + +// Canvas setup +const canvas = document.createElement('canvas') +canvas.width = 700 +canvas.height = 700 +document.body.appendChild(canvas) +const ctx = canvas.getContext('2d') +ctx.translate(canvas.width / 2, canvas.height) + +// Draw a tree +fractalTreeBasic({totalIterations: 10, basicLength: 10, rotate: 25}) + +function fractalTreeBasic({totalIterations, basicLength, rotate}) { + + // Draw the tree trunk + const trunkLength = basicLength * 2 * Math.pow(1.2, totalIterations + 1) + const width = Math.pow(totalIterations, 0.6) + + ctx.beginPath() + ctx.moveTo(0, 0) + ctx.lineTo(0, - trunkLength) + ctx.lineWidth = width + ctx.strokeStyle = 'black' + ctx.stroke() + + drawBranch(90, [0, - trunkLength], totalIterations + 1) + + function drawBranch(angle, startPoint, iterations) { + const len = basicLength * Math.pow(1.2, iterations) + + const width = Math.pow(iterations, 0.6) + + const red = Math.floor(255 - (iterations / totalIterations) * 255) + const green = 0 + const blue = Math.floor( 255 - (iterations / totalIterations) * 255) + const color = `rgb(${red}, ${green}, ${blue})` + + const x1 = startPoint[0] + const y1 = startPoint[1] + + const y2 = y1 - len * Math.sin((angle * Math.PI) / 180) + const x2 = x1 + len * Math.cos((angle * Math.PI) / 180) + + console.log('draw branch', x1, y1, x2, y2) + + ctx.beginPath() + ctx.moveTo(x1, y1) + ctx.lineTo(x2, y2) + ctx.lineWidth = width + ctx.strokeStyle = color + ctx.stroke() + + if (iterations - 1 > 0) { + // draw left branch + drawBranch(angle + rotate, [x2, y2], iterations - 1) + // draw right branch + drawBranch(angle - rotate, [x2, y2], iterations - 1) + } + } +} diff --git a/src/calltree.js b/src/calltree.js index 24b8dd9..d6ae230 100644 --- a/src/calltree.js +++ b/src/calltree.js @@ -566,19 +566,26 @@ const select_and_toggle_expanded = (state, id) => { } } -export const expand_path = (state, node) => ({ - ...state, - calltree_node_is_expanded: { - ...state.calltree_node_is_expanded, - ...Object.fromEntries( - path_to_root(state.calltree, node) - .map(n => [n.id, true]) - ), - // Also expand node, since it is not included in - // path_to_root - [node.id]: true, +export const expand_path = (state, node) => { + if(state.calltree_node_is_expanded?.[node.id]) { + return state } -}) + + + return { + ...state, + calltree_node_is_expanded: { + ...state.calltree_node_is_expanded, + ...Object.fromEntries( + path_to_root(state.calltree, node) + .map(n => [n.id, true]) + ), + // Also expand node, since it is not included in + // path_to_root + [node.id]: true, + } + } +} export const initial_calltree_node = state => { const root = root_calltree_node(state) diff --git a/src/canvas.js b/src/canvas.js new file mode 100644 index 0000000..609bc5c --- /dev/null +++ b/src/canvas.js @@ -0,0 +1,144 @@ +// TODO time-travel for canvas ImageData + +import {abort_replay} from './runtime/record_io.js' +import {set_record_call} from './runtime/runtime.js' +import {is_expandable} from './calltree.js' + +const context_reset = globalThis?.CanvasRenderingContext2D?.prototype?.reset + +function reset(context) { + if(context_reset != null) { + context_reset.call(context) + } else { + // For older browsers, `reset` may be not available + // changing width does the same as `reset` + // see https://stackoverflow.com/a/45871243/795038 + context.canvas.width = context.canvas.width + 0 + } +} + +function canvas_reset(canvas_ops) { + for(let context of canvas_ops.contexts) { + reset(context) + } +} + +export function apply_canvas_patches(window) { + const proto = window?.CanvasRenderingContext2D?.prototype + + if(proto == null) { + return + } + + const props = Object.getOwnPropertyDescriptors(proto) + + Object.entries(props).forEach(([name, p]) => { + if(p.value != null) { + if(typeof(p.value) != 'function') { + // At the moment this was written, all canvas values were functions + return + } + const method = p.value + proto[name] = { + // declare function like this so it has `name` property set + [name]() { + const cxt = window.__cxt + + set_record_call(cxt) + + /* + abort replay, because otherwise animated_fractal_tree would replay + instantly (because setTimeout is in io_trace) + */ + if(!cxt.io_trace_is_recording && !cxt.is_recording_deferred_calls) { + abort_replay(cxt) + } + + const version_number = ++cxt.version_counter + + try { + return method.apply(this, arguments) + } finally { + cxt.canvas_ops.contexts.add(this) + cxt.canvas_ops.ops.push({ + canvas_context: this, + method, + version_number, + args: arguments, + }) + } + } + }[name] + } + + if(p.set != null) { + const set_op = p.set + Object.defineProperty(proto, name, { + set(prop_value) { + const cxt = window.__cxt + + set_record_call(cxt) + + if(!cxt.io_trace_is_recording && !cxt.is_recording_deferred_calls) { + abort_replay(cxt) + } + + const version_number = ++cxt.version_counter + + try { + set_op.call(this, prop_value) + } finally { + cxt.canvas_ops.contexts.add(this) + cxt.canvas_ops.ops.push({ + canvas_context: this, + version_number, + set_op, + prop_value, + }) + } + } + }) + } + }) +} + +function replay_op(op) { + if(op.method != null) { + op.method.apply(op.canvas_context, op.args) + } else if(op.set_op != null) { + op.set_op.call(op.canvas_context, op.prop_value) + } else { + throw new Error('illegal op') + } +} + +export function redraw_canvas(state, is_replay_all_canvs_ops) { + if(state.calltree == null) { + // code is invalid or not executed yet + return + } + + const cxt = state.rt_cxt + + if(cxt.canvas_ops.ops == null) { + return + } + + canvas_reset(cxt.canvas_ops) + + if(is_replay_all_canvs_ops) { + for(let op of cxt.canvas_ops.ops) { + replay_op(op) + } + } else { + const current = state.current_calltree_node + // replay all ops up to current_calltree_node, including + const version_number = state.current_calltree_node.last_version_number + for(let op of cxt.canvas_ops.ops) { + if(op.version_number > version_number) { + break + } + replay_op(op) + } + } +} diff --git a/src/editor/calltree.js b/src/editor/calltree.js index d75490d..caf48c3 100644 --- a/src/editor/calltree.js +++ b/src/editor/calltree.js @@ -150,10 +150,13 @@ export class CallTree { } this.state = state this.render_active(this.state.current_calltree_node, true) - scrollIntoViewIfNeeded( - this.container, - this.node_to_el.get(this.state.current_calltree_node.id).getElementsByClassName('call_el')[0] - ) + if(prev?.current_calltree_node != state.current_calltree_node) { + // prevent scroll on adding deferred call + scrollIntoViewIfNeeded( + this.container, + this.node_to_el.get(this.state.current_calltree_node.id).getElementsByClassName('call_el')[0] + ) + } } render_expand_node(prev_state, state) { diff --git a/src/editor/editor.js b/src/editor/editor.js index 14aaa12..14a8c2e 100644 --- a/src/editor/editor.js +++ b/src/editor/editor.js @@ -171,9 +171,13 @@ export class Editor { }) } + has_value_explorer() { + return this.value_explorer != null + } + unembed_value_explorer() { - if(this.value_explorer == null) { - return + if(!this.has_value_explorer()) { + return null } const session = this.ace_editor.getSession() diff --git a/src/editor/ui.js b/src/editor/ui.js index e28833f..bf28cc6 100644 --- a/src/editor/ui.js +++ b/src/editor/ui.js @@ -6,6 +6,7 @@ import {Logs} from './logs.js' import {IO_Trace} from './io_trace.js' import {ShareDialog} from './share_dialog.js' import {el} from './domutils.js' +import {redraw_canvas} from '../canvas.js' export class UI { constructor(container, state){ @@ -174,6 +175,27 @@ export class UI { this.render_current_module(state.current_module) this.set_active_tab('calltree', true) + + container.addEventListener('focusin', e => { + const active = document.activeElement + let is_focus_in_editor + if(this.editor_container.contains(document.activeElement)) { + if(this.editor.has_value_explorer()) { + // depends on if we come to value explorer from editor or from debugger + is_focus_in_editor = !this.debugger_container.contains( + this.editor.value_explorer.return_to + ) + } else { + is_focus_in_editor = true + } + } else { + is_focus_in_editor = false + } + if(this.prev_is_focus_in_editor != is_focus_in_editor) { + this.prev_is_focus_in_editor= is_focus_in_editor + redraw_canvas(get_state(), is_focus_in_editor) + } + }) } set_active_tab(tab_id, skip_focus = false) { diff --git a/src/effects.js b/src/effects.js index e28db3e..25c2732 100644 --- a/src/effects.js +++ b/src/effects.js @@ -8,6 +8,7 @@ import { } from './calltree.js' import {current_cursor_position} from './calltree.js' import {exec, reload_app_window, FILES_ROOT} from './index.js' +import {redraw_canvas} from './canvas.js' // Imports in the context of `app_window`, so global variables in loaded // modules refer to that window's context @@ -139,7 +140,7 @@ export const render_initial_state = (ui, state, example) => { } } -export const apply_side_effects = (prev, next, ui) => { +export const apply_side_effects = (prev, next, ui, cmd) => { if(prev.project_dir != next.project_dir) { ui.files.render(next) } @@ -156,7 +157,6 @@ export const apply_side_effects = (prev, next, ui) => { if(prev.entrypoint != next.entrypoint) { localStorage.entrypoint = next.entrypoint } - if(prev.html_file != next.html_file) { localStorage.html_file = next.html_file } @@ -167,7 +167,8 @@ export const apply_side_effects = (prev, next, ui) => { ui.editor.switch_session(next.current_module) } - if(current_cursor_position(next) != ui.editor.get_cursor_position()) { + // Do not set cursor position on_deferred_call, because editor may be in the middle of the edition operation + if(current_cursor_position(next) != ui.editor.get_cursor_position() && cmd != 'on_deferred_call') { ui.editor.set_cursor_position(current_cursor_position(next)) } @@ -205,6 +206,9 @@ export const apply_side_effects = (prev, next, ui) => { || prev.calltree_changed_token != next.calltree_changed_token ) { + + // code finished executing + const is_loading = next.loading_external_imports_state != null || @@ -233,6 +237,8 @@ export const apply_side_effects = (prev, next, ui) => { } else { + // code was already executed before current action + if(get_deferred_calls(prev) == null && get_deferred_calls(next) != null) { ui.calltree.render_deferred_calls(next) } @@ -256,6 +262,16 @@ export const apply_side_effects = (prev, next, ui) => { } ui.logs.render_logs(next, prev.logs, next.logs) + + + // Redraw canvas + if( + prev.current_calltree_node != next.current_calltree_node + || + prev.calltree_node_is_expanded != next.calltree_node_is_expanded + ) { + redraw_canvas(next, ui.is_focus_in_editor) + } } } diff --git a/src/eval.js b/src/eval.js index 098177e..fec708e 100644 --- a/src/eval.js +++ b/src/eval.js @@ -501,6 +501,8 @@ export const eval_modules = ( window: globalThis.app_window, storage, + + canvas_ops: {ops: [], contexts: new Set()}, } const result = run(module_fns, cxt, io_trace) diff --git a/src/examples.js b/src/examples.js index 9c0a37d..24aac2d 100644 --- a/src/examples.js +++ b/src/examples.js @@ -31,6 +31,22 @@ export const examples = [ path: 'plot', entrypoint: 'plot/index.js', }, + + { + path: 'fractal_tree', + entrypoint: 'fractal_tree/fractal_tree.js', + with_app_window: true, + }, + { + path: 'animated_fractal_tree', + entrypoint: 'animated_fractal_tree/animated_fractal_tree.js', + with_app_window: true, + }, + { + path: 'canvas_animation_bubbles', + entrypoint: 'canvas_animation_bubbles/bubbles.js', + with_app_window: true, + }, ].map(e => ({...e, entrypoint: e.entrypoint ?? e.path})) const files_list = examples diff --git a/src/index.js b/src/index.js index cd5d882..64ce394 100644 --- a/src/index.js +++ b/src/index.js @@ -309,8 +309,9 @@ export const exec = (cmd, ...args) => { // Wrap with_code_execution, because rendering values can trigger execution // of code by toString() and toJSON() methods + with_code_execution(() => { - apply_side_effects(state, nextstate, ui) + apply_side_effects(state, nextstate, ui, cmd) if(effects != null) { (Array.isArray(effects) ? effects : [effects]).forEach(e => { diff --git a/src/runtime/record_io.js b/src/runtime/record_io.js index 334e473..7b172a2 100644 --- a/src/runtime/record_io.js +++ b/src/runtime/record_io.js @@ -19,33 +19,61 @@ const io_patch = (window, path, use_context = false) => { obj[method].__original = original } +export const abort_replay = (cxt) => { + cxt.io_trace_is_replay_aborted = true + cxt.io_trace_abort_replay() + // throw error to prevent further code execution. It + // is not necesseary, because execution would not have + // any effects anyway + const error = new Error('io replay aborted') + error.__ignore = true + throw error +} + +const patched_method_prolog = (window, original, that, has_new_target, args) => { + const cxt = window.__cxt + + if(cxt.io_trace_is_replay_aborted) { + // Try to finish fast + const error = new Error('io replay was aborted') + error.__ignore = true + throw error + } + + // save call, so on expand_call and find_call IO functions would not be + // called. + // TODO: we have a problem when IO function is called from third-party + // lib and async context is lost + set_record_call(cxt) + + if(cxt.is_recording_deferred_calls) { + // TODO record trace on deferred calls? + const value = has_new_target + ? new original(...args) + : original.apply(that, args) + return {is_return: true, value} + } + + return {is_return: false} +} + const make_patched_method = (window, original, name, use_context) => { const method = function(...args) { - - const cxt = window.__cxt - - if(cxt.io_trace_is_replay_aborted) { - // Try to finish fast - const error = new Error('io replay was aborted') - error.__ignore = true - throw error - } - - // save call, so on expand_call and find_call IO functions would not be - // called. - // TODO: we have a problem when IO function is called from third-party - // lib and async context is lost - set_record_call(cxt) - const has_new_target = new.target != null - if(cxt.is_recording_deferred_calls) { - // TODO record trace on deferred calls? - return has_new_target - ? new original(...args) - : original.apply(this, args) + const {value, is_return} = patched_method_prolog( + window, + original, + this, + has_new_target, + args + ) + if(is_return) { + return value } + const cxt = window.__cxt + const cxt_copy = cxt if(cxt.io_trace_is_recording) { @@ -142,14 +170,7 @@ const make_patched_method = (window, original, name, use_context) => { ) ) ){ - cxt.io_trace_is_replay_aborted = true - cxt.io_trace_abort_replay() - // throw error to prevent further code execution. It - // is not necesseary, becuase execution would not have - // any effects anyway - const error = new Error('io replay aborted') - error.__ignore = true - throw error + abort_replay(cxt) } else { const next_resolution = cxt.io_trace.find((e, i) => @@ -179,6 +200,7 @@ const make_patched_method = (window, original, name, use_context) => { const next_event = cxt.io_trace[cxt.io_trace_index] if(next_event.type == 'call') { + // TODO use abort_replay cxt.io_trace_is_replay_aborted = true cxt.io_trace_abort_replay() } else { @@ -267,6 +289,41 @@ const patch_Date = (window) => { io_patch(window, ['Date', 'now']) } +const patch_interval = (window, name) => { + const original = window[name] + + window[name] = function(...args) { + const has_new_target = new.target != null + const {value, is_return} = patched_method_prolog( + window, + original, + this, + has_new_target, + args + ) + if(is_return) { + return value + } + + const cxt = window.__cxt + + if(!cxt.io_trace_is_recording) { + /* + Discard io_trace. Run code without IO trace because it is not clear + how it should work with io trace + */ + abort_replay(cxt) + } + + return has_new_target + ? new original(...args) + : original.apply(this, args) + } + + Object.defineProperty(window[name], 'name', {value: name}) +} + + export const apply_io_patches = (window) => { io_patch(window, ['Math', 'random']) @@ -276,7 +333,8 @@ export const apply_io_patches = (window) => { // replaying from trace io_patch(window, ['clearTimeout']) - // TODO patch setInterval to only cleanup all intervals on finish + patch_interval(window, 'setInterval') + patch_interval(window, 'clearInterval') patch_Date(window) diff --git a/src/runtime/runtime.js b/src/runtime/runtime.js index af11a1d..77e5f42 100644 --- a/src/runtime/runtime.js +++ b/src/runtime/runtime.js @@ -4,6 +4,7 @@ import {defineMultiversionArray, create_array, wrap_array} from './array.js' import {create_object} from './object.js' import {defineMultiversionSet} from './set.js' import {defineMultiversionMap} from './map.js' +import {apply_canvas_patches} from '../canvas.js' /* Converts generator-returning function to promise-returning function. Allows to @@ -44,6 +45,7 @@ export const run = gen_to_promise(function*(module_fns, cxt, io_trace) { defineMultiversion(cxt.window) apply_io_patches(cxt.window) inject_leporello_api(cxt) + apply_canvas_patches(cxt.window) cxt.window.__is_initialized = true } else { throw new Error('illegal state')