From b9d567d0abe4431dcee5ba5798141d482a07909c Mon Sep 17 00:00:00 2001 From: Fons van der Plas Date: Fri, 2 Apr 2021 13:18:28 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=8C=88=20Interactive=20@bind=20on=20a=20s?= =?UTF-8?q?tatic=20preview=20(#988)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/common/PlutoHash.js | 39 ++++++++++++ frontend/common/SliderServerClient.js | 88 +++++++++++++++++++++++++++ frontend/components/Editor.js | 63 ++++++++++++------- 3 files changed, 168 insertions(+), 22 deletions(-) create mode 100644 frontend/common/PlutoHash.js create mode 100644 frontend/common/SliderServerClient.js diff --git a/frontend/common/PlutoHash.js b/frontend/common/PlutoHash.js new file mode 100644 index 0000000000..e0ff80e1c4 --- /dev/null +++ b/frontend/common/PlutoHash.js @@ -0,0 +1,39 @@ +export const base64_arraybuffer = async (/** @type {BufferSource} */ data) => { + const base64url = await new Promise((r) => { + const reader = new FileReader() + reader.onload = () => r(reader.result) + reader.readAsDataURL(new Blob([data])) + }) + + return base64url.split(",", 2)[1] +} + +export const hash_arraybuffer = async (/** @type {BufferSource} */ data) => { + // @ts-ignore + const hashed_buffer = await window.crypto.subtle.digest("SHA-256", data) + return await base64_arraybuffer(hashed_buffer) +} + +export const hash_str = async (/** @type {string} */ s) => { + const data = new TextEncoder().encode(s) + return await hash_arraybuffer(data) +} + +export const debounced_promises = (async_function) => { + let currently_running = false + let rerun_when_done = false + + return async () => { + if (currently_running) { + rerun_when_done = true + } else { + currently_running = true + rerun_when_done = true + while (rerun_when_done) { + rerun_when_done = false + await async_function() + } + currently_running = false + } + } +} diff --git a/frontend/common/SliderServerClient.js b/frontend/common/SliderServerClient.js new file mode 100644 index 0000000000..0d12f66a66 --- /dev/null +++ b/frontend/common/SliderServerClient.js @@ -0,0 +1,88 @@ +import { trailingslash } from "./Binder.js" +import { hash_arraybuffer, debounced_promises, base64_arraybuffer } from "./PlutoHash.js" +import { pack, unpack } from "./MsgPack.js" +import immer from "../imports/immer.js" +import _ from "../imports/lodash.js" + +export const nothing_actions = ({ actions }) => Object.fromEntries(Object.keys(actions).map((k) => [k, () => {}])) + +export const slider_server_actions = ({ setStatePromise, launch_params, actions, get_original_state, get_current_state, apply_notebook_patches }) => { + const notebookfile_hash = fetch(launch_params.notebookfile) + .then((r) => r.arrayBuffer()) + .then(hash_arraybuffer) + + notebookfile_hash.then((x) => console.log("Notebook file hash:", x)) + + const bond_connections = notebookfile_hash + .then((hash) => fetch(trailingslash(launch_params.slider_server_url) + "bondconnections/" + encodeURIComponent(hash) + "/")) + .then((r) => r.arrayBuffer()) + .then((b) => unpack(new Uint8Array(b))) + + bond_connections.then((x) => console.log("Bond connections:", x)) + + const mybonds = {} + const bonds_to_set = { + current: new Set(), + } + const request_bond_response = debounced_promises(async () => { + const base = trailingslash(launch_params.slider_server_url) + const hash = await notebookfile_hash + const graph = await bond_connections + + if (bonds_to_set.current.size > 0) { + const to_send = new Set(bonds_to_set.current) + bonds_to_set.current.forEach((varname) => (graph[varname] ?? []).forEach((x) => to_send.add(x))) + console.debug("Requesting bonds", bonds_to_set.current, to_send) + bonds_to_set.current = new Set() + + const mybonds_filtered = Object.fromEntries(Object.entries(mybonds).filter(([k, v]) => to_send.has(k))) + + const packed = pack(mybonds_filtered) + + const url = base + "staterequest/" + encodeURIComponent(hash) + "/" + + try { + const use_get = url.length + (packed.length * 4) / 3 + 20 < 8000 + + const response = use_get + ? await fetch(url + encodeURIComponent(await base64_arraybuffer(packed)), { + method: "GET", + }) + : await fetch(url, { + method: "POST", + body: packed, + }) + + const { patches, ids_of_cells_that_ran } = unpack(new Uint8Array(await response.arrayBuffer())) + + await apply_notebook_patches( + patches, + immer((state) => { + const original = get_original_state() + ids_of_cells_that_ran.forEach((id) => { + state.cell_results[id] = original.cell_results[id] + }) + })(get_current_state()) + ) + } catch (e) { + console.error(e) + } + } + }) + + return { + ...nothing_actions({ actions }), + set_bond: async (symbol, value, is_first_value) => { + setStatePromise( + immer((state) => { + state.notebook.bonds[symbol] = { value: value } + }) + ) + if (mybonds[symbol] == null || !_.isEqual(mybonds[symbol].value, value)) { + mybonds[symbol] = { value: value } + bonds_to_set.current.add(symbol) + await request_bond_response() + } + }, + } +} diff --git a/frontend/components/Editor.js b/frontend/components/Editor.js index 8d281a8834..237120e060 100644 --- a/frontend/components/Editor.js +++ b/frontend/components/Editor.js @@ -2,7 +2,7 @@ import { html, Component, useState, useEffect, useMemo } from "../imports/Preact import immer, { applyPatches, produceWithPatches } from "../imports/immer.js" import _ from "../imports/lodash.js" -import { create_pluto_connection, resolvable_promise, ws_address_from_base } from "../common/PlutoConnection.js" +import { create_pluto_connection } from "../common/PlutoConnection.js" import { init_feedback } from "../common/Feedback.js" import { FilePicker } from "./FilePicker.js" @@ -19,11 +19,12 @@ import { slice_utf8, length_utf8 } from "../common/UnicodeTools.js" import { has_ctrl_or_cmd_pressed, ctrl_or_cmd_name, is_mac_keyboard, in_textarea_or_input } from "../common/KeyboardShortcuts.js" import { handle_log } from "../common/Logging.js" import { PlutoContext, PlutoBondsContext } from "../common/PlutoContext.js" -import { pack, unpack } from "../common/MsgPack.js" +import { unpack } from "../common/MsgPack.js" import { useDropHandler } from "./useDropHandler.js" import { start_binder, BinderPhase } from "../common/Binder.js" import { read_Uint8Array_with_progress, FetchProgress } from "./FetchProgress.js" import { BinderButton } from "./BinderButton.js" +import { slider_server_actions, nothing_actions } from "../common/SliderServerClient.js" const default_path = "..." const DEBUG_DIFFING = false @@ -195,6 +196,8 @@ export class Editor extends Component { disable_ui: !!(url_params.get("disable_ui") ?? window.pluto_disable_ui), //@ts-ignore binder_url: url_params.get("binder_url") ?? window.pluto_binder_url, + //@ts-ignore + slider_server_url: url_params.get("slider_server_url") ?? window.pluto_slider_server_url, } this.state = { @@ -651,17 +654,31 @@ patch: ${JSON.stringify( connect_metadata: { notebook_id: this.state.notebook.notebook_id }, }).then(on_establish_connection) - const real_actions = this.actions - const fake_actions = Object.fromEntries(Object.keys(this.actions).map((k) => [k, () => {}])) + this.real_actions = this.actions + this.fake_actions = + this.launch_params.slider_server_url != null + ? slider_server_actions({ + setStatePromise: this.setStatePromise, + actions: this.actions, + launch_params: this.launch_params, + apply_notebook_patches, + get_original_state: () => this.original_state, + get_current_state: () => this.state.notebook, + }) + : nothing_actions({ + actions: this.actions, + }) this.on_disable_ui = () => { document.body.classList.toggle("disable_ui", this.state.disable_ui) document.head.querySelector("link[data-pluto-file='hide-ui']").setAttribute("media", this.state.disable_ui ? "all" : "print") //@ts-ignore - this.actions = this.state.disable_ui ? fake_actions : real_actions //heyo + this.actions = + this.state.disable_ui || (this.launch_params.slider_server_url != null && !this.state.connected) ? this.fake_actions : this.real_actions //heyo } this.on_disable_ui() + this.original_state = null if (this.state.static_preview) { ;(async () => { const r = await fetch(this.launch_params.statefile) @@ -670,8 +687,10 @@ patch: ${JSON.stringify( statefile_download_progress: progress, }) }) + const state = unpack(data) + this.original_state = state this.setState({ - notebook: unpack(data), + notebook: state, initializing: false, binder_phase: this.state.offer_binder ? BinderPhase.wait_for_user : null, }) @@ -870,22 +889,22 @@ patch: ${JSON.stringify( } }) - // Disabled because we don't want to accidentally delete cells - // or we can enable it with a prompt - // Even better would be excel style: grey out until you paste it. If you paste within the same notebook, then it is just a move. - // document.addEventListener("cut", (e) => { - // if (!in_textarea_or_input()) { - // const serialized = this.serialize_selected() - // if (serialized) { - // navigator.clipboard - // .writeText(serialized) - // .then(() => this.delete_selected("Cut")) - // .catch((err) => { - // alert(`Error cutting cells: ${e}`) - // }) - // } - // } - // }) + document.addEventListener("cut", (e) => { + // Disabled because we don't want to accidentally delete cells + // or we can enable it with a prompt + // Even better would be excel style: grey out until you paste it. If you paste within the same notebook, then it is just a move. + // if (!in_textarea_or_input()) { + // const serialized = this.serialize_selected() + // if (serialized) { + // navigator.clipboard + // .writeText(serialized) + // .then(() => this.delete_selected("Cut")) + // .catch((err) => { + // alert(`Error cutting cells: ${e}`) + // }) + // } + // } + }) document.addEventListener("paste", async (e) => { const topaste = e.clipboardData.getData("text/plain")