Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🔆 Fancy JS animation magic support #582

Merged
merged 10 commits into from
Oct 20, 2020
18 changes: 13 additions & 5 deletions frontend/common/Bond.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,28 @@

import observablehq from "./SetupCellEnvironment.js"

export const connect_bonds = (node, all_completed_promise, requests) => {
export const connect_bonds = (node, all_completed_promise, cell_invalidated_promise, requests) => {
node.querySelectorAll("bond").forEach(async (bond_node) => {
// read the docs on Generators.input from observablehq/stdlib:
const inputs = observablehq.Generators.input(bond_node.firstElementChild)
var is_first_value = true

while (bond_node.getRootNode() == document) {
// the <bond> node will be invalidated when the cell re-evaluates. when this happens, we need to stop processing input events
var node_is_invalidated = false
cell_invalidated_promise.then(() => {
node_is_invalidated = true
})

while (!node_is_invalidated) {
// wait for all (other) cells to complete - if we don't, the Julia code would get overloaded with new values
await all_completed_promise.current
// wait for a new input value. If a value is ready, then this promise resolves immediately
const val = await inputs.next().value
// send to the Pluto back-end (have a look at set_bond in Editor.js)
const to_send = await transformed_val(val)
requests.set_bond(bond_node.getAttribute("def"), to_send, is_first_value)
if (!node_is_invalidated) {
// send to the Pluto back-end (have a look at set_bond in Editor.js)
const to_send = await transformed_val(val)
requests.set_bond(bond_node.getAttribute("def"), to_send, is_first_value)
}
// the first value might want to be ignored - https://github.com/fonsp/Pluto.jl/issues/275
is_first_value = false
}
Expand Down
136 changes: 92 additions & 44 deletions frontend/components/CellOutput.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import { observablehq_for_cells } from "../common/SetupCellEnvironment.js"
import "../treeview.js"

export class CellOutput extends Component {
constructor() {
super()
this.compensate_scrollheight_ref = { current: () => {} }
}

shouldComponentUpdate({ last_run_timestamp }) {
return last_run_timestamp !== this.props.last_run_timestamp
}
Expand All @@ -20,15 +25,22 @@ export class CellOutput extends Component {
}

componentDidUpdate(prevProps, prevState, snapshot) {
// Scroll the page to compensate for change in page height:
const new_height = this.base.scrollHeight

if (document.body.querySelector("pluto-cell:focus-within")) {
const cell_outputs_after_focused = document.body.querySelectorAll("pluto-cell:focus-within ~ pluto-cell > pluto-output") // CSS wizardry ✨
if (cell_outputs_after_focused.length == 0 || !Array.from(cell_outputs_after_focused).includes(this.base)) {
window.scrollBy(0, new_height - snapshot)
this.compensate_scrollheight_ref.current = () => {
// Scroll the page to compensate for change in page height:
const new_height = this.base.scrollHeight
if (document.body.querySelector("pluto-cell:focus-within")) {
const cell_outputs_after_focused = document.body.querySelectorAll("pluto-cell:focus-within ~ pluto-cell > pluto-output") // CSS wizardry ✨
if (cell_outputs_after_focused.length == 0 || !Array.from(cell_outputs_after_focused).includes(this.base)) {
window.scrollBy(0, new_height - snapshot)
}
}
}

if (!needs_delayed_scrollheight_compensation(this.props.mime)) {
this.compensate_scrollheight_ref.current()
} else {
// then this is handled by RawHTMLContainer or PlutoImage, asynchronously
}
}

render() {
Expand All @@ -41,49 +53,84 @@ export class CellOutput extends Component {
mime=${this.props.mime}
>
<assignee>${this.props.rootassignee}</assignee>
<${OutputBody} ...${this.props} />
<${OutputBody} ...${this.props} compensate_scrollheight_ref=${this.compensate_scrollheight_ref} />
</pluto-output>
`
}
}

let PlutoImage = ({ body, mime }) => {
let PlutoImage = ({ body, mime, compensate_scrollheight_ref }) => {
// I know I know, this looks stupid.
// BUT it is necessary to make sure the object url is only created when we are actually attaching to the DOM,
// and is removed when we are detatching from the DOM
let imgref = useRef()
useLayoutEffect(() => {
let url = URL.createObjectURL(new Blob([body], { type: mime }))

// we compensate scrolling after the image has loaded
imgref.current.onload = imgref.current.onerror = () => {
imgref.current.style.display = null
compensate_scrollheight_ref.current()
}
if (imgref.current.src === "") {
// an <img> that is loading takes up 21 vertical pixels, which causes a 1-frame scroll flicker
// the solution is to make the <img> invisible until the image is loaded
imgref.current.style.display = "none"
}
imgref.current.src = url

return () => URL.revokeObjectURL(url)
}, [body])

return html`<div><img ref=${imgref} type=${mime} src=${""} /></div>`
}

const OutputBody = ({ mime, body, cell_id, all_completed_promise, requests }) => {
const needs_delayed_scrollheight_compensation = (mime) => {
return (
mime === "text/html" ||
mime === "image/png" ||
mime === "image/jpg" ||
mime === "image/jpeg" ||
mime === "image/gif" ||
mime === "image/bmp" ||
mime === "image/svg+xml"
)
}

const OutputBody = ({ mime, body, cell_id, all_completed_promise, requests, compensate_scrollheight_ref, persist_js_state }) => {
switch (mime) {
case "image/png":
case "image/jpg":
case "image/jpeg":
case "image/gif":
case "image/bmp":
case "image/svg+xml":
return html`<${PlutoImage} mime=${mime} body=${body} />`
return html`<${PlutoImage} mime=${mime} body=${body} compensate_scrollheight_ref=${compensate_scrollheight_ref} />`
break
case "text/html":
// Snippets starting with <!DOCTYPE or <html> are considered "full pages" that get their own iframe.
// Not entirely sure if this works the best, or if this slows down notebooks with many plots too much.
// AFAIK JSServe and Plotly both trigger and iframe now.
// NOTE: Jupyter doesn't do this, jupyter renders everything directly in pages DOM
if (body.startsWith("<!DOCTYPE ") || body.startsWith("<html>")) {
// Snippets starting with <!DOCTYPE or <html are considered "full pages" that get their own iframe.
// Not entirely sure if this works the best, or if this slows down notebooks with many plots.
// AFAIK JSServe and Plotly both trigger this code.
// NOTE: Jupyter doesn't do this, jupyter renders everything directly in pages DOM.
if (body.startsWith("<!DOCTYPE") || body.startsWith("<html")) {
return html`<${IframeContainer} body=${body} />`
} else {
return html`<${RawHTMLContainer} body=${body} all_completed_promise=${all_completed_promise} requests=${requests} />`
return html`<${RawHTMLContainer}
body=${body}
all_completed_promise=${all_completed_promise}
requests=${requests}
compensate_scrollheight_ref=${compensate_scrollheight_ref}
persist_js_state=${persist_js_state}
/>`
}
break
case "application/vnd.pluto.tree+xml":
return html`<${RawHTMLContainer} body=${body} all_completed_promise=${all_completed_promise} requests=${requests} />`
return html`<${RawHTMLContainer}
body=${body}
all_completed_promise=${all_completed_promise}
requests=${requests}
compensate_scrollheight_ref=${undefined}
/>`
break
case "application/vnd.pluto.stacktrace+json":
return html`<div><${ErrorMessage} cell_id=${cell_id} requests=${requests} ...${JSON.parse(body)} /></div>`
Expand Down Expand Up @@ -136,13 +183,8 @@ let IframeContainer = ({ body }) => {
* @param {{ code: string, environment: { [name: string]: any } }} options
*/
let execute_dynamic_function = async ({ environment, code }) => {
const wrapped_code = `
"use strict";
let fn = async () => {
${code}
}
return fn()
`
// single line so that we don't affect line numbers in the stack trace
const wrapped_code = `"use strict"; return (async () => {${code}})()`

let { ["this"]: this_value, ...args } = environment
let arg_names = Object.keys(args)
Expand All @@ -156,31 +198,32 @@ const execute_scripttags = async ({ root_node, script_nodes, previous_results_ma

// Run scripts sequentially
for (let node of script_nodes) {
root_node.currentScript = node

if (node.src != "") {
// If it has a remote src="", de-dupe and copy the script to head
if (!Array.from(document.head.querySelectorAll("script")).some((s) => s.src === node.src)) {
const new_el = document.createElement("script")
new_el.src = node.src
new_el.type = node.type === "module" ? "module" : "text/javascript"
var script_el = Array.from(document.head.querySelectorAll("script")).find((s) => s.src === node.src)

// new_el.async = false
if (script_el == null) {
script_el = document.createElement("script")
script_el.src = node.src
script_el.type = node.type === "module" ? "module" : "text/javascript"
script_el.pluto_is_loading_me = true
}
const need_to_await = script_el.pluto_is_loading_me != null
if (need_to_await) {
await new Promise((resolve) => {
new_el.addEventListener("load", resolve)
new_el.addEventListener("error", resolve)
document.head.appendChild(new_el)
script_el.addEventListener("load", resolve)
script_el.addEventListener("error", resolve)
document.head.appendChild(script_el)
})
} else {
continue
script_el.pluto_is_loading_me = undefined
}
} else {
// If there is no src="", we take the content en run it in an observablehq-like environment
// If there is no src="", we take the content and run it in an observablehq-like environment
try {
let script_id = node.id
let result = await execute_dynamic_function({
environment: {
this: script_id ? previous_results_map.get(script_id) : undefined,
this: script_id ? previous_results_map.get(script_id) : window,
currentScript: node,
invalidation: invalidation,
...observablehq_for_cells,
Expand All @@ -192,11 +235,12 @@ const execute_scripttags = async ({ root_node, script_nodes, previous_results_ma
results_map.set(script_id, result)
}
// Insert returned element
if (result instanceof HTMLElement && result.nodeType === Node.ELEMENT_NODE) {
if (result instanceof Element && result.nodeType === Node.ELEMENT_NODE) {
node.parentElement.insertBefore(result, node)
}
} catch (err) {
console.log("Couldn't execute script:", node)
console.error("Couldn't execute script:", node)
// needs to be in its own console.error so that the stack trace is printed
console.error(err)
// TODO: relay to user
}
Expand All @@ -207,7 +251,7 @@ const execute_scripttags = async ({ root_node, script_nodes, previous_results_ma

let run = (f) => f()

export let RawHTMLContainer = ({ body, all_completed_promise, requests }) => {
export let RawHTMLContainer = ({ body, all_completed_promise, requests, compensate_scrollheight_ref, persist_js_state = false }) => {
let previous_results_map = useRef(new Map())

let invalidate_scripts = useRef(() => {})
Expand All @@ -230,11 +274,11 @@ export let RawHTMLContainer = ({ body, all_completed_promise, requests }) => {
root_node: container.current,
script_nodes: Array.from(container.current.querySelectorAll("script")),
invalidation: invalidation,
previous_results_map: previous_results_map.current,
previous_results_map: persist_js_state ? previous_results_map.current : new Map(),
})

if (all_completed_promise != null && requests != null) {
connect_bonds(container.current, all_completed_promise, requests)
connect_bonds(container.current, all_completed_promise, invalidation, requests)
}

// convert LaTeX to svg
Expand All @@ -251,6 +295,10 @@ export let RawHTMLContainer = ({ body, all_completed_promise, requests }) => {
highlight_julia(code_element)
}
} catch (err) {}

if (compensate_scrollheight_ref != null) {
compensate_scrollheight_ref.current()
}
})

return () => {
Expand Down
2 changes: 1 addition & 1 deletion sample/Interactivity.jl
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ md"""#### More packages

In fact, **_any package_ can add bindable values to their objects**. For example, a geoplotting package could add a JS `input` event to their plot that contains the cursor coordinates when it is clicked. You can then use those coordinates inside Julia.

A package _does not need to add `Pluto.jl` as a dependency to so_: only the `Base.show(io, MIME("text/html"), obj)` function needs to be extended to contain a `<script>` that triggers the `input` event with a value. (It's up to the package creator _when_ and _what_.) This _does not affect_ how the object is displayed outside of Pluto.jl: uncaught events are ignored by your browser."""
A package _does not need to add `Pluto.jl` as a dependency to do so_: only the `Base.show(io, MIME("text/html"), obj)` function needs to be extended to contain a `<script>` that triggers the `input` event with a value. (It's up to the package creator _when_ and _what_.) This _does not affect_ how the object is displayed outside of Pluto.jl: uncaught events are ignored by your browser."""

# ╔═╡ 36ca3050-7f36-11ea-3caf-cb10e945ca99
md"""## Tips
Expand Down
1 change: 1 addition & 0 deletions src/analysis/Errors.jl
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ end
"Send `error` to the frontend without backtrace. Runtime errors are handled by `WorkspaceManager.eval_format_fetch_in_workspace` - this function is for Reactivity errors."
function relay_reactivity_error!(cell::Cell, error::Exception)
cell.last_run_timestamp = time()
cell.persist_js_state = false
cell.errored = true
cell.runtime = missing
cell.output_repr, cell.repr_mime = PlutoRunner.format_output(CapturedException(error, []))
Expand Down
1 change: 1 addition & 0 deletions src/evaluation/Run.jl
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ function run_reactive!(session::ServerSession, notebook::Notebook, old_topology:

cell.queued = false
cell.running = true
cell.persist_js_state = cell ∉ cells
putnotebookupdates!(session, notebook, clientupdate_cell_output(notebook, cell))

if any_interrupted
Expand Down
2 changes: 2 additions & 0 deletions src/notebook/Cell.jl
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Base.@kwdef mutable struct Cell

"Time that the last output was created, used only on the frontend to rerender the output"
last_run_timestamp::Float64=0
"Whether `this` inside `<script id=something>` should refer to the previously returned object in HTML output. This is used for fancy animations. true iff a cell runs as a reactive consequence."
persist_js_state::Bool=false

parsedcode::Union{Nothing,Expr}=nothing
module_usings::Set{Expr}=Set{Expr}()
Expand Down
1 change: 1 addition & 0 deletions src/webserver/Session.jl
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ function clientupdate_cell_output(notebook::Notebook, cell::Cell; initiator::Uni
:errored => cell.errored,
:output => Dict(
:last_run_timestamp => cell.last_run_timestamp,
:persist_js_state => cell.persist_js_state,
:mime => cell.repr_mime,
:body => cell.output_repr,
:rootassignee => cell.rootassignee,
Expand Down