PlutoPlotly coverage report

Filename Stmts Miss Cover Missing
src\PlutoPlotly.jl 13 5 61.54% 64-73
src\local_plotly_library.jl 67 12 82.09% 2-5, 35-37, 52-55, 65, 136-137
src\basics.jl 64 6 90.62% 36-39, 45, 52, 140, 157
src\main_struct.jl 27 0 100.00%
src\paste_receiver.jl 1 0 100.00%
src\mathjax.jl 2 0 100.00%
src\preprocess.jl 65 1 98.46% 78
src\js_helpers.jl 27 11 59.26% 33, 60-78
src\show.jl 4 0 100.00%
src\plotlybase_forward.jl 11 0 100.00%
src\utilities.jl 22 0 100.00%
src\script_contents\clipboard.jl 0 0 0.00%
src\script_contents\resizer.jl 0 0 0.00%
ext\PlotlyKaleidoExt.jl 19 19 0.00% 6-32
ext\UnitfulExt.jl 1 0 100.00%
TOTAL 323 54 83.28%

src\PlutoPlotly.jl

1  
2  
3  
4  
5  
6  
7  
8  
9  
10  
11  
12  
13  
14  
15  
16  
17  
18  
19  
20  
21  
22  
23  
24  
25  
26  
27  
28  
29  
30  
31  
32  
33  
34  
35  
36  
37  
38  
39  
40  
41  
42  
43  
44  
45  
46  
47  
48  
49  
50  
51  
52  
53  
54  
55  
56  
57  
58  
59  
60  
61  
62  
63  
64  
65  
66  
67  
68  
69  
70  
71  
72  
73  
74  
75  
76  
module PlutoPlotly

using PlotlyBase

using HypertextLiteral
using AbstractPlutoDingetjes
using Dates
using Scratch
using TOML
using Colors
using ColorSchemes
using LaTeXStrings
using Markdown
using Downloads: download
using Artifacts
using ScopedValues
using PrecompileTools
# This is similar to `@reexport` but does not exports undefined names and can
# also avoid exporting the module name
function re_export(m::Module; skip_modname = false)
    mod_name = nameof(m)
    nms = names(m)
    exprts = filter(nms) do n
        isdefined(m, n) && (!skip_modname || n != mod_name)
    end
    eval(:(using .$mod_name))
    eval(:(export $(exprts...)))
end

re_export(PlotlyBase; skip_modname = false)
export PlutoPlot, get_plotly_version, change_plotly_version,
force_pluto_mathjax_local, htl_js, add_plotly_listener!,
add_class!, remove_class!, add_js_listener!, default_plotly_template,
get_image_options, change_image_options!, plutoplotly_paste_receiver
export plot, push_script!, prepend_cell_selector
export make_subplots
export enable_plutoplotly_offline
# From utilities.jl
export sample_colorscheme, discrete_colorscale


include("local_plotly_library.jl")

include("basics.jl")
include("script_contents/clipboard.jl")
include("script_contents/resizer.jl")
include("main_struct.jl")
include("paste_receiver.jl")
include("mathjax.jl")
include("preprocess.jl")
include("js_helpers.jl")
include("show.jl")
# Forward methods of PlotlyBase to support PlutoPlot objects
include("plotlybase_forward.jl")
include("utilities.jl")

# function __init__()
	# if !is_inside_pluto()
	# 	@warn "You loaded this package outside of Pluto, this is not the intended behavior and you should use either PlotlyBase or PlotlyJS directly.\nNOTE: If you receive this warning during pre-compilation or sysimage creation, you can ignore this warning."
	# end
# end

@compile_workload begin
    data = [
        scatter(;y = rand(10)),
        scattergeo(;lat = rand(10), lon = rand(10)),
        heatmap(;z = rand(10,10)),
        surface(;z = rand(10,10))
    ]
    layout = Layout(;title = "lol")
    p = plot(data, layout)
    _show(p)
    plot(rand(10,4)) |> _show
end

end

src\local_plotly_library.jl

1  
2  
3  
4  
5  
6  
7  
8  
9  
10  
11  
12  
13  
14  
15  
16  
17  
18  
19  
20  
21  
22  
23  
24  
25  
26  
27  
28  
29  
30  
31  
32  
33  
34  
35  
36  
37  
38  
39  
40  
41  
42  
43  
44  
45  
46  
47  
48  
49  
50  
51  
52  
53  
54  
55  
56  
57  
58  
59  
60  
61  
62  
63  
64  
65  
66  
67  
68  
69  
70  
71  
72  
73  
74  
75  
76  
77  
78  
79  
80  
81  
82  
83  
84  
85  
86  
87  
88  
89  
90  
91  
92  
93  
94  
95  
96  
97  
98  
99  
100  
101  
102  
103  
104  
105  
106  
107  
108  
109  
110  
111  
112  
113  
114  
115  
116  
117  
118  
119  
120  
121  
122  
123  
124  
125  
126  
127  
128  
129  
130  
131  
132  
133  
134  
135  
136  
137  
138  
139  
140  
141  
142  
143  
144  
145  
146  
147  
148  
149  
150  
151  
152  
153  
154  
155  
156  
157  
158  
159  
160  
161  
162  
163  
164  
165  
166  
167  
168  
169  
170  
171  
172  
173  
174  
175  
176  
177  
178  
179  
180  
181  
182  
183  
184  
185  
186  
187  
188  
189  
190  
191  
192  
193  
get_plotly_esm_url(v) = "https://esm.sh/plotly.js-dist-min@$(VersionNumber(v))"
get_plotly_cdn_url(v) = "https://cdn.plot.ly/plotly-$(VersionNumber(v)).min.js"

# We use our custom bundler to download an esm version of the plotly library
get_plotly_download_url(v) = "https://github.com/disberd/PlotlyArtifactsESM/releases/download/v$(VersionNumber(v))/plotly-esm-min.mjs"

"""
mapping a path like "/home/user/.julia/blabla/plotly-1.2.3.min.js" to its contents, read as String
"""
const PLOTLY_DEP_CONTENTS = Dict{String, String}()

function get_local_plotly_contents(v)
    maybe_add_plotly_local(v)
    path = get_local_path(v)
    get!(PLOTLY_DEP_CONTENTS, path) do
        read(path, String)
    end
end

get_local_path(v) = if VersionNumber(v) === ARTIFACT_VERSION
    joinpath(artifact"plotly-esm-min", "plotly-esm-min.mjs")
else
    # We use the UUID explicitly to make this work with PlutoDevMacros even without rootmodule
    scratchspace = get_scratch!(Base.UUID("8e989ff0-3d88-8e9f-f020-2b208a939ff0"), "plotly-library-esm")
    joinpath(scratchspace, "$(get_local_name(v)).mjs")
end
get_local_name(v) = "plotly-esm-min-$(VersionNumber(v))"

function maybe_add_plotly_local(v)
    ver = VersionNumber(v)
    # Check if the artifact already exists
    path = get_local_path(ver)
    if !isfile(path)
        # We download bundle and save locally
        @info "Downloading a local version of plotly@$v"
        bundle_url = get_plotly_download_url(ver)
        download(bundle_url, path)
    end
    nothing
end


function src_type(type)
    @assert type in ("hybrid", "esm", "local")
    type
end

function get_plotly_import(v, force = "hybrid")
    force = src_type(force)
    if force == "hybrid"
        _ImportedHybridJS(v)
    elseif force == "esm"
        _ImportedRemoteJS(get_plotly_esm_url(v))
    elseif force == "local"
        import_local_js(get_local_plotly_contents(v))
    end
end


# Identify a remote JS ESM module to be imported when shown in a script. The `extract` argument, if non-empty, will be the name of the property of the remote module to extract
struct _ImportedRemoteJS
    src::String
    extract::String
end
_ImportedRemoteJS(src) = _ImportedRemoteJS(src, "")

function Base.show(io, m::MIME"text/javascript", i::_ImportedRemoteJS)
    write(io, 
        "(await import($(repr(i.src))))"
    )
    if !isempty(i.extract)
        # Extract specific field from the module
        write(io, ".$(i.extract)")
    end
end


# Identify a local (on filesystem) JS ESM module to be imported when shown in a script. The `extract` argument, if non-empty, will be the name of the property of the local module to extract
struct _ImportedLocalJS
    published
    extract::String
    function _ImportedLocalJS(published, extract::AbstractString = "")
        @nospecialize
        new(published, extract)
    end
end


function Base.show(io, m::MIME"text/javascript", i::_ImportedLocalJS)
    write(io, 
        """
        (await (() => {
        window.created_imports = window.created_imports ?? new Map();
        let code = """
    )
    Base.show(io, m, i.published)

    write(io,
        """;
        if(created_imports.has(code)){
            return created_imports.get(code);
        } else {
            let blob_promise = new Promise((resolve, reject) => {
                const reader = new FileReader();
                reader.onload = async () => {
                    try {
                        resolve(await import(reader.result));
                    } catch(e) {
                        reject();
                    }
                }
                reader.onerror = () => reject();
                reader.onabort = () => reject();
                reader.readAsDataURL(
                    new Blob([code], {type : "text/javascript"}))
                });
                created_imports.set(code, blob_promise);
                return blob_promise;
            }
        })())
        """
    )
    if !isempty(i.extract)
        # Extract specific field from the module
        write(io, ".$(i.extract)")
    end
    return nothing
end

function import_local_js(code::AbstractString, extract::AbstractString = "")

    code_js = 
        try
        AbstractPlutoDingetjes.Display.published_to_js(code)
    catch e
        @warn "published_to_js did not work" exception=(e,catch_backtrace()) maxlog=1
        repr(code)
    end

    _ImportedLocalJS(code_js, extract)
end


"""
    enable_plutoplotly_offline(;version = get_plotly_version())
Creates a script that loads the plotly library on the current browser session so that it is available even when not connected to internet.

Put this in a separate cell so that the plotly JS library is stored in the browser and available for all plots.
"""
function enable_plutoplotly_offline(;version = get_plotly_version())
    _import = import_local_js(get_local_plotly_contents(version), "default")
    v_str = string(VersionNumber(version))
    @htl("""
        <script>
            const imports = {
                $(v_str): $(_import)
            }
            window.plutoplotly_imports = imports
        </script>
    """)
end

struct _ImportedHybridJS
    object::String
    key::String
    fallback::_ImportedRemoteJS
end
function _ImportedHybridJS(v)
    object = "plutoplotly_imports"
    key = string(VersionNumber(v))
    fallback = _ImportedRemoteJS(get_plotly_esm_url(v), "default")
    return _ImportedHybridJS(object, key, fallback)
end


function Base.show(io::IO, m::MIME"text/javascript", i::_ImportedHybridJS)
    write(io, "window.$(i.object)?.['$(i.key)'] ??")
    show(io, m, i.fallback)
end
# function Base.show(io::IO, m::MIME"text/javascript", i::_ImportedHybridJS)
#     write(io, """await (async function() {
#     let Plotly = window.$(i.object)?.['$(i.key)']
#     if (Plotly == undefined) {
#         console.log("Could not find loaded library among offline imports, trying to load from esm.sh")
#         Plotly = """)
#     show(io, m, i.fallback)
#     write(io, """
#     } else {
#         console.log("Loaded plotly from window.plutoplotly_imports")
#     }
#     return Plotly
# })()""")
# end

src\basics.jl

1  
2  
3  
4  
5  
6  
7  
8  
9  
10  
11  
12  
13  
14  
15  
16  
17  
18  
19  
20  
21  
22  
23  
24  
25  
26  
27  
28  
29  
30  
31  
32  
33  
34  
35  
36  
37  
38  
39  
40  
41  
42  
43  
44  
45  
46  
47  
48  
49  
50  
51  
52  
53  
54  
55  
56  
57  
58  
59  
60  
61  
62  
63  
64  
65  
66  
67  
68  
69  
70  
71  
72  
73  
74  
75  
76  
77  
78  
79  
80  
81  
82  
83  
84  
85  
86  
87  
88  
89  
90  
91  
92  
93  
94  
95  
96  
97  
98  
99  
100  
101  
102  
103  
104  
105  
106  
107  
108  
109  
110  
111  
112  
113  
114  
115  
116  
117  
118  
119  
120  
121  
122  
123  
124  
125  
126  
127  
128  
129  
130  
131  
132  
133  
134  
135  
136  
137  
138  
139  
140  
141  
142  
143  
144  
145  
146  
147  
148  
149  
150  
151  
152  
153  
154  
155  
156  
157  
158  
159  
160  
161  
162  
163  
164  
165  
166  
167  
168  
169  
170  
171  
172  
173  
174  
175  
176  
177  
178  
179  
180  
const ARTIFACT_VERSION = VersionNumber(read(joinpath(artifact"plotly-esm-min", "VERSION"), String))
const DEFAULT_PLOTLY_VERSION = Ref(ARTIFACT_VERSION)
const PLOTLY_VERSION = ScopedValue{Union{Nothing, String, VersionNumber}}(nothing)
const DEFAULT_TEMPLATE = Ref(PlotlyBase.templates[PlotlyBase.templates.default])
const JS = HypertextLiteral.JavaScript

"""
	ScriptContents
Wrapper around a vector of `HypertextLiteral.JavaScript` elements. It has a custom print implementation of `HypertextLiteral.print_script` in order to allow serialization of its various elements inside a script tag.

It is used inside the PlutoPlot to allow modularity and ease customization of the script contents that is used to generate the plotlyjs plot in Javascript.
"""
struct ScriptContents
	vec::Vector{JS}
end

function HypertextLiteral.print_script(io::IO, value::ScriptContents)
	for el ∈ value.vec
		print(io, el.content, '\n')
	end
end

"""
	htl_js(x)
Simple convenience constructor for `HypertextLiteral.JavaScript` objects, renamed and re-exported from HypertextLiteral for convenience in case HypertextLiteral is not explicitly loaded alongisde PlutoPlotly.

See also: [`add_plotly_listeners!`](@ref)
"""
htl_js(x) = HypertextLiteral.JavaScript(x)
htl_js(x::HypertextLiteral.JavaScript) = x

maybe_publish_to_js(x) = if is_inside_pluto()
	if isdefined(Main.PlutoRunner, :core_published_to_js)
		Main.PlutoRunner.PublishedToJavascript(x)
	else
		Main.PlutoRunner.publish_to_js(x)
	end
else
	x
end

current_cell_id()::Base.UUID = if is_inside_pluto()
	Main.PlutoRunner.currently_running_cell_id[]
else
	Base.UUID(zero(UInt128))
end

function Base.show(io::IO, mime::MIME"text/html", s::JS)
    if is_inside_pluto()
        show(io, mime, Markdown.MD(Markdown.Code("js",s.content)))
    else
        show(io, MIME"text/plain",s)
    end
end


## Plotly Version ##
function change_plotly_version(v)
	ver = VersionNumber(v)
	maybe_add_plotly_local(ver)
	DEFAULT_PLOTLY_VERSION[] = ver
end

function get_plotly_version() 
    v = @something PLOTLY_VERSION[] DEFAULT_PLOTLY_VERSION[]
    return VersionNumber(v)
end

## Prepend Cell Selector ##
"""
	prepend_cell_selector(selector="")
	prepend_cell_selector(selectors)

Prepends a CSS selector (represented by the argument `selector`) with a selector
of the current pluto-cell (of the form `pluto-cell[id='cell_id']`, where
`cell_id` is the currently running cell).

It can be used to ease creating style sheets (using `@htl` from
HypertextLiteral.jl) with selector that only apply to the cell where they are
executed.

When called with a vector of selectors as input, prepends each selector and
joins them together using `,` as separator.

`prepend_cell_selector("div") = pluto-cell[id='\$cell_id'] div`

`prepend_cell_selector(["div", "span"]) = pluto-cell[id='\$cell_id'] div, pluto-cell[id='\$cell_id'] span`

As example, one can create a plot and force its width to 400px in CSS by using the following snippet:
```julia
@htl \"\"\"
\$(plot(rand(10)))
<style>
	\$(prepend_cell_selector("div.js-plotly-plot")) {
		width: 400px !important;
	}
</style>
\"\"\"
```
"""
prepend_cell_selector(str::AbstractString="")::String = "pluto-cell[id='$(current_cell_id())'] $str" |> strip
prepend_cell_selector(selectors) = join(map(prepend_cell_selector, selectors), ",\n")

const IO_DICT = Ref{Tuple{<:IO, Dict{UInt, Int}}}((IOBuffer(), Dict{UInt, Int}()))
function get_IO_DICT(io::IO)
	old_io = first(IO_DICT[])
	dict = if old_io === io
		last(IO_DICT[])
	else
		d = Dict{UInt, Int}()
		IO_DICT[] = (io, d)
		d
	end
	return dict
end

## Unique Counter ##
function unique_io_counter(io::IO, identifier = "script_id")
	!get(io, :is_pluto, false) && return -1 # We simply return -1 if not inside pluto
	# We extract (or create if not existing) a dictionary that will keep track of instances of the same script name
	dict = get_IO_DICT(io)
	# We use the objectid as the key
	key = objectid(identifier)
	counter = get(dict, key, 0) + 1
	# Update the counter on the dict that is shared within this IOContext
	dict[key] = counter
end

# Using the unique_io_counter inside the show3 method allows to have unique counters for plots within a same cell.
# This does not ensure that the same plot object is always given the same unique script id if the plots are added to the cells with `if...end` blocks.
function plotly_script_id(io::IO)
	counter = unique_io_counter(io, "plotly-plot")
	return "plot_$counter"
end

function find_matching_template(t::Template)
	for name in templates.available
		t == templates[name] && return name
	end
	return missing
end

"""
	default_plotly_template(;find_matching = false)::Template
Returns the current default plotly template (following the synthax
to set Templates from PlotlyBase).

If `find_matching` is set to true, the function will also send a message (using
`@info`) to specify whether the default template is one of the templates
available by default in PlotlyBase (and which one it is) or not.
"""
function default_plotly_template(; find_matching = false)
	template = DEFAULT_TEMPLATE[] 
	if find_matching
		matching = find_matching_template(template)
		if matching isa Missing
			@info "The default template is not one of the predefined ones"
		else
			@info "The default plotly template is $matching"
		end
	end
	template
end

"""
	default_plotly_template(template::Template)::Template
	default_plotly_template(name::Union{Symbol, String})::Template
Set `template` as the current default plotly template (**globally**) to be used by all plots
from PlutoPlotly (unless specifically overridden with Layout).

If called with a `Symbol` or `String`, uses `name` to extract the corresponding
template the default ones available in PlotlyBase and sets it as default.
"""
default_plotly_template(t::Template) = DEFAULT_TEMPLATE[] = t
default_plotly_template(s::String) = default_plotly_template(Symbol(s))
function default_plotly_template(s::Symbol)
	s in templates.available || s === :none || error("The provided template $s is not available")
	template = s === :none ? Template() : templates[s]
	DEFAULT_TEMPLATE[] = template
end

src\main_struct.jl

1  
2  
3  
4  
5  
6  
7  
8  
9  
10  
11  
12  
13  
14  
15  
16  
17  
18  
19  
20  
21  
22  
23  
24  
25  
26  
27  
28  
29  
30  
31  
32  
33  
34  
35  
36  
37  
38  
39  
40  
41  
42  
43  
44  
45  
46  
47  
48  
49  
50  
51  
52  
53  
54  
55  
56  
57  
58  
59  
60  
61  
62  
63  
64  
65  
66  
67  
68  
69  
70  
71  
72  
73  
74  
75  
76  
77  
78  
79  
80  
81  
82  
83  
84  
85  
86  
87  
88  
89  
90  
91  
92  
93  
94  
95  
96  
97  
98  
99  
100  
101  
102  
103  
104  
105  
106  
107  
108  
109  
110  
111  
112  
113  
114  
115  
116  
117  
118  
119  
120  
121  
122  
123  
124  
125  
126  
127  
128  
129  
130  
131  
132  
133  
134  
135  
136  
137  
138  
139  
140  
141  
142  
143  
144  
145  
146  
147  
148  
149  
150  
151  
152  
153  
154  
155  
156  
157  
158  
159  
160  
161  
162  
163  
164  
165  
166  
167  
168  
169  
170  
171  
172  
173  
174  
175  
176  
177  
178  
179  
180  
181  
182  
183  
184  
185  
186  
187  
188  
189  
190  
191  
192  
193  
194  
195  
196  
197  
198  
199  
200  
201  
202  
203  
204  
205  
206  
207  
208  
209  
210  
211  
212  
213  
214  
215  
216  
217  
218  
219  
220  
221  
222  
223  
224  
225  
226  
227  
228  
229  
230  
231  
232  
233  
234  
235  
236  
237  
238  
239  
240  
241  
242  
243  
244  
245  
246  
247  
248  
249  
250  
251  
252  
253  
254  
255  
256  
257  
258  
259  
260  
261  
262  
263  
264  
265  
266  
267  
268  
269  
270  
271  
272  
273  
274  
275  
276  
277  
278  
279  
280  
281  
282  
283  
284  
285  
286  
287  
288  
289  
290  
291  
292  
293  
294  
295  
296  
297  
298  
299  
300  
301  
302  
303  
304  
305  
306  
307  
308  
309  
310  
311  
312  
313  
314  
315  
316  
317  
318  
319  
320  
321  
322  
323  
324  
325  
326  
327  
328  
329  
const _default_script_contents = htl_js.([
	"""
	// Flag to check if this cell was  manually ran or reactively ran
	const firstRun = this ? false : true
	const CONTAINER = this ?? html`<div class='plutoplotly-container'>`
	const PLOT = CONTAINER.querySelector('.js-plotly-plot') ?? CONTAINER.appendChild(html`<div>`)
	const parent = CONTAINER.parentElement
	// We use a controller to remove event listeners upon invalidation
	const controller = new AbortController()
	// We have to add this to keep supporting @bind with the old API using PLOT
	PLOT.addEventListener('input', (e) => {
		CONTAINER.value = PLOT.value
		if (e.bubbles) {
			return
		}
		CONTAINER.dispatchEvent(new CustomEvent('input'))
	}, { signal: controller.signal })
	""",
	"""
		// This create the style subdiv on first run
		firstRun && CONTAINER.appendChild(html`
		<style>
		.plutoplotly-container {
			width: 100%;
			height: 100%;
			min-height: 0;
			min-width: 0;
		}
		.plutoplotly-container .js-plotly-plot .plotly div {
			margin: 0 auto; // This centers the plot
		}
		.plutoplotly-container.popped-out {
			overflow: auto;
			z-index: 1000;
			position: fixed;
			resize: both;
			background: var(--main-bg-color);
			border: 3px solid var(--kbd-border-color);
			border-radius: 12px;
			border-top-left-radius: 0px;
			border-top-right-radius: 0px;
		}
		.plutoplotly-clipboard-header {
			display: flex;
			flex-flow: row wrap;
			background: var(--main-bg-color);
			border: 3px solid var(--kbd-border-color);
			border-top-left-radius: 12px;
			border-top-right-radius: 12px;
			position: fixed;
			z-index: 1001;
			cursor: move;
			transform: translate(0px, -100%);
			padding: 5px;
		}
		.plutoplotly-clipboard-header span {
			display: inline-block;
			flex: 1
		}
		.plutoplotly-clipboard-header.hidden {
			display: none;
		}
		.clipboard-span {
			position: relative;
		}
		.clipboard-value {
			padding-right: 5px;
			padding-left: 2px;
			cursor: text;
		}
		.clipboard-span.format {
			display: none;
		}
		.clipboard-span.filename {
			flex: 0 0 100%;
			text-align: center;
			border-top: 3px solid var(--kbd-border-color);
			margin-top: 5px;
			display: none;
		}
		.plutoplotly-container.filesave .clipboard-span.filename {
			display: inline-block;
		}
		.clipboard-value.filename {
			margin-left: 3px;
			text-align: left;
			min-width: min(60%, min-content);
		}
		.plutoplotly-container.filesave .clipboard-span.format {
			display: inline-flex;
		}
		.clipboard-span.format .label {
			flex: 0 0 0;
		}
		.clipboard-value.format {
			position: relative;
			flex: 1 0 auto;
			min-width: 30px;
			margin-right: 10px;
		}
		div.format-options {
			display: inline-flex;
			flex-flow: column;
			position: absolute;
			background: var(--main-bg-color);
			border-radius: 12px;
			padding-left: 3px;
			z-index: 2000;
		}
		div.format-options:hover {
			cursor: pointer;
			border: 3px solid var(--kbd-border-color);
			padding: 3px;
			transform: translate(-3px, -6px);
		}
		div.format-options .format-option {
			display: none;
		}
		div.format-options:hover .format-option {
			display: inline-block;
		}
		.format-option:not(.selected) {
			margin-top: 3px;
		}
		div.format-options .format-option.selected {
			order: -1;
			display: inline-block;
		}
		.format-option:hover {
			background-color: var(--kbd-border-color);
		}
		span.config-value {
			font-weight: normal;
			color: var(--pluto-output-color);
			display: none;
			position: absolute;
			background: var(--main-bg-color);
			border: 3px solid var(--kbd-border-color);
			border-radius: 12px;
			transform: translate(0px, calc(-100% - 10px));
			padding: 5px;
		}
		.label {
			user-select: none;
		}
		.label:hover span.config-value {
			display: inline-block;
			min-width: 150px;
		}
		.clipboard-span.matching-config .label {
			color: var(--cm-macro-color);
			font-weight: bold;
		}
		.clipboard-span.different-config .label {
			color: var(--cm-tag-color);
			font-weight: bold;
		}
	</style>
	`)
	""",
	"""
	let original_height = plot_obj.layout.height
	let original_width = plot_obj.layout.width
	// For the height we have to also put a fixed value in case the plot is put on a non-fixed-size container (like the default wrapper)
	// We define a variable to check whether we still have to remove the fixed height
	let remove_container_size = firstRun
	let container_height = original_height ?? PLOT.container_height ?? 400
	CONTAINER.style.height = container_height + 'px'
	""",
	clipboard_script,
	resizer_script,
	"""

	Plotly.react(PLOT, plot_obj).then(() => {
		// Assign the Plotly event listeners
		for (const [key, listener_vec] of Object.entries(plotly_listeners)) {
			for (const listener of listener_vec) {
				PLOT.on(key, listener)
			}
		}
		// Assign the JS event listeners
		for (const [key, listener_vec] of Object.entries(js_listeners)) {
			for (const listener of listener_vec) {
				PLOT.addEventListener(key, listener, {
					signal: controller.signal
				})
			}
		}
	}
	)
	""",
	"""

	invalidation.then(() => {
		// Remove all plotly listeners
		PLOT.removeAllListeners()
		// Remove all JS listeners
		controller.abort()
		// Remove the resizeObserver
		resizeObserver.disconnect()
	})
	""",
])

"""
	PlutoPlot(p::Plot; kwargs...)

A wrapper around `PlotlyBase.Plot` to provide optimized visualization within
Pluto notebooks exploiting `@htl` from HypertextLiteral.

# Fields
- `Plot::PlotlyBase.Plot`
- `plotly_listeners::Dict{String, Vector{HypertextLitera.JavaScript}}`
- `js_listeners::Dict{String, Vector{HypertextLitera.JavaScript}}`
- `classList::Vector{String}`
- `script_contents::ScriptContents`

Once the wrapper has been created, the underlying `Plot` object can be accessed
from the `Plot` field of the `PlutoPlot` object.

Custom listeners to [plotly
events](https://plotly.com/javascript/plotlyjs-events/) are saved inside the
`plotly_listeners` field and can be added to the `PlutoPlot` as *javascript*
functions using the [`add_plotly_listener!`](@ref) function.

Custom listeners to normal javascript events can instead be added to the
`PlutoPlot` as *javascript* functions using the [`add_js_listener!`](@ref)
function.

Multiple listeners can be associated to each event, and they are executed in the
order they are added.

A list of custom CSS classes can be added to the PlutoPlot by using the
[`add_class!`](@ref) and [`remove_class!`](@ref) functions.

Finally, the contents of the script tag generating the plot are stored in the
field `script_contents` which is of type [`ScriptContents`](@ref). The elements
of `script_contents` are written serially inside the javascript script tag. The
displayed plot can be customized by modifying the elements of this field.

# Examples
```julia
p = PlutoPlot(Plot(rand(10)))
add_plotly_listener!(p, "plotly_click", "e => console.log(e)")
add_class!(p, "custom_class")
```

See also: [`ScriptContents`](@ref), [`add_js_listener!`](@ref), [`add_plotly_listener!`](@ref)
"""
Base.@kwdef struct PlutoPlot
	Plot::PlotlyBase.Plot
	plotly_listeners::Dict{String, Vector{JS}} = Dict{String, Vector{JS}}()
	js_listeners::Dict{String, Vector{JS}} = Dict{String, Vector{JS}}()
	classList::Vector{String} = String[]
	script_contents::ScriptContents = ScriptContents(deepcopy(_default_script_contents))
end
PlutoPlot(p::PlotlyBase.Plot; kwargs...) = PlutoPlot(;kwargs..., Plot = p)

# Getter that extract the underlying Plot object data
function Base.getproperty(p::PlutoPlot, s::Symbol)
	if hasfield(Plot, s)
		getfield(getfield(p, :Plot), s)
	else
		getfield(p, s)
	end
end

function plot(args...;kwargs...) 
	@nospecialize
	PlutoPlot(Plot(args...;kwargs...))
end

# This function extracts the toImageButtonOptions as a Dict
"""
	get_image_options(p::Union{Plot, PlutoPlot})::Dict{Symbol, Any}
Extract the dictionary of image options that are stored in the
`toImageButtonOptions` of the `PlotConfig` object embedded in the `Plot`.

If not explicitly set, the image options are empty by default when creating a Plot object.

See also: [`change_image_options!`](@ref)
"""
function get_image_options(p::Union{Plot, PlutoPlot}) 
    dict = something(p.config.toImageButtonOptions, Dict())
    return Dict{Symbol, Any}((Symbol(k) => v) for (k, v) in dict)
end

"""
	change_image_options!(p::Union{Plot, PlutoPlot}; kwargs...)

Returns the input plot `p` after having modified the `toImageButtonOptions` of the
`PlotConfig` object embedded in the plot. These options are passed to
the plotly.js library and are used to provide defaults when downloading the plot
as an image (See the relevant
[docs](https://plotly.com/julia/configuration-options/#customizing-modebar-download-plot-button)
for more details.)

If explicitly set, each option will also be used as the default value when
popping out plot container for customizing size/scale/name before copying to
clipboard or downloading the image. See [PR
#22](https://github.com/JuliaPluto/PlutoPlotly.jl/pull/32) of PlutoPlotly for
more details.

## Keyword Arguments
- `format`: The format of the exported plot to download. Can be one of "png", "jpeg", "webp", "svg" or "full-json",
- `width`: An integer specifying the width (in pixels) of the exported plot.
- `height`: An integer specifying the height (in pixels) of the exported plot.
- `scale`: Set the scaling for the generated image, keeping the aspect ratio intact (increases the resolution).
- `filename`: Sets the name of the exported file, the extension will be added automatically based on the chosen `format`.
"""
function change_image_options!(p::Union{Plot, PlutoPlot}; kwargs...)
    # valid_args = (:format, :width, :height, :scale, :setBackground, :imageDataOnly, :filename)
	# At the moment setBackground and imageDataOnly are not supported.
    valid_args = (:format, :width, :height, :scale, :filename)
    invalid_kwargs = setdiff(keys(kwargs), valid_args) |> Tuple
    isempty(invalid_kwargs) || error("You provided the some invalid keyword arguments.
Invalid kwargs: $invalid_kwargs
Possible kwargs: $valid_args")
    existing_dict = get_image_options(p)
    new_dict = Dict{Symbol, Any}()
    for k in valid_args
        val = get(kwargs, k, get(existing_dict, k, missing))
        val isa Missing && continue
        new_dict[k] = val
    end
    isempty(new_dict) && return
    p.config.toImageButtonOptions = new_dict
    p
end

src\paste_receiver.jl

1  
2  
3  
4  
5  
6  
7  
8  
9  
10  
11  
12  
13  
14  
15  
16  
17  
18  
19  
20  
21  
22  
23  
24  
25  
26  
27  
28  
29  
30  
31  
32  
33  
34  
35  
36  
37  
38  
39  
40  
41  
42  
43  
44  
45  
46  
47  
48  
49  
50  
51  
52  
53  
54  
55  
56  
57  
58  
59  
60  
61  
62  
63  
64  
65  
66  
67  
68  
69  
70  
71  
72  
73  
74  
75  
76  
77  
78  
79  
80  
81  
82  
83  
84  
85  
86  
87  
88  
89  
90  
91  
92  
93  
94  
95  
96  
97  
98  
99  
100  
101  
102  
103  
104  
105  
106  
107  
108  
109  
110  
111  
112  
113  
114  
115  
116  
117  
118  
119  
120  
121  
122  
123  
124  
125  
126  
127  
128  
129  
130  
131  
132  
133  
134  
135  
136  
137  
138  
139  
140  
141  
142  
143  
144  
145  
146  
147  
148  
149  
150  
151  
152  
153  
154  
155  
156  
157  
158  
159  
160  
161  
162  
163  
164  
165  
166  
167  
168  
169  
170  
171  
172  
173  
174  
175  
176  
177  
178  
179  
180  
181  
182  
183  
184  
185  
186  
187  
188  
189  
190  
191  
192  
193  
194  
195  
196  
197  
198  
199  
200  
201  
202  
203  
204  
205  
206  
207  
208  
209  
210  
211  
212  
213  
214  
215  
216  
217  
218  
219  
220  
221  
222  
223  
224  
225  
226  
227  
228  
229  
230  
231  
232  
233  
234  
235  
236  
237  
238  
239  
240  
241  
242  
243  
244  
245  
246  
247  
248  
249  
250  
251  
252  
253  
254  
255  
256  
257  
258  
259  
260  
261  
262  
263  
264  
265  
266  
267  
268  
269  
270  
271  
272  
273  
274  
275  
276  
277  
278  
279  
280  
281  
282  
283  
284  
285  
286  
287  
288  
289  
290  
291  
292  
293  
294  
295  
296  
297  
"""
  plutoplotly_paste_receiver(;popped = true)
Create a widget that when shown inside a Pluto output generates a container
specifically made for extracting images of exported plots obtained with the
clipboard button on the plotly modebar.

With `popped` equals to true (default), the widget will be collapsed and
represented by a clipboard icon on the top-right of the screen. When clicked
upon, the container div is expanded and it will contain the last image that has
been sent to the clipboard from a PlutoPlotly plot.
"""
plutoplotly_paste_receiver(;popped = true) = @htl("""
<script src="https://kit.fontawesome.com/087fc9ff41.js" crossorigin="anonymous"></script>
<paste-receiver class="plutoplotly noimage minimized $(popped ? "popped" : "")">
  <div class="header">
    <i class="empty"></i>
    <i class="clipboard fa-regular fa-clipboard"></i>
    <i class="minimize fa-solid fa-down-left-and-up-right-to-center"></i>
    <i class="popout fa-solid fa-arrow-up-right-from-square"></i>
    <i class="close fa-solid fa-xmark"></i>
  </div>
  <img />
  <div class="message">
    The plot image will be pasted here as soon as the clipboard button is
    pressed
  </div>
</paste-receiver>
<script>
  const paste_receiver =
    currentScript.parentElement.querySelector("paste-receiver");
  const img = paste_receiver.querySelector("img");
  const clipboard_icon = paste_receiver.querySelector(".clipboard");
  paste_receiver.attachImage = function (data, caller) {
    img.src = data;
    paste_receiver.last_caller = caller;
    paste_receiver.classList.toggle("noimage", false);
    paste_receiver.classList.toggle("hasimage", true);
    // We make the clipboard wobble for half a second
    clipboard_icon.classList.toggle('animate', true)
    setTimeout(() => clipboard_icon.classList.toggle('animate', false), 1000)
  };

  const { default: interact } = await import(
    "https://esm.sh/interactjs@1.10.19"
  );
  function initialize_interact() {
    paste_receiver.offset = paste_receiver.offset ?? { x: 0, y: 0 };
    const startPosition = { x: 0, y: 0 };
    interact("paste-receiver.popped > .header")
      .draggable({
        listeners: {
          start(event) {
            paste_receiver.offset.y = startPosition.y =
              paste_receiver.offsetTop;
            paste_receiver.offset.x = startPosition.x =
              paste_receiver.offsetLeft;
          },
          move(event) {
            paste_receiver.offset.x += event.dx;
            paste_receiver.offset.y += event.dy;

            paste_receiver.style.top = `min(95vh, \${paste_receiver.offset.y}px`;
            paste_receiver.style.left = `min(95vw, \${paste_receiver.offset.x}px`;
          },
        },
      })
      .on("doubletap", (e) => {
        minimize(e, true)
      });

    function modify_size_position(remove) {
      const keys = ['top','left','width','height']
      const ps = paste_receiver.position_size ?? _.pick(paste_receiver.getBoundingClientRect(), keys)
      const cs = getComputedStyle(paste_receiver)
      for (const key of keys) {
        if (remove) {
          ps[key] = parseFloat(cs[key])
          paste_receiver.style[key] = ""
        } else {
          // We put the style from the saved one
          paste_receiver.style[key] = ps[key] + "px"
        }
      }
      paste_receiver.position_size = ps
    }
    function find_center(el) {
        r = el.getBoundingClientRect()
        return {
            top: r.top + r.height/2,
            left: r.left + r.width/2
        }
    }

    // This function minimizes or expands the plot and handles the offset
    function minimize(evt, force) {
        const current = paste_receiver.classList.contains('minimized')
        if (force == current) {
            // Noting happens, we just return
            return
        }
        const minimized_after = !current
        const r_before = paste_receiver.getBoundingClientRect()
        paste_receiver.classList.toggle('minimized',force)
        const r_after = paste_receiver.getBoundingClientRect()
        // Expanded (e) and Contracted (c) sizes
        const e = minimized_after ? r_before : r_after
        const c = minimized_after ? r_after : r_before
        // We compute the viewport size
        const vw = window.innerWidth
        const vh = window.innerHeight
        // This is the distance from the top of the top-left icon to the top-left of the container
        const dist = {
          top: 17.4,
          left: 15.9,
        }
        const to_right = e.left + e.width/2 > vw/2
        const minimize_icon = paste_receiver.querySelector('.minimize')
        minimize_icon.style.order = to_right ? 1 : -1
        let left
        let top
        if (minimized_after) {
            /*
            We are contracting
            */
           left = to_right ?
           // We have to contracts towards the right, putting the left at the right corner
           e.right - dist.left : 
           e.left + dist.left
           // Top for top we don't care about left and right targets, just top value
           top = e.top + e.height/2 > vh/2 ?
           e.bottom - dist.top : 
           e.top + dist.top
        } else {
            /*
            We are expanding
            */
           left = to_right ?
           // We have to contracts towards the right, putting the left at the right corner
           c.right + dist.left - e.width : 
           c.left - dist.left
           // Top for top we don't care about left and right targets, just top value
           top = e.top + e.height/2 > vh/2 ?
           c.bottom + dist.top - e.height :
           c.top - dist.top
        }
        paste_receiver.style.left = left + 'px'
        paste_receiver.style.top = top + 'px'
    }

    interact('i.minimize').on('tap', function(e) {
        // We skip on right click
        if (e.originalEvent.button == 2) {return}
        minimize(e, true)
    })
    interact('paste-receiver.minimized i.clipboard').on('tap', function(e) {
        // We skip on right click
        if (e.originalEvent.button == 2) {return}
        minimize(e, false)
    })

    interact('paste-receiver.popped:not(.minimized)')
      .resizable({
        edges: { top: true, left: false, bottom: true, right: true },
        listeners: {
          move: function (event) {
            console.log(event)
            Object.assign(paste_receiver.style, {
              width: `\${event.rect.width}px`,
              height: `\${event.rect.height}px`,
            });
          },
        },
      })
      
      interact('i.popout').on('tap', function(e) {
        // We skip on right click
        if (e.originalEvent.button == 2) {return}
        paste_receiver.classList.toggle('popped', true)
        modify_size_position(false)
      })
      interact('i.close').on('tap', function(e) {
        // We skip on right click
        if (e.originalEvent.button == 2) {return}
        modify_size_position(true)
        paste_receiver.classList.toggle('popped', false)
      })
  }
  initialize_interact()

  invalidation.then(() => {
    interact('paste-receiver').unset()
    interact('i.close').unset()
  })
</script>
<style>
  paste-receiver > .header {
    height: 30px;
    position: absolute;
    left: 0px;
    top: 0px;
    width: 100%;
    display: flex;
    justify-content: space-between;
    align-items: center;
  }
  paste-receiver.plutoplotly {
    display: flex;
    background: var(--main-bg-color);
    border: 3px solid var(--kbd-border-color);
    border-radius: 12px;
    min-height: 200px;
    max-height: 800px;
    align-items: center;
    justify-content: center;
    flex-flow: column;
    position: relative;
    overflow: auto;
  }
  paste-receiver.plutoplotly.popped {
    z-index: 1000;
    position: fixed;
    width: 600px;
    height: 400px;
    right: 165px;
    top: 62px;
  }
  paste-receiver.plutoplotly.popped.minimized {
    overflow: visible;
    min-height: 0px;
    height: 0px !important;
    width: 0px !important;
    border: none;
    background-color: transparent;
  }
  paste-receiver.plutoplotly.popped.minimized .header {
    height: 100%;
  }
  paste-receiver.plutoplotly.popped.minimized *:not(.header) {
    display: none;
  }
  paste-receiver.plutoplotly .header:not(:hover) i {
    visibility: hidden
  }
  paste-receiver.plutoplotly.popped.minimized i.clipboard {
    display: block;
    scale: 1.5;
    transform: translate(-50%, 0);
    visibility: visible !important;
  }
  i.clipboard.animate {
    animation: tilt-shaking 0.2s 0s 6;
  }
  @keyframes tilt-shaking {
    0% { transform: rotate(0deg) translate(-50%, 0); }
    25% { transform: rotate(5deg) translate(-50%, 0); }
    50% { transform: rotate(0eg) translate(-50%, 0); }
    75% { transform: rotate(-5deg) translate(-50%, 0); }
    100% { transform: rotate(0deg) translate(-50%, 0); }
  }
  paste-receiver.noimage > div.noimage,
  paste-receiver.noimage > img {
    margin: 0 auto;
  }
  paste-receiver.noimage > img {
    display: none;
  }
  paste-receiver.hasimage > .message {
    display: none;
  }
  paste-receiver.hasimage > img {
    display: block;
  }
  paste-receiver i {
    margin: 0 5px;
    cursor: pointer;
    color: var(--pluto-output-color);
  }
  paste-receiver.popped i.empty,
  paste-receiver.popped i.popout {
      display: none;
  }
  paste-receiver.popped:not(.minimized) i.clipboard {
      display: none;
  }
  paste-receiver.popped.minimized i.minimize {
      display: none;
  }
  paste-receiver:not(.popped) i.clipboard,
  paste-receiver:not(.popped) i.minimize,
  paste-receiver:not(.popped) i.close {
    display: none;
  }
  .header:hover > i.popout {
    visibility: visible;
  }
</style>
""")

src\mathjax.jl

1  
2  
3  
4  
5  
6  
7  
8  
9  
10  
11  
12  
13  
14  
15  
16  
17  
18  
19  
20  
# This hack is necessary to force loading mathjax

const FORCE_MATHJAX_LOCAL = Ref(false)

"""
	force_pluto_mathjax_local::Bool
	force_pluto_mathjax_local(flag::Bool)::Bool

Returns `true` if the `PlutoPlot` `show` method forces svgs produced by MathJax
to be locally cached and `false` otherwise.

The flag can be set at package level by providing the intended boolean value as
argument to the function

Local svg caching is used to make mathjax in recent plolty versions (>2.10) work
as expected. The default `global` caching in Pluto creates problems with the
math display.
"""
force_pluto_mathjax_local() = FORCE_MATHJAX_LOCAL[]
force_pluto_mathjax_local(flag::Bool) = FORCE_MATHJAX_LOCAL[] = flag

src\preprocess.jl

1  
2  
3  
4  
5  
6  
7  
8  
9  
10  
11  
12  
13  
14  
15  
16  
17  
18  
19  
20  
21  
22  
23  
24  
25  
26  
27  
28  
29  
30  
31  
32  
33  
34  
35  
36  
37  
38  
39  
40  
41  
42  
43  
44  
45  
46  
47  
48  
49  
50  
51  
52  
53  
54  
55  
56  
57  
58  
59  
60  
61  
62  
63  
64  
65  
66  
67  
68  
69  
70  
71  
72  
73  
74  
75  
76  
77  
78  
79  
80  
81  
82  
83  
84  
85  
86  
87  
88  
89  
90  
91  
92  
93  
94  
95  
96  
97  
98  
99  
100  
101  
102  
103  
104  
105  
106  
107  
108  
109  
110  
111  
112  
113  
114  
115  
116  
117  
118  
119  
120  
121  
122  
123  
124  
125  
126  
127  
128  
129  
130  
131  
132  
133  
134  
135  
136  
137  
138  
139  
140  
141  
142  
143  
144  
145  
146  
147  
148  
149  
150  
151  
152  
153  
154  
155  
156  
157  
158  
159  
160  
161  
162  
163  
164  
const FORCE_FLOAT32 = ScopedValue(true)

# Get a val to specify whether 
floatval() = Val{FORCE_FLOAT32[]}()

# Helper struct just to have the name as symbol parameter for dispatch
struct AttrName{S}
    name::Symbol
    AttrName(s::Symbol) = new{s}(s)
end
# This is used to handle args... which in case only one element is passed is not iterable
Base.iterate(n::AttrName, state=1) = state > 1 ? nothing : (n, state + 1)

#=
This function is basically `_json_lower` from PlotlyBase, but we do it directly
on the PlutoPlot to avoid the modifying the behavior of `_json_lower` for `Plot`
objects (which is required to modify how matrices are passed to `publish_to_js`).

We now have a complex dispatch to be able to do custom processing for specific
attributes.

The standard signature for a _process_with_names method is:
    _process_with_names(x, fl::Val, @nospecialize(args::Vararg{AttrName}))

where 
- the first argument should be the actual input to process and should be
typed accordingly for dispatch.
- The second argument is either `Val{true}` or `Val{false}` and represents the
flag to force number to be converted in Float32. # We added this to
significantly improve performance as the runtime check for converting or not was
creating type instability.
- All the remaining arguments are of type `AttrName` and represent the path of
attributes names leading to this specific input. For example, if we are
processing the input that is inside the xaxis_range in the layout, the function
call will have this form:
    _process_with_names(x, fl, AttrName(:xaxis), AttrName(:range), AttrName(:layout))

This again is to allow dispatch to work on the path so that one can customize behavior of _process_with_names with great control.
At the moment this is only used for modifying the behavior when `title` is
passed as a String, changing it to the more recent plotly syntax (see
https://github.com/JuliaPluto/PlutoPlotly.jl/issues/51)

The various `@nospecialize` below are to avoid exploding compilation given our exponential number of dispatch options, so we only specialize where we need.
=#

# Main _process_with_names for the PlutoPlot object
function _process_with_names(pp::PlutoPlot)
    p = pp.Plot
    fl = floatval()
    out = Dict(
        :data => _process_with_names(p.data, fl, AttrName(:data)),
        :layout => _process_with_names(p.layout, fl, AttrName(:layout)),
        :frames => _process_with_names(p.frames, fl, AttrName(:frames)),
        :config => _process_with_names(p.config, fl, AttrName(:config))
    )

    templates = PlotlyBase.templates
    layout_template = p.layout.template
    template = if layout_template isa String
        layout_template === "none" ? Template() : templates[layout_template]
    elseif layout_template === templates[templates.default]
        # If we enter here we did not specify any template in the layout, so se use our default
        DEFAULT_TEMPLATE[]
    else
        layout_template
    end
    out[:layout][:template] = _process_with_names(template, fl, AttrName(:template), AttrName(:layout))
    out
end

# Generic fallbacks
_process_with_names(x, ::Val, @nospecialize(args::Vararg{AttrName})) = _preprocess(x)
_process_with_names(x) = _process_with_names(x, floatval())

# Handle strings
_process_with_names(s::AbstractString, ::Val, @nospecialize(args::Vararg{AttrName})) =
    _preprocess(s)
_process_with_names(s::AbstractString, ::Val, ::AttrName{:title}, @nospecialize(args::Vararg{AttrName})) =
    Dict(:text => _preprocess(s))

# Handle Reals
_process_with_names(x::Real, ::Val{false}, @nospecialize(args::Vararg{AttrName})) = x
_process_with_names(x::Real, ::Val{true}, @nospecialize(args::Vararg{AttrName})) = x isa Bool ? x : Float32(x)
# Tuple, Arrays
_process_with_names(x::Union{Tuple,AbstractArray}, fl::Val, @nospecialize(args::Vararg{AttrName})) = [_process_with_names(el, fl, args...) for el in x]
# Multidimensional array of numbers must be nested 1D arrays
_process_with_names(A::AbstractArray{<:Union{Number,AbstractVector{<:Number}},N}, fl::Val, @nospecialize(args::Vararg{AttrName})) where {N} =
    if N == 1
        [_process_with_names(el, fl, args...) for el in A]
    else
        [_process_with_names(collect(s), fl, args...) for s ∈ eachslice(A; dims=ndims(A))]
    end

# Dict ans HasFields
function _process_with_names(d::Dict, fl::Val, @nospecialize(args::Vararg{AttrName}))
    Dict{Any,Any}(k => if k isa Symbol
        # We have this branch as we might have plotly properties here and we assume
        # they are if the dict key is a symbol.
        _process_with_names(v, fl, AttrName(k), args...)
    else
        _process_with_names(v, fl, args...)
    end for (k, v) in pairs(d))
end
function _process_with_names(d::Dict{Symbol}, fl::Val, @nospecialize(args::Vararg{AttrName}))
    Dict{Symbol,Any}(k => _process_with_names(v, fl, AttrName(k), args...) for (k, v) in pairs(d))
end
# We have a separate one because it seems to reduce allocations
_process_with_names(a::PlotlyBase.HasFields, fl::Val, @nospecialize(args::AttrName)) =
    _process_with_names(a.fields, fl, args...)

# Templates
_process_with_names(t::PlotlyBase.Template, fl::Val, @nospecialize(args::Vararg{AttrName})) = Dict(
    :data => _process_with_names(t.data, fl, AttrName(:data), args...),
    :layout => _process_with_names(t.layout, fl, AttrName(:layout), args...)
)

# Config
function _process_with_names(pc::PlotlyBase.PlotConfig, fl::Val, @nospecialize(args::Vararg{AttrName}))
    out = Dict{Symbol,Any}()
    for fn in fieldnames(PlotlyBase.PlotConfig)
        field = getfield(pc, fn)
        if !isnothing(field)
            out[fn] = _process_with_names(field, fl, AttrName(fn), args...)
        end
    end
    out
end

## The functions below are the internal processing only taking the value, so not depending on names path or float32 flag
# Defaults to JSON.lower for generic non-overloaded types
_preprocess(x) = PlotlyBase.JSON.lower(x) # Default
_preprocess(x::TimeType) = sprint(print, x) # For handling datetimes

_preprocess(s::Union{AbstractString,Symbol}) = String(s)

_preprocess(x::Union{Nothing,Missing}) = x
_preprocess(x::Symbol) = string(x)

_preprocess(c::PlotlyBase.Cycler) = c.vals

function _preprocess(c::PlotlyBase.ColorScheme)::Vector{Tuple{Float64,String}}
    N = length(c.colors)
    map(ic -> ((ic[1] - 1) / (N - 1), _preprocess(ic[2])), enumerate(c.colors))
end

# Files that will be later moved to an extension. At the moment it's pointless because PlotlyBase uses those internally anyway.
_preprocess(s::LaTeXString) = s.s

# Colors, they can be put inside an extension
_preprocess(c::Color) = @views begin
    s = hex(c, :rrggbb)
    r = parse(Int, s[1:2]; base=16)
    g = parse(Int, s[3:4]; base=16)
    b = parse(Int, s[5:6]; base=16)
    return "rgb($r,$g,$b)"
end
_preprocess(c::TransparentColor) = @views begin
    s = hex(c, :rrggbbaa)
    r = parse(Int, s[1:2]; base=16)
    g = parse(Int, s[3:4]; base=16)
    b = parse(Int, s[5:6]; base=16)
    a = parse(Int, s[7:8]; base=16)
    return "rgba($r,$g,$b,$(a/255))"
end

src\js_helpers.jl

1  
2  
3  
4  
5  
6  
7  
8  
9  
10  
11  
12  
13  
14  
15  
16  
17  
18  
19  
20  
21  
22  
23  
24  
25  
26  
27  
28  
29  
30  
31  
32  
33  
34  
35  
36  
37  
38  
39  
40  
41  
42  
43  
44  
45  
46  
47  
48  
49  
50  
51  
52  
53  
54  
55  
56  
57  
58  
59  
60  
61  
62  
63  
64  
65  
66  
67  
68  
69  
70  
71  
72  
73  
74  
75  
76  
77  
78  
79  
80  
81  
82  
83  
84  
85  
86  
87  
88  
89  
90  
91  
92  
93  
94  
95  
96  
97  
98  
99  
100  
101  
102  
103  
104  
105  
106  
107  
108  
109  
110  
111  
112  
113  
## add listeners ##
"""
	add_js_listener!(p::PlutoPlot, event_name::String, listener::HypertextLiteral.JavaScript)
	add_js_listener!(p::PlutoPlot, event_name::String, listener::String)

Add a custom *javascript* `listener` (to be provided as `String` or directly as `HypertextLiteral.JavaScript`) to the `PlutoPlot` object `p`, and associated to the javascript event specified by `event_name`.

The listeners are added to the HTML plot div after rendering. The div where the plot is inserted can be accessed using the variable named `PLOT` inside the listener code.

# Differences with `add_plotly_listener!`
This function adds standard javascript events via the `addEventListener` function. These events differ from the plotly specific events.

See also: [`add_plotly_listener!`](@ref), [`htl_js`](@ref)

# Examples:
```julia
p = PlutoPlot(Plot(rand(10), Layout(uirevision = 1)))
add_js_listener!(p, "mousedown", htl_js(\"\"\"
function(e) {

console.log(PLOT) // logs the plot div inside the developer console when pressing down the mouse

}
\"\"\"
```
"""
function add_js_listener!(p::PlutoPlot, event_name::String, listener::JS)
	ldict = p.js_listeners
	listeners_array = get!(ldict, event_name, JS[])
	push!(listeners_array, listener)
	return p
end
add_js_listener!(p::PlutoPlot, event_name, listener::String) = add_js_listener!(p, event_name, htl_js(listener))

## add class ##
"""
	add_class!(p::PlutoPlot, className::String)

Add a CSS class with name `className` to the list of custom classes that are added to the PLOT div when displayed inside Pluto. This can be used to give custom CSS styles to certain plots.

See also: [`remove_class!`](@ref)
"""
function add_class!(p::PlutoPlot, className::String)
	cl = p.classList
	if className ∉ cl
		push!(cl, className)
	end
	return p
end

## remove class ##

"""
	remove_class!(p::PlutoPlot, className::String)

Remove a CSS class with name `className` (if present) from the list of custom classes that are added to the PLOT div when displayed inside Pluto. This can be used to give custom CSS styles to certain plots.

See also: [`add_class!`](@ref)
"""
function remove_class!(p::PlutoPlot, className::String)
	cl = p.classList
	idx = findfirst(x -> x === className, cl)
	if idx !== nothing
		deleteat!(cl, idx)
	end
	return p
end

## Push Script ##
"""
	push_script!(p::PlutoPlot, items...)
Add script contents contained in collection `items` at the end of the plot show method script.
The `item` must either be a collection of `String` or `HypertextLiteral.JavaScript` elements
"""
function push_script!(p::PlutoPlot, items::Vararg{JS,N}) where N
	@nospecialize
	push!(p.script_contents.vec, items...)
	return p
end

## plotly listener ##
"""
	add_plotly_listener!(p::PlutoPlot, event_name::String, listener::HypertextLiteral.JavaScript)
	add_plotly_listener!(p::PlutoPlot, event_name::String, listener::String)

Add a custom *javascript* `listener` (to be provided as `String` or directly as `HypertextLiteral.JavaScript`) to the `PlutoPlot` object `p`, and associated to the [plotly event](https://plotly.com/javascript/plotlyjs-events/) specified by `event_name`.

The listeners are added to the HTML plot div after rendering. The div where the plot is inserted can be accessed using the variable named `PLOT` inside the listener code.

# Differences with `add_js_listener!`
This function adds a listener using the plotly internal events via the `on` function. These events differ from the standard javascript ones and provide data specific to the plot.

See also: [`add_js_listener!`](@ref), [`htl_js`](@ref)

# Examples:
```julia
p = PlutoPlot(Plot(rand(10), Layout(uirevision = 1)))
add_plotly_listener!(p, "plotly_relayout", htl_js(\"\"\"
function(e) {

console.log(PLOT) // logs the plot div inside the developer console

}
\"\"\"
```
"""
function add_plotly_listener!(p::PlutoPlot, event_name::String, listener::JS)
	ldict = p.plotly_listeners
	listeners_array = get!(ldict, event_name, JS[])
	push!(listeners_array, listener)
	return p
end
add_plotly_listener!(p::PlutoPlot, event_name, listener::String) = add_plotly_listener!(p, event_name, htl_js(listener))

src\show.jl

1  
2  
3  
4  
5  
6  
7  
8  
9  
10  
11  
12  
13  
14  
15  
16  
17  
18  
19  
20  
21  
22  
23  
24  
25  
26  
27  
28  
29  
30  
31  
32  
33  
34  
35  
36  
37  
38  
39  
40  
41  
42  
function _show(pp::PlutoPlot; script_id = "pluto-plotly-div", ver = get_plotly_version())
@htl """
	<script id=$(script_id)>
		// We start by putting all the variable interpolation here at the beginning
		// We have to convert all typedarrays in the layout to normal arrays. See Issue #25
		// We use lodash for this for compactness
		function removeTypedArray(o) {
			return _.isTypedArray(o) ? Array.from(o) :
			_.isPlainObject(o) ? _.mapValues(o, removeTypedArray) : 
			o
		}

		// Publish the plot object to JS
		let plot_obj = _.update($(maybe_publish_to_js(_process_with_names(pp))), "layout", removeTypedArray)
		// Get the plotly listeners
		const plotly_listeners = $(pp.plotly_listeners)
		// Get the JS listeners
		const js_listeners = $(pp.js_listeners)
		// Deal with eventual custom classes
		let custom_classlist = $(pp.classList)


		// Load the plotly library
		const Plotly = $(get_plotly_import(ver, "hybrid"))

		// Check if we have to force local mathjax font cache
		if ($(force_pluto_mathjax_local()) && window?.MathJax?.config?.svg?.fontCache === 'global') {
			window.MathJax.config.svg.fontCache = 'local'
		}

		$(pp.script_contents)

		return CONTAINER
	</script>
"""
end

# ╔═╡ d42d4694-e05d-4e0e-a198-79a3a5cb688a
function Base.show(io::IO, mime::MIME"text/html", plt::PlutoPlot)
	show(io, mime, _show(plt; script_id =  plotly_script_id(io)))
	# show(io, mime, _show(plt))
end

src\plotlybase_forward.jl

1  
2  
3  
4  
5  
6  
7  
8  
9  
10  
11  
12  
13  
14  
15  
16  
17  
18  
19  
20  
21  
22  
23  
24  
25  
26  
27  
28  
29  
30  
31  
32  
33  
34  
35  
36  
37  
38  
39  
40  
41  
42  
43  
44  
45  
46  
47  
48  
49  
50  
51  
52  
# Methods that do not return the plot object
for fname in (
    :relayout!,
    :restyle!,
    :addtraces!,
    :deletetraces!,
    :movetraces!,
    :purge!,
    :react!,
    :extendtraces!,
    :prependtraces!,
    # Layout shapes 
    :add_hrect!, :add_hline!, :add_vrect!, :add_vline!, :add_shape!,
    :add_layout_image!,
    # generic methods from API
    first.(PlotlyBase._layout_obj_updaters)...,
    first.(PlotlyBase._layout_vector_updaters)...,
)
    @eval PlotlyBase.$fname(p::PlutoPlot, args...; kwargs...) =
    PlotlyBase.$fname(p.Plot, args...; kwargs...) 
end

# Methods that do return the plot object, (we return the PlutoPlot object in this case)
for fname in (
    :update!,
    :add_trace!,
)
    @eval function PlotlyBase.$fname(p::PlutoPlot, args...; kwargs...) 
        PlotlyBase.$fname(p.Plot, args...; kwargs...) 
        p
    end
end

# Methods that return a copy of the plot
# Methods that do return the plot object, (we return the PlutoPlot object in this case)
for fname in (:fork, :restyle, :relayout, :update, :addtraces, :deletetraces,
:movetraces, :redraw, :extendtraces, :prependtraces, :purge, :react)
    @eval function PlotlyBase.$fname(p::PlutoPlot, args...; kwargs...) 
        p = PlotlyBase.$fname(p.Plot, args...; kwargs...) 
        PlutoPlot(p)
    end
end

# Here we put methods from PlotlyJS.jl
make_subplots(;kwargs...) = plot(Layout(Subplots(;kwargs...)))

@doc (@doc Subplots) make_subplots

# Overload of hcat,vcat,hvcat
Base.hcat(ps::PlutoPlot...) = PlutoPlot(hcat(map(x -> x.Plot, ps)...))
Base.vcat(ps::PlutoPlot...) = PlutoPlot(vcat(map(x -> x.Plot, ps)...))
Base.hvcat(rows::Tuple{Vararg{Int}}, ps::PlutoPlot...) = PlutoPlot(hvcat(rows, map(x -> x.Plot, ps)...))

src\utilities.jl

1  
2  
3  
4  
5  
6  
7  
8  
9  
10  
11  
12  
13  
14  
15  
16  
17  
18  
19  
20  
21  
22  
23  
24  
25  
26  
27  
28  
29  
30  
31  
32  
33  
34  
35  
36  
37  
38  
39  
40  
41  
42  
43  
44  
45  
46  
47  
48  
49  
50  
51  
52  
53  
54  
55  
56  
57  
58  
59  
60  
61  
62  
63  
64  
65  
66  
67  
68  
69  
70  
71  
72  
73  
74  
75  
76  
77  
78  
"""
    sample_colorscheme(cs::ColorScheme, sample_points::AbstractVector; alpha = nothing)
    sample_colorscheme(cs::ColorScheme, npoints::Int = length(cs); alpha = nothing)
    sample_colorscheme(cs_name::Symbol, args...; alpha = nothing)

Function to create a reduced sampled version of the provided colorscheme `cs`, optionally specifying the opacity of the colors using the `alpha` keyword argument.

The `sample_points` argument can be provided as a vector of points in the range [0, 1] to sample the colorscheme at specific points, or as an integer `npoints` to sample the colorscheme at `npoints` evenly spaced points in the range [0, 1].

Instead of being called with an explicit `ColorScheme` object, the function can also be called with a `Symbol` which is used to extract the corresponding `ColorScheme` object from the `colorschemes` Dict in the `ColorSchemes.jl` package.
"""
function sample_colorscheme(cs::ColorScheme, sample_points::AbstractVector; alpha = nothing)
    @assert minimum(sample_points) >= 0 && maximum(sample_points) <= 1 "sample_points must be in the range [0, 1]"
    colors = map(sample_points) do i
        c = get(cs, i)
        ca = convert(RGBA, c)
        if alpha === nothing
            ca
        else
            RGBA(ca.r, ca.g, ca.b, alpha)
        end
    end
    return ColorScheme(colors, "custom", "autogenerated")
end
sample_colorscheme(cs::ColorScheme, npoints::Int = length(cs); kwargs...) = sample_colorscheme(cs, range(0, 1, length = npoints); kwargs...)
function sample_colorscheme(cs_name::Symbol, args...; kwargs...) 
    cs = get(colorschemes, cs_name) do 
        error("colorscheme name must be a valid key for the `ColorSchemes.colorschemes` Dict.\n$(cs_name) not found within the valid keys")
    end 
    sample_colorscheme(cs, args...; kwargs...)
end

"""
    discrete_colorscale(cs::Union{ColorScheme, Symbol}, points; alpha = nothing)
    discrete_colorscale(colors::AbstractVector{<:Colorant})

Function to create a discrete colorscale from a colorscheme or a vector of colors, specifically intended for use as the `colorscale` attribute of a plotlyjs plot.

The function will assume that the resulting colorscale will have evenly spaced mapping between the colors in the vector and the corresponding plotly colorscale.
As example, when providing a vector of 3 colors as input, the resulting colorscale will assign each of the 3 colors to 33% of the values of the colorbar/coloraxis.

This is useful for creating heatmaps where values are mapped to a few number of colors rather than to a continuous gradient.

When called with a `ColorScheme` or `Symbol` as first argument, the colors are first sampled using the `sample_colorscheme` function, and then converted to a discrete colorscale.

```jldoctest
julia> using PlutoPlotly;

julia> colorscale = discrete_colorscale(:viridis, 5)
10-element Vector{Tuple{Float64, String}}:
 (0.0, "rgba(68,1,84,1.0)")
 (0.2, "rgba(68,1,84,1.0)")
 (0.2, "rgba(59,82,139,1.0)")
 (0.4, "rgba(59,82,139,1.0)")
 (0.4, "rgba(33,144,140,1.0)")
 (0.6, "rgba(33,144,140,1.0)")
 (0.6, "rgba(93,201,99,1.0)")
 (0.8, "rgba(93,201,99,1.0)")
 (0.8, "rgba(253,231,37,1.0)")
 (1.0, "rgba(253,231,37,1.0)")

julia> plot(heatmap(; z = rand(10,10), colorscale))
```
"""
function discrete_colorscale(cs::Union{ColorScheme, Symbol}, points; alpha = nothing)
    colors = sample_colorscheme(cs, points; alpha).colors
    return discrete_colorscale(colors)
end
function discrete_colorscale(colors::AbstractVector{<:Colorant})
    N = length(colors)
    cols = map(enumerate(colors)) do (i,c)
		[
			((i-1)/N, _preprocess(c))			
			((i)/N, _preprocess(c))
		]
	end
	vcat(cols...)
end

src\script_contents\clipboard.jl

1  
2  
3  
4  
5  
6  
7  
8  
9  
10  
11  
12  
13  
14  
15  
16  
17  
18  
19  
20  
21  
22  
23  
24  
25  
26  
27  
28  
29  
30  
31  
32  
33  
34  
35  
36  
37  
38  
39  
40  
41  
42  
43  
44  
45  
46  
47  
48  
49  
50  
51  
52  
53  
54  
55  
56  
57  
58  
59  
60  
61  
62  
63  
64  
65  
66  
67  
68  
69  
70  
71  
72  
73  
74  
75  
76  
77  
78  
79  
80  
81  
82  
83  
84  
85  
86  
87  
88  
89  
90  
91  
92  
93  
94  
95  
96  
97  
98  
99  
100  
101  
102  
103  
104  
105  
106  
107  
108  
109  
110  
111  
112  
113  
114  
115  
116  
117  
118  
119  
120  
121  
122  
123  
124  
125  
126  
127  
128  
129  
130  
131  
132  
133  
134  
135  
136  
137  
138  
139  
140  
141  
142  
143  
144  
145  
146  
147  
148  
149  
150  
151  
152  
153  
154  
155  
156  
157  
158  
159  
160  
161  
162  
163  
164  
165  
166  
167  
168  
169  
170  
171  
172  
173  
174  
175  
176  
177  
178  
179  
180  
181  
182  
183  
184  
185  
186  
187  
188  
189  
190  
191  
192  
193  
194  
195  
196  
197  
198  
199  
200  
201  
202  
203  
204  
205  
206  
207  
208  
209  
210  
211  
212  
213  
214  
215  
216  
217  
218  
219  
220  
221  
222  
223  
224  
225  
226  
227  
228  
229  
230  
231  
232  
233  
234  
235  
236  
237  
238  
239  
240  
241  
242  
243  
244  
245  
246  
247  
248  
249  
250  
251  
252  
253  
254  
255  
256  
257  
258  
259  
260  
261  
262  
263  
264  
265  
266  
267  
268  
269  
270  
271  
272  
273  
274  
275  
276  
277  
278  
279  
280  
281  
282  
283  
284  
285  
286  
287  
288  
289  
290  
291  
292  
293  
294  
295  
296  
297  
298  
299  
300  
301  
302  
303  
304  
305  
306  
307  
308  
309  
310  
311  
312  
313  
314  
315  
316  
317  
318  
319  
320  
321  
322  
323  
324  
325  
326  
327  
328  
329  
330  
331  
332  
333  
334  
335  
336  
337  
338  
339  
340  
341  
342  
343  
344  
345  
346  
347  
348  
349  
350  
351  
352  
353  
354  
355  
356  
357  
358  
359  
360  
361  
362  
363  
364  
365  
366  
367  
368  
369  
370  
371  
372  
373  
374  
375  
376  
377  
378  
379  
380  
381  
382  
383  
384  
385  
386  
387  
388  
389  
390  
391  
392  
393  
394  
395  
396  
397  
398  
399  
400  
401  
402  
403  
404  
405  
406  
407  
408  
409  
410  
411  
412  
413  
414  
415  
416  
417  
418  
419  
420  
421  
422  
423  
424  
425  
426  
427  
428  
429  
430  
431  
432  
433  
434  
435  
436  
437  
438  
439  
440  
441  
442  
443  
444  
445  
446  
447  
448  
449  
450  
451  
452  
453  
454  
455  
456  
457  
458  
459  
460  
461  
462  
463  
464  
465  
466  
467  
468  
469  
470  
471  
472  
473  
474  
475  
476  
477  
478  
479  
480  
481  
482  
483  
484  
485  
486  
487  
488  
489  
490  
491  
492  
493  
494  
495  
496  
const clipboard_script = htl_js("""
// We create a Promise version of setTimeout
function delay(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

// We import interact for dragging/resizing
const { default: interact } = await import('https://esm.sh/interactjs@1.10.19')


function getImageOptions() {
  const o = plot_obj.config.toImageButtonOptions ?? {};
  return {
    format: o.format ?? "png",
    width: o.width ?? original_width,
    height: o.height ?? original_height,
    scale: o.scale ?? 1,
    filename: o.filename ?? "newplot",
  };
}

const CLIPBOARD_HEADER =
  CONTAINER.querySelector(".plutoplotly-clipboard-header") ??
  CONTAINER.insertAdjacentElement(
    "afterbegin",
    html`<div class="plutoplotly-clipboard-header hidden">
      <span class="clipboard-span format"
        ><span class="label">Format:</span
        ><span class="clipboard-value format"></span
      ></span>
      <span class="clipboard-span width"
        ><span class="label">Width:</span
        ><span class="clipboard-value width"></span>px</span
      >
      <span class="clipboard-span height"
        ><span class="label">Height:</span
        ><span class="clipboard-value height"></span>px</span
      >
      <span class="clipboard-span scale"
        ><span class="label">Scale:</span
        ><span class="clipboard-value scale"></span
      ></span>
      <button class="clipboard-span set">Set</button>
      <button class="clipboard-span unset">Unset</button>
      <span class="clipboard-span filename"
        ><span class="label">Filename:</span
        ><span class="clipboard-value filename"></span
      ></span>
    </div>`
  );

function checkConfigSync(container) {
  const valid_classes = [
    "missing-config",
    "matching-config",
    "different-config",
  ];
  function setClass(cl) {
    for (const name of valid_classes) {
      container.classList.toggle(name, name == cl);
    }
  }
  // We use the custom getters we'll set up in the container
  const { ui_value, config_value, config_span, key } = container;
  if (config_value === undefined) {
    setClass("missing-config");
    config_span.innerHTML = `The key <b><em>\${key}</em></b> is not present in the config.`;
  } else if (ui_value == config_value) {
    setClass("matching-config");
    config_span.innerHTML = `The key <b><em>\${key}</em></b> has the same value in the config and in the header.`;
  } else {
    setClass("different-config");
    config_span.innerHTML = `The key <b><em>\${key}</em></b> has a different value (<em>\${config_value}</em>) in the config.`;
  }
  // Add info about setting and unsetting
  config_span.insertAdjacentHTML(
    "beforeend",
    `<br>Click on the label <em><b>once</b></em> to set the current UI value in the config.`
  );
  config_span.insertAdjacentHTML(
    "beforeend",
    `<br>Click <em><b>twice</b></em> to remove this key from the config.`
  );
}

const valid_formats = ["png", "svg", "webp", "jpeg", "full-json"];
function initializeUIValueSpan(span, key, value) {
  const container = span.closest(".clipboard-span");
  span.contentEditable = key === "format" ? "false" : "true";
  let parse = (x) => x;
  let update = (x) => (span.textContent = x);
  if (key === "width" || key === "height") {
    parse = (x) => Math.round(parseFloat(x));
  } else if (key === "scale") {
    parse = parseFloat;
  } else if (key === "format") {
    // We remove contentEditable
    span.contentEditable = "false";
    // Here we first add the subspans for each option
    const opts_div = span.appendChild(html`<div class="format-options"></div>`);
    for (const fmt of valid_formats) {
      const opt = opts_div.appendChild(
        html`<span class="format-option \${fmt}">\${fmt}</span>`
      );
      opt.onclick = (e) => {
        span.value = opt.textContent;
      };
    }
    parse = (x) => {
      return valid_formats.includes(x) ? x : localValue;
    };
    update = (x) => {
      for (const opt of opts_div.children) {
        opt.classList.toggle("selected", opt.textContent === x);
      }
    };
  } else {
    // We only have filename here
  }
  let localValue;
  Object.defineProperty(span, "value", {
    get: () => {
      return localValue;
    },
    set: (val) => {
      if (val !== "") {
        localValue = parse(val);
      }
      update(localValue);
      checkConfigSync(container);
    },
  });
  // We also assign a listener so that the editable is blurred when enter is pressed
  span.onkeydown = (e) => {
    if (e.keyCode === 13) {
      e.preventDefault();
      span.blur();
    }
  };
  span.value = value;
}

function initializeConfigValueSpan(span, key) {
  // Here we mostly want to define the setter and getter
  const container = span.closest(".clipboard-span");
  Object.defineProperty(span, "value", {
    get: () => {
      return plot_obj.config.toImageButtonOptions[key];
    },
    set: (val) => {
      // if undefined is passed, we remove the entry from the options
      if (val === undefined) {
        delete plot_obj.config.toImageButtonOptions[key];
      } else {
        plot_obj.config.toImageButtonOptions[key] = val;
      }
      checkConfigSync(container);
    },
  });
}

const config_spans = {};
for (const [key, value] of Object.entries(getImageOptions())) {
  const container = CLIPBOARD_HEADER.querySelector(`.clipboard-span.\${key}`);
  const label = container.querySelector(".label");
  // We give the label a function that on single click will set the current value and with double click will unset it
  label.onclick = DualClick(
    () => {
      container.config_value = container.ui_value;
    },
    (e) => {
      console.log("e", e);
      e.preventDefault();
      container.config_value = undefined;
    }
  );
  const ui_value_span = container.querySelector(".clipboard-value");
  const config_value_span =
    container.querySelector(".config-value") ??
    label.insertAdjacentElement(
      "afterbegin",
      html`<span class="config-value"></span>`
    );
  // Assing the two spans as properties of the containing span
  container.ui_span = ui_value_span;
  container.config_span = config_value_span;
  container.key = key;
  config_spans[key] = container;
  if (firstRun) {
    plot_obj.config.toImageButtonOptions =
      plot_obj.config.toImageButtonOptions ?? {};
    // We do the initialization of the value span
    initializeUIValueSpan(ui_value_span, key, value);
    // Then we initialize the config value
    initializeConfigValueSpan(config_value_span, key);
    // We put some convenience getters/setters
    // ui_value forward
    Object.defineProperty(container, "ui_value", {
      get: () => ui_value_span.value,
      set: (val) => {
        ui_value_span.value = val;
      },
    });
    // config_value forward
    Object.defineProperty(container, "config_value", {
      get: () => config_value_span.value,
      set: (val) => {
        config_value_span.value = val;
      },
    });
  }
}

// These objects will contain the default value

// This code updates the image options in the PLOT config with the provided ones
function setImageOptions(o) {
  for (const [key, container] of Object.entries(config_spans)) {
    container.config_value = o[key];
  }
}
function unsetImageOptions() {
  setImageOptions({});
}

const set_button = CLIPBOARD_HEADER.querySelector(".clipboard-span.set");
const unset_button = CLIPBOARD_HEADER.querySelector(".clipboard-span.unset");
if (firstRun) {
  set_button.onclick = (e) => {
    for (const container of Object.values(config_spans)) {
      container.config_value = container.ui_value;
    }
  };
  unset_button.onclick = unsetImageOptions;
}

// We add a function to check if the clipboard is popped out
CONTAINER.isPoppedOut = () => {
  return CONTAINER.classList.contains("popped-out");
};

CLIPBOARD_HEADER.onmousedown = function (event) {
  if (event.target.matches("span.clipboard-value")) {
    console.log("We don't move!");
    return;
  }
  const start = {
    left: parseFloat(CONTAINER.style.left),
    top: parseFloat(CONTAINER.style.top),
    X: event.pageX,
    Y: event.pageY,
  };
  function moveAt(event, start) {
    const top = event.pageY - start.Y + start.top + "px";
    const left = event.pageX - start.X + start.left + "px";
    CLIPBOARD_HEADER.style.left = left;
    CONTAINER.style.left = left;
    CONTAINER.style.top = top;
  }

  // move our absolutely positioned ball under the pointer
  moveAt(event, start);
  function onMouseMove(event) {
    moveAt(event, start);
  }

  // We use this to remove the mousemove when clicking outside of the container
  const controller = new AbortController();

  // move the container on mousemove
  document.addEventListener("mousemove", onMouseMove, {
    signal: controller.signal,
  });
  document.addEventListener(
    "mousedown",
    (e) => {
      if (e.target.closest(".plutoplotly-container") !== CONTAINER) {
        cleanUp();
        controller.abort();
        return;
      }
    },
    { signal: controller.signal }
  );

  function cleanUp() {
    console.log("cleaning up the plot move listener");
    controller.abort();
    CLIPBOARD_HEADER.onmouseup = null;
  }

  // (3) drop the ball, remove unneeded handlers
  CLIPBOARD_HEADER.onmouseup = cleanUp;
};

function sendToClipboard(blob) {
  if (!navigator.clipboard) {
    alert(
      "The Clipboard API does not seem to be available, make sure the Pluto notebook is being used from either localhost or an https source."
    );
  }
  navigator.clipboard
    .write([
      new ClipboardItem({
        // The key is determined dynamically based on the blob's type.
        [blob.type]: blob,
      }),
    ])
    .then(
      function () {
        console.log("Async: Copying to clipboard was successful!");
      },
      function (err) {
        console.error("Async: Could not copy text: ", err);
      }
    );
}

function copyImageToClipboard() {
  // We extract the image options from the provided parameters (if they exist)
  const config = {};
  for (const [key, container] of Object.entries(config_spans)) {
    let val =
      container.config_value ??
      (CONTAINER.isPoppedOut() ? container.ui_value : undefined);
    // If we have undefined we don't create the key. We also ignore format because the clipboard only supports png.
    if (val === undefined || key === "format") {
      continue;
    }
    config[key] = val;
  }
  Plotly.toImage(PLOT, config).then(function (dataUrl) {
    fetch(dataUrl)
      .then((res) => res.blob())
      .then((blob) => {
        const paste_receiver = document.querySelector('paste-receiver.plutoplotly')
        if (paste_receiver) {
          paste_receiver.attachImage(dataUrl, CONTAINER)
        }
        sendToClipboard(blob)
      });
  });
}

function saveImageToFile() {
  const config = {};
  for (const [key, container] of Object.entries(config_spans)) {
    let val =
      container.config_value ??
      (CONTAINER.isPoppedOut() ? container.ui_value : undefined);
    // If we have undefined we don't create the key.
    if (val === undefined) {
      continue;
    }
    config[key] = val;
  }
  Plotly.downloadImage(PLOT, config);
}

let container_rect = { width: 0, height: 0, top: 0, left: 0 };
function unpop_container(cl) {
  CONTAINER.classList.toggle("popped-out", false);
  CONTAINER.classList.toggle(cl, false);
  // We fix the height back to the value it had before popout, also setting the flag to signal that upon first resize we remove the fixed inline-style
  CONTAINER.style.height = container_rect.height + "px";
  remove_container_size = true;
  // We set the other fixed inline-styles to null
  CONTAINER.style.width = "";
  CONTAINER.style.top = "";
  CONTAINER.style.left = "";
  // We also remove the CLIPBOARD_HEADER
  CLIPBOARD_HEADER.style.width = "";
  CLIPBOARD_HEADER.style.left = "";
  // Finally we remove the hidden class to the header
  CLIPBOARD_HEADER.classList.toggle("hidden", true);
  return;
}
function popout_container(opts) {
  const cl = opts?.cl;
  const target_container_size = opts?.target_container_size ?? {};
  const target_plot_size = opts?.target_plot_size ?? {};
  if (CONTAINER.isPoppedOut()) {
    return unpop_container(cl);
  }
  CONTAINER.classList.toggle(cl, cl === undefined ? false : true);
  // We extract the current size of the container, save them and fix them
  const { width, height, top, left } = CONTAINER.getBoundingClientRect();
  container_rect = { width, height, top, left };
  // We save the current plot size before we pop as it will fill the screen
  const current_plot_size = {
    width: PLOT._fullLayout.width,
    height: PLOT._fullLayout.height,
  };
  // We have to save the pad data before popping so we can resize precisely
  const pad = {};
  pad.unpopped = getSizeData().container_pad;
  CONTAINER.classList.toggle("popped-out", true);
  pad.popped = getSizeData().container_pad;
  // We do top and left based on the current rect
  for (const key of ["top", "left"]) {
    const start_val = target_container_size[key] ?? container_rect[key];
    let offset = 0;
    for (const kind of ["padding", "border"]) {
      offset += pad.popped[kind][key] - pad.unpopped[kind][key];
    }
    CONTAINER.style[key] = start_val - offset + "px";
    if (key === "left") {
      CLIPBOARD_HEADER.style[key] = CONTAINER.style[key];
    }
  }
  // We compute the width and height depending on eventual config data
  const csz = computeContainerSize({
    width:
      target_plot_size.width ??
      config_spans.width.config_value ??
      current_plot_size.width,
    height:
      target_plot_size.height ??
      config_spans.height.config_value ??
      current_plot_size.height,
  });
  for (const key of ["width", "height"]) {
    const val = target_container_size[key] ?? csz[key];
    CONTAINER.style[key] = val + "px";
    if (key === "width") {
      CLIPBOARD_HEADER.style[key] = CONTAINER.style[key];
    }
  }
  CLIPBOARD_HEADER.classList.toggle("hidden", false);
  const controller = new AbortController();

  document.addEventListener(
    "mousedown",
    (e) => {
      if (e.target.closest(".plutoplotly-container") !== CONTAINER) {
        unpop_container();
        controller.abort();
        return;
      }
    },
    { signal: controller.signal }
  );
}

CONTAINER.popOut = popout_container;

function DualClick(single_func, dbl_func) {
  let nclicks = 0;
  return function (...args) {
    nclicks += 1;
    if (nclicks > 1) {
      dbl_func(...args);
      nclicks = 0;
    } else {
      delay(300).then(() => {
        if (nclicks == 1) {
          single_func(...args);
        }
        nclicks = 0;
      });
    }
  };
}

// We remove the default download image button
plot_obj.config.modeBarButtonsToRemove = _.union(
  plot_obj.config.modeBarButtonsToRemove,
  ["toImage"]
);
// We add the custom button to the modebar
plot_obj.config.modeBarButtonsToAdd = _.union(
  plot_obj.config.modeBarButtonsToAdd,
  [
    {
      name: "Copy PNG to Clipboard",
      icon: {
        height: 520,
        width: 520,
        path: "M280 64h40c35.3 0 64 28.7 64 64V448c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V128C0 92.7 28.7 64 64 64h40 9.6C121 27.5 153.3 0 192 0s71 27.5 78.4 64H280zM64 112c-8.8 0-16 7.2-16 16V448c0 8.8 7.2 16 16 16H320c8.8 0 16-7.2 16-16V128c0-8.8-7.2-16-16-16H304v24c0 13.3-10.7 24-24 24H192 104c-13.3 0-24-10.7-24-24V112H64zm128-8a24 24 0 1 0 0-48 24 24 0 1 0 0 48z",
      },
      direction: "up",
      click: DualClick(copyImageToClipboard, () => {
        popout_container();
      }),
    },
    {
      name: "Download Image",
      icon: Plotly.Icons.camera,
      direction: "up",
      click: DualClick(saveImageToFile, () => {
        popout_container({ cl: "filesave" });
      }),
    },
  ]
);
""")

src\script_contents\resizer.jl

1  
2  
3  
4  
5  
6  
7  
8  
9  
10  
11  
12  
13  
14  
15  
16  
17  
18  
19  
20  
21  
22  
23  
24  
25  
26  
27  
28  
29  
30  
31  
32  
33  
34  
35  
36  
37  
38  
39  
40  
41  
42  
43  
44  
45  
46  
47  
48  
49  
50  
51  
52  
53  
54  
55  
56  
57  
58  
59  
60  
61  
62  
63  
64  
65  
66  
67  
68  
69  
70  
71  
72  
73  
74  
75  
76  
77  
78  
79  
80  
81  
82  
83  
84  
85  
86  
87  
88  
89  
90  
91  
92  
93  
94  
95  
96  
97  
98  
99  
100  
101  
102  
103  
104  
105  
106  
107  
108  
109  
110  
111  
112  
113  
114  
115  
116  
117  
118  
119  
120  
121  
122  
123  
124  
125  
126  
127  
128  
129  
130  
131  
132  
133  
134  
135  
136  
137  
138  
139  
140  
141  
142  
143  
144  
145  
146  
147  
148  
149  
150  
const resizer_script = htl_js("""
function getOffsetData(el) {
  let cs = window.getComputedStyle(el, null);
  const odata = {
    padding: {
      left: parseFloat(cs.paddingLeft),
      right: parseFloat(cs.paddingRight),
      top: parseFloat(cs.paddingTop),
      bottom: parseFloat(cs.paddingBottom),
      width: parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight),
      height: parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom),
    },
    border: {
      left: parseFloat(cs.borderLeftWidth),
      right: parseFloat(cs.borderRightWidth),
      top: parseFloat(cs.borderTopWidth),
      bottom: parseFloat(cs.borderBottomWidth),
      width: parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth),
      height: parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth),
    }
  };
  if (el === PLOT) {
    // For the PLOT we also want to take into account the offset
    odata.offset = {
      top: PLOT.offsetParent == CONTAINER ? PLOT.offsetTop : 0,
      left: PLOT.offsetParent == CONTAINER ? PLOT.offsetLeft : 0,
    }
  }
  return odata;
}
function getSizeData() {
  const data = {
    plot_pad: getOffsetData(PLOT),
    plot_rect: PLOT.getBoundingClientRect(),
    container_pad: getOffsetData(CONTAINER),
    container_rect: CONTAINER.getBoundingClientRect(),
  };
  return data;
}
function computeContainerSize({ width, height }, sizeData = getSizeData()) {
  const computed_size = computePlotSize(sizeData);
  const offsets = computed_size.offsets;

  const plot_data = {
    width: width ?? computed_size.width,
    height: height ?? computed_size.height,
  };

  return {
    width: (width ?? computed_size.width) + offsets.width,
    height: (height ?? computed_size.height) + offsets.height,
    noChange: width == computed_size.width && height == computed_size.height,
  }
}

// This function will change the container size so that the resulting plot will be matching the provided specs
function changeContainerSize({ width, height }, sizeData = getSizeData()) {
  if (!CONTAINER.isPoppedOut()) {
    console.log("Tried to change container size when not popped, ignoring");
    return;
  }

  const csz = computeContainerSize({ width, height }, sizeData);

  if (csz.noChange) {
    console.log("Size is the same as current, ignoring");
    return
  }
  // We are now going to set he width and height of the container
  for (const key of ["width", "height"]) {
    CONTAINER.style[key] = csz[key] + "px";
  }
}
// We now create the function that will update the plot based on the values specified
function updateFromHeader() {
  const header_data = {
    height: config_spans.height.ui_value,
    width: config_spans.width.ui_value,
  };
  changeContainerSize(header_data);
}
// We assign this function to the onblur event of width and height
if (firstRun) {
  for (const container of Object.values(config_spans)) {
    container.ui_span.onblur = (e) => {
      container.ui_value = container.ui_span.textContent;
      updateFromHeader();
    };
  }
}
// This function computes the plot size to use for relayout as a function of the container size
function computePlotSize(data = getSizeData()) {
  // Remove Padding
  const { container_pad, plot_pad, container_rect } = data;
  const offsets = {
    width:
      plot_pad.padding.width +
      plot_pad.border.width +
      plot_pad.offset.left +
      container_pad.padding.width +
      container_pad.border.width,
    height:
      plot_pad.padding.height +
      plot_pad.border.height +
      plot_pad.offset.top +
      container_pad.padding.height +
      container_pad.border.height,
  };
  const sz = {
    width: Math.round(container_rect.width - offsets.width),
    height: Math.round(container_rect.height - offsets.height),
    offsets,
  };
  return sz;
}

// Create the resizeObserver to make the plot even more responsive! :magic:
const resizeObserver = new ResizeObserver((entries) => {
  const sizeData = getSizeData();
  const {container_rect, container_pad} = sizeData;
  let plot_size = computePlotSize(sizeData);
  // We save the height in the PLOT object
  PLOT.container_height = container_rect.height;
  // We deal with some stuff if the container is poppped
  CLIPBOARD_HEADER.style.width = container_rect.width + "px";
  CLIPBOARD_HEADER.style.left = container_rect.left + "px";
  config_spans.height.ui_value = plot_size.height;
  config_spans.width.ui_value = plot_size.width;
  /* 
		The addition of the invalid argument `plutoresize` seems to fix the problem with calling `relayout` simply with `{autosize: true}` as update breaking mouse relayout events tracking. 
		See https://github.com/plotly/plotly.js/issues/6156 for details
		*/
  let config = {
    // If this is popped out, we ignore the original width/height
    width: (CONTAINER.isPoppedOut() ? undefined : original_width) ?? plot_size.width,
    height: (CONTAINER.isPoppedOut() ? undefined : original_height) ?? plot_size.height,
    plutoresize: true,
  };
  Plotly.relayout(PLOT, config).then(() => {
    if (remove_container_size && !CONTAINER.isPoppedOut()) {
      // This is needed to avoid the first resize upon plot creation to already be without a fixed height
      CONTAINER.style.height = "";
      CONTAINER.style.width = "";
      remove_container_size = false;
    }
  });
});

resizeObserver.observe(CONTAINER);
""")

ext\PlotlyKaleidoExt.jl

1  
2  
3  
4  
5  
6  
7  
8  
9  
10  
11  
12  
13  
14  
15  
16  
17  
18  
19  
20  
21  
22  
23  
24  
25  
26  
27  
28  
29  
30  
31  
32  
33  
34  
35  
module PlotlyKaleidoExt

using PlutoPlotly: PlutoPlot, get_plotly_version
using PlotlyKaleido: savefig, PlotlyKaleido, restart, P, is_running

function get_version_in_kaleido()
    is_running() || return nothing
    exec = P.proc.cmd.exec
    flag_idx = findfirst(startswith("https://cdn.plot.ly"), exec)
    isnothing(flag_idx) && return nothing
    url = exec[flag_idx]
    m = match(r"https://cdn.plot.ly/plotly-(\d+\.\d+\.\d+).min.js", url)
    return first(m.captures) |> VersionNumber
end
function ensure_correct_version()
    pkgversion(PlotlyKaleido) >= v"2.2.1" || return # If we can't change version, we just assume it's correct
    current_version = get_plotly_version()
    # We find the flags in the cmd used to start kaleido, if we have a specified
    # version, that appears as a url of the corresponding version on the plotly
    # CDN, see https://github.com/JuliaPlots/PlotlyKaleido.jl/pull/9 for more
    # details
    kaleido_plotly_version = get_version_in_kaleido()
    if isnothing(kaleido_plotly_version) || kaleido_plotly_version != current_version
        @info "(Re)Starting the kaleido process with plotly version $current_version"
        restart(; plotly_version = current_version) # Process it not active, we simply start it with the correct version
        return
    end
end

function PlotlyKaleido.savefig(io::IO, p::PlutoPlot, args...; kwargs...) 
    ensure_correct_version()
    savefig(io, p.Plot, args...; kwargs...)
end

end

ext\UnitfulExt.jl

1  
2  
3  
4  
5  
6  
7  
8  
module UnitfulExt

using PlutoPlotly: _process_with_names, PlutoPlotly, AttrName
using Unitful: Quantity, ustrip

PlutoPlotly._process_with_names(q::Quantity, fl::Val, @nospecialize(args::Vararg{AttrName})) = _process_with_names(ustrip(q), fl, args...)

end