Skip to content

Commit

Permalink
πŸ”† Fancy JS animation magic support (#582)
Browse files Browse the repository at this point in the history
  • Loading branch information
fonsp committed Oct 20, 2020
1 parent 1c0012b commit 6a8d495
Show file tree
Hide file tree
Showing 7 changed files with 111 additions and 50 deletions.
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

0 comments on commit 6a8d495

Please sign in to comment.