diff --git a/src/PlutoUI.jl b/src/PlutoUI.jl index 135086cb..17e6be80 100644 --- a/src/PlutoUI.jl +++ b/src/PlutoUI.jl @@ -19,6 +19,9 @@ end @reexport module TableOfContentsNotebook include("./TableOfContents.jl") end +@reexport module SidebarNotebook + include("./Sidebar.jl") +end @reexport module ClockNotebook include("./Clock.jl") end diff --git a/src/Sidebar.jl b/src/Sidebar.jl new file mode 100644 index 00000000..ebaf67f4 --- /dev/null +++ b/src/Sidebar.jl @@ -0,0 +1,284 @@ +### A Pluto.jl notebook ### +# v0.14.5 + +using Markdown +using InteractiveUtils + +# This Pluto notebook uses @bind for interactivity. When running this notebook outside of Pluto, the following 'mock version' of @bind gives bound variables a default value (instead of an error). +macro bind(def, element) + quote + local el = $(esc(element)) + global $(esc(def)) = Core.applicable(Base.get, el) ? Base.get(el) : missing + el + end +end + +# ╔═╡ 6bb52c7c-9031-426b-9061-b9bebc213089 +using Markdown: withtag + +# ╔═╡ 40118f5c-d059-4f9f-9ed2-04cd99b92675 +md""" +# Sidebar + +You can add anything to the sidebar: +""" + +# ╔═╡ 153b0efb-dc67-47bc-8f13-13e2ffe2dac5 + + +# ╔═╡ 583fcd1b-bd5d-45fa-b0b7-db234cfd281d +md""" +## Implementation +""" + +# ╔═╡ 1bdea858-cbe1-4a69-abf3-322ec8bdfb25 +begin + struct SidebarItem{T} + item::T + end + function Base.show(io::IO, m::MIME"text/html", si::SidebarItem) + withtag(io, :div, :class => "sidebar-item") do + show(io, m, si.item) + end + end +end + +# ╔═╡ 749bdfb0-fcc3-4cab-b17e-45f66835cd84 +const sidebar_js = sidebar -> """ +const getParentCell = el => el.closest("pluto-cell") + +// Get all items not yet in sidebar (new items) +const getItems = () => { + const selector = "pluto-notebook pluto-cell .sidebar-item:not(.sidebar-placed)" + return Array.from(document.querySelectorAll(selector)) +} + +const render = (el) => html`\${el.map(h => { + + // Mark item as placed, so we don't do it twice + h.classList.add("sidebar-placed") + + return html`` +})}` + +const initial_expanded = $(sidebar.initial_expanded) +const sidebarNode = html`` +sidebarNode.querySelector("button").addEventListener("click", function(e) { + let expanded = sidebarNode.getAttribute("aria-expanded") + sidebarNode.setAttribute("aria-expanded", expanded == "true" ? "false" : "true") +}) + +// Dictionary relating sidebar items (nodes) and cell ids +const sidebarItems = { + current: {}, +} + +const updateCallback = () => { + // Update our dictionary of items + getItems().forEach((h) => { + const parent_cell = getParentCell(h) + sidebarItems.current[parent_cell.id] = h + }) + console.log(sidebarItems) + + let items = render(Object.keys(sidebarItems.current).map((key) => { + return sidebarItems.current[key] + })) + + sidebarNode.querySelector("div").replaceWith( + html`
\${items}
` + ) +} +updateCallback() + +const notebook = document.querySelector("pluto-notebook") + +// We have a MutationObserver for each cell: +const observers = { + current: [], +} + +const createCellObservers = () => { + observers.current.forEach((o) => o.disconnect()) + observers.current = Array.from(notebook.querySelectorAll("pluto-cell")).map(el => { + const o = new MutationObserver(updateCallback) + o.observe(el, {childList: true, attributeFilter: ["class"]}) + return o + }) +} +createCellObservers() + +// An observer for the notebook's child list, which updates our cell observers: +const notebookObserver = new MutationObserver(() => { + updateCallback() + createCellObservers() +}) +notebookObserver.observe(notebook, {childList: true, attributeFilter: ["class"]}) + +// And finally, an observer for the document.body classList, to make sure that the +// sidebar also works when it is loaded during notebook initialization. +const bodyClassObserver = new MutationObserver(updateCallback) +bodyClassObserver.observe(document.body, {childList: true, attributeFilter: ["class"]}) + +invalidation.then(() => { + notebookObserver.disconnect() + bodyClassObserver.disconnect() + observers.current.forEach((o) => o.disconnect()) +}) + +return sidebarNode +""" + +# ╔═╡ 0f02965c-e014-4d18-aef5-e34d39b78682 +const sidebar_css = """ +.plutoui-sidebar button { + display: none; + + border: none; + border-radius: 50%; + background-color: white; + + /* Almost the same as */ + position: absolute; + top: 12px; + right: 12px; + width: 24px; + height: 24px; + background-size: 24px 24px; + cursor: pointer; + background-image: url(https://cdn.jsdelivr.net/gh/ionic-team/ionicons@5.0.0/src/svg/close-outline.svg); +} + +@media screen and (min-width: 1666px) { + .plutoui-sidebar button { + display: inline-block; + } + + .plutoui-sidebar[aria-expanded="false"] button { + position: fixed; + /* Sidebar top plus desired top position */ + top: calc(5rem + 12px); + /* Sidebar left plus desired left position */ + left: calc(1rem + 12px); + + padding: 12px; + border: 3px solid rgba(0, 0, 0, 0.15); + box-shadow: 0 0 11px 0px #00000010; + + background-image: url(https://cdn.jsdelivr.net/gh/ionic-team/ionicons@5.0.0/src/svg/chevron-forward-outline.svg); + } + + .plutoui-sidebar { + transition: linear 0.1s; + min-height: 56px; + + /* Almost the same as */ + position: fixed; + left: 1rem; + top: 5rem; + width: 25%; + padding: 10px; + border: 3px solid rgba(0, 0, 0, 0.15); + border-radius: 10px; + box-shadow: 0 0 11px 0px #00000010; + /* That is, viewport minus top minus Live Docs */ + max-height: calc(100vh - 5rem - 56px); + overflow: auto; + z-index: 50; + background: white; + } + + .plutoui-sidebar[aria-expanded="false"] { + width: 0; + margin-left: 0; + + padding: 0; + border: none; + } +} + +/* Almost the same as */ +.plutoui-sidebar div .sidebar-row { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-bottom: 2px; +} +.highlight-pluto-cell-shoulder { + background: rgba(0, 0, 0, 0.05); + background-clip: padding-box; +} +""" + +# ╔═╡ 8d79a95f-2290-4578-a58b-c92ab11e7ae3 +begin + """Generate a sidebar for declared content. + + # Examples: + + ```julia + Sidebar() + ``` + """ + Base.@kwdef struct Sidebar + initial_expanded::Bool=true + end + Base.push!(::Sidebar, x) = SidebarItem(x) # TODO: is this what we want? + function Base.show(io::IO, ::MIME"text/html", sidebar::Sidebar) + withtag(io, :script) do + print(io, sidebar_js(sidebar)) + end + withtag(io, :style) do + print(io, sidebar_css) + end + end +end + +# ╔═╡ c2a0ffb4-b015-11eb-0d96-638ad55db44e +export Sidebar + +# ╔═╡ 2eac0c69-bac7-4d0f-9b89-b2071fc34f6a +sidebar = Sidebar() + +# ╔═╡ 7f5f38c6-adf6-43c4-b23f-782455a8815a +push!( + sidebar, + md""" + # My awesome sidebar + + You can add anything to the sidebar. + """ +) + +# ╔═╡ d6031502-e581-45b0-8c8d-ed4937b62a7b +push!(sidebar, md"Type your name: $(@bind x html\"\")") + +# ╔═╡ 89bdecb6-035c-4974-9d32-b510b9e7a818 +x + +# ╔═╡ eb56e390-3b6f-462f-a01e-1e0ba0f64c75 +push!(sidebar, @bind checked html"") + +# ╔═╡ 37916f4c-bfdd-4a92-ab03-5c574ec8d41f +checked + +# ╔═╡ Cell order: +# ╠═c2a0ffb4-b015-11eb-0d96-638ad55db44e +# ╠═6bb52c7c-9031-426b-9061-b9bebc213089 +# ╠═2eac0c69-bac7-4d0f-9b89-b2071fc34f6a +# ╠═89bdecb6-035c-4974-9d32-b510b9e7a818 +# ╟─40118f5c-d059-4f9f-9ed2-04cd99b92675 +# ╠═7f5f38c6-adf6-43c4-b23f-782455a8815a +# ╠═d6031502-e581-45b0-8c8d-ed4937b62a7b +# ╠═eb56e390-3b6f-462f-a01e-1e0ba0f64c75 +# ╠═37916f4c-bfdd-4a92-ab03-5c574ec8d41f +# ╠═153b0efb-dc67-47bc-8f13-13e2ffe2dac5 +# ╟─583fcd1b-bd5d-45fa-b0b7-db234cfd281d +# ╠═8d79a95f-2290-4578-a58b-c92ab11e7ae3 +# ╠═1bdea858-cbe1-4a69-abf3-322ec8bdfb25 +# ╠═749bdfb0-fcc3-4cab-b17e-45f66835cd84 +# ╠═0f02965c-e014-4d18-aef5-e34d39b78682