From ba8c660442ee13420042aaa0dc7ba8bfbeb2270b Mon Sep 17 00:00:00 2001 From: dragonwocky Date: Wed, 31 Jan 2024 23:02:04 +1100 Subject: [PATCH] feat: scroll to heading on click in outliner --- src/common/events.js | 21 ++++++---- src/core/client.mjs | 2 +- src/core/menu/islands/Checkbox.mjs | 2 +- src/extensions/outliner/client.css | 47 ---------------------- src/extensions/outliner/client.mjs | 50 +++++++++++++++-------- src/extensions/outliner/mod.json | 8 ++++ src/extensions/scroll-to-top/client.mjs | 4 +- src/extensions/scroll-to-top/mod.json | 2 +- src/extensions/topbar/client.mjs | 53 ++++++++++++++----------- 9 files changed, 89 insertions(+), 100 deletions(-) delete mode 100644 src/extensions/outliner/client.css diff --git a/src/common/events.js b/src/common/events.js index a87ef9e..81aa5a6 100644 --- a/src/common/events.js +++ b/src/common/events.js @@ -64,16 +64,20 @@ const _state = {}, let documentObserver, mutationListeners = []; const mutationQueue = [], - addMutationListener = (selector, callback, attributesOnly = false) => { - mutationListeners.push([selector, callback, attributesOnly]); + addMutationListener = (selector, callback, subtree = true) => { + mutationListeners.push([selector, callback, subtree]); }, removeMutationListener = (callback) => { mutationListeners = mutationListeners.filter(([, c]) => c !== callback); }, - selectorMutated = (mutation, selector, attributesOnly) => { - const matchesTarget = mutation.target?.matches(selector); - if (attributesOnly) return matchesTarget; - const descendsFromTarget = mutation.target?.matches(`${selector} *`), + selectorMutated = (mutation, selector, subtree) => { + const target = + mutation.type === "characterData" + ? mutation.target.parentElement + : mutation.target, + matchesTarget = target?.matches(selector); + if (!subtree) return matchesTarget; + const descendsFromTarget = target?.matches(`${selector} *`), addedToTarget = [...(mutation.addedNodes || [])].some( (node) => node instanceof HTMLElement && @@ -85,8 +89,8 @@ const mutationQueue = [], handleMutations = () => { while (mutationQueue.length) { const mutation = mutationQueue.shift(); - for (const [selector, callback, attributesOnly] of mutationListeners) { - const matches = selectorMutated(mutation, selector, attributesOnly); + for (const [selector, callback, subtree] of mutationListeners) { + const matches = selectorMutated(mutation, selector, subtree); if (matches) callback(mutation); } } @@ -99,6 +103,7 @@ const mutationQueue = [], }); documentObserver.observe(document.body, { attributes: true, + characterData: true, childList: true, subtree: true, }); diff --git a/src/core/client.mjs b/src/core/client.mjs index 8376611..75f3d37 100644 --- a/src/core/client.mjs +++ b/src/core/client.mjs @@ -99,7 +99,7 @@ const insertMenu = async (api, db) => { Configure the notion-enhancer and its mods `.attach($button, "right"); addMutationListener(notionSidebar, appendToDom); - addMutationListener(".notion-app-inner", updateMenuTheme, true); + addMutationListener(".notion-app-inner", updateMenuTheme, false); appendToDom(); addKeyListener(openMenuHotkey, (event) => { diff --git a/src/core/menu/islands/Checkbox.mjs b/src/core/menu/islands/Checkbox.mjs index 96c2889..0d7fc36 100644 --- a/src/core/menu/islands/Checkbox.mjs +++ b/src/core/menu/islands/Checkbox.mjs @@ -13,7 +13,7 @@ function Checkbox({ _get, _set, _requireReload = true, ...props }) { type="checkbox" class="hidden checked:&+div:(px-px bg-[color:var(--theme--accent-primary)]) - not-checked:&+div:(&>div:text-transparent + not-checked:&+div:(&>i:text-transparent border-(& [color:var(--theme--fg-primary)]) hover:bg-[color:var(--theme--bg-hover)])" ...${props} diff --git a/src/extensions/outliner/client.css b/src/extensions/outliner/client.css deleted file mode 100644 index a37d938..0000000 --- a/src/extensions/outliner/client.css +++ /dev/null @@ -1,47 +0,0 @@ -/** - * notion-enhancer: outliner - * (c) 2020 CloudHill (https://github.com/CloudHill) - * (c) 2021 dragonwocky (https://dragonwocky.me/) - * (https://notion-enhancer.github.io/) under the MIT license - */ - -#outliner--notice { - color: var(--theme--text_secondary); - font-size: 14px; - margin-top: 0; - margin-bottom: 1rem; -} - -.outliner--header { - position: relative; - margin: 0 -1rem; - padding: 0 1rem; - display: block; - font-size: 14px; - line-height: 2.2; - white-space: nowrap; - overflow: hidden; - user-select: none; - text-overflow: ellipsis; - text-decoration: none; - text-indent: var(--outliner--indent); - color: inherit; - cursor: pointer !important; - transition: background 20ms ease-in; -} -.outliner--header:hover { - background: var(--theme--ui_interactive-hover); -} - -.outliner--header:empty::after { - color: var(--theme--text_secondary); - content: attr(placeholder); -} - -/* indentation lines */ -.outliner--header:not([style='--outliner--indent:0px;'])::before { - content: ''; - height: 100%; - position: absolute; - left: calc((1rem + var(--outliner--indent)) - 11px); -} diff --git a/src/extensions/outliner/client.mjs b/src/extensions/outliner/client.mjs index 6a7d51a..bdbcf38 100644 --- a/src/extensions/outliner/client.mjs +++ b/src/extensions/outliner/client.mjs @@ -8,15 +8,16 @@ "use strict"; export default async (api, db) => { - const { html, addMutationListener, addPanelView } = api, - frame = ".notion-sidebar-container + div", + const { html, debounce, addMutationListener, addPanelView } = api, + behavior = (await db.get("smoothScrolling")) ? "smooth" : "auto", + scroller = ".notion-frame > .notion-scroller", page = ".notion-page-content", headings = [ ".notion-header-block", ".notion-sub_header-block", ".notion-sub_sub_header-block", ], - $view = html`
`; + $toc = html`
`; addPanelView({ title: "Outliner", // prettier-ignore @@ -28,7 +29,15 @@ export default async (api, db) => { `, - $view, + $view: html`
+

+ Click on a heading to jump to it. +

+ ${$toc} +
`, }); function Heading({ indent, ...props }, ...children) { @@ -36,9 +45,9 @@ export default async (api, db) => { role="button" class="notion-enhancer--outliner-heading block cursor-pointer select-none text-[14px] - m-px py-[6px] pr-[2px] pl-[${indent * 18}px] decoration-(2 [color:var(--theme--fg-border)]) hover:bg-[color:var(--theme--bg-hover)] + py-[6px] pr-[2px] pl-[${indent * 18}px] underline-(& offset-4)" ...${props} > @@ -46,29 +55,38 @@ export default async (api, db) => { `; } + let $page; + const updatePage = () => { + if (document.contains($page)) return; + $page = document.querySelector(page); + updateHeadings(); + }; + const getHeadings = () => { - return [...document.querySelectorAll(headings.join(", "))]; + return [...$page.querySelectorAll(headings.join(", "))]; }, getHeadingLevel = ($heading) => { for (let i = 0; i < headings.length; i++) if ($heading.matches(headings[i])) return i + 1; }, - updateHeadings = () => { - $view.innerHTML = ""; + updateHeadings = debounce(() => { + $toc.innerHTML = ""; + if (!$page) return; + const $frag = document.createDocumentFragment(); for (const $heading of getHeadings()) { const title = $heading.innerText, indent = getHeadingLevel($heading); if (!title) continue; const $h = html`<${Heading} indent=${indent} onclick=${() => { - // todo: scroll into view + const $scroller = document.querySelector(scroller); + $scroller.scrollTo({ top: $heading.offsetTop - 24, behavior }); }}>${title}

`; - $view.append($h); + $frag.append($h); } - }; + $toc.append($frag); + }); - let $page; - addMutationListener(page, () => { - if (!document.contains($page)) $page = document.querySelector(page); - if ($page) updateHeadings(); - }); + const semanticHeadings = '[class$="header-block"] :is(h2, h3, h4)'; + addMutationListener(`${page} ${semanticHeadings}`, updateHeadings); + addMutationListener(`${page}, ${scroller}`, updatePage, false); }; diff --git a/src/extensions/outliner/mod.json b/src/extensions/outliner/mod.json index 2b2de33..6ebc5e5 100644 --- a/src/extensions/outliner/mod.json +++ b/src/extensions/outliner/mod.json @@ -17,5 +17,13 @@ "avatar": "https://avatars.githubusercontent.com/u/54142180" } ], + "options": [ + { + "type": "toggle", + "key": "smoothScrolling", + "description": "Animates scrolling to a heading smoothly. Disable this to jump to a heading instantly when clicking it in the Outliner's table of contents.", + "value": true + } + ], "clientScripts": ["client.mjs"] } diff --git a/src/extensions/scroll-to-top/client.mjs b/src/extensions/scroll-to-top/client.mjs index 67d1f1c..674bd2a 100644 --- a/src/extensions/scroll-to-top/client.mjs +++ b/src/extensions/scroll-to-top/client.mjs @@ -19,7 +19,7 @@ export default async (api, db) => { let $scroller; const $btn = html`<${FloatingButton} - onclick=${() => $scroller?.scroll({ top: 0, left: 0, behavior })} + onclick=${() => $scroller?.scroll({ top: 0, behavior })} aria-label="Scroll to top" > `, @@ -38,6 +38,6 @@ export default async (api, db) => { $scroller?.addEventListener("scroll", onScroll); onScroll(); }; - addMutationListener(scroller, setup, true); + addMutationListener(scroller, setup, false); setup(); }; diff --git a/src/extensions/scroll-to-top/mod.json b/src/extensions/scroll-to-top/mod.json index f279483..f13b372 100644 --- a/src/extensions/scroll-to-top/mod.json +++ b/src/extensions/scroll-to-top/mod.json @@ -20,7 +20,7 @@ { "type": "toggle", "key": "smoothScrolling", - "description": "Animates the return to the top of the page smoothly. Disable this to jump instantly to the top of the page when clicking the scroll to top button.", + "description": "Animates the return to the top of the page smoothly. Disable this to jump to the top of the page instantly when clicking the scroll to top button.", "value": true }, { diff --git a/src/extensions/topbar/client.mjs b/src/extensions/topbar/client.mjs index 3095e23..bd62d39 100644 --- a/src/extensions/topbar/client.mjs +++ b/src/extensions/topbar/client.mjs @@ -26,77 +26,82 @@ export default async function (api, db) { $btn.style.padding = "0px 8px"; $btn.innerHTML = $btn.ariaLabel; }, - displayIcon = ($btn, icon) => { - if ($btn.innerHTML === icon) return; + displayIcon = ($btn, $icon) => { + if ($btn.contains($icon)) return; $btn.style.width = "33px"; $btn.style.padding = "0px"; $btn.style.justifyContent = "center"; - $btn.innerHTML = icon; + $btn.innerHTML = ""; + $btn.append($icon); }; // share button is text by default const shareSelector = ".notion-topbar-share-menu", shareButton = await db.get("shareButton"), - shareIcon = await db.get("shareIcon"); + shareIcon = await db.get("shareIcon"), + $shareIcon = shareIcon + ? html(shareIcon.content) + : html``; addMutationListener(shareSelector, () => { const $btn = document.querySelector(shareSelector); - let icon = shareIcon?.content; - icon ??= ``; if (!$btn) return; - if (shareButton === "Icon") displayIcon($btn, icon); + if (shareButton === "Icon") displayIcon($btn, $shareIcon); if (shareButton === "Disabled" && $btn.style.display !== "none") $btn.style.display = "none"; }); const commentsSelector = ".notion-topbar-comments-button", commentsButton = await db.get("commentsButton"), - commentsIcon = await db.get("commentsIcon"); + commentsIcon = await db.get("commentsIcon"), + $commentsIcon = commentsIcon ? html(commentsIcon.content) : undefined; addMutationListener(commentsSelector, () => { - const $btn = document.querySelector(commentsSelector), - icon = commentsIcon?.content; + const $btn = document.querySelector(commentsSelector); if (!$btn) return; if (commentsButton === "Text") displayLabel($btn); - if (commentsButton === "Icon" && icon) displayIcon($btn, icon); + if (commentsButton === "Icon" && commentsIcon) + displayIcon($btn, $commentsIcon); if (commentsButton === "Disabled" && $btn.style.display !== "none") $btn.style.display = "none"; }); const updatesSelector = ".notion-topbar-updates-button", updatesButton = await db.get("updatesButton"), - updatesIcon = await db.get("updatesIcon"); + updatesIcon = await db.get("updatesIcon"), + $updatesIcon = updatesIcon ? html(updatesIcon.content) : undefined; addMutationListener(updatesSelector, () => { - const $btn = document.querySelector(updatesSelector), - icon = updatesIcon?.content; + const $btn = document.querySelector(updatesSelector); if (!$btn) return; if (updatesButton === "Text") displayLabel($btn); - if (updatesButton === "Icon" && icon) displayIcon($btn, icon); + if (updatesButton === "Icon" && updatesIcon) + displayIcon($btn, $updatesIcon); if (updatesButton === "Disabled" && $btn.style.display !== "none") $btn.style.display = "none"; }); const favoriteSelector = ".notion-topbar-favorite-button", favoriteButton = await db.get("favoriteButton"), - favoriteIcon = await db.get("favoriteIcon"); + favoriteIcon = await db.get("favoriteIcon"), + $favoriteIcon = favoriteIcon ? html(favoriteIcon.content) : undefined; addMutationListener(favoriteSelector, () => { - const $btn = document.querySelector(favoriteSelector), - icon = favoriteIcon?.content; + const $btn = document.querySelector(favoriteSelector); if (!$btn) return; if (favoriteButton === "Text") displayLabel($btn); - if (favoriteButton === "Icon" && icon) displayIcon($btn, icon); + if (favoriteButton === "Icon" && favoriteIcon) + displayIcon($btn, $favoriteIcon); if (favoriteButton === "Disabled" && $btn.style.display !== "none") $btn.style.display = "none"; }); const moreSelector = ".notion-topbar-more-button", moreButton = await db.get("moreButton"), - moreIcon = await db.get("moreIcon"); + moreIcon = await db.get("moreIcon"), + $moreIcon = moreIcon ? html(moreIcon.content) : undefined; addMutationListener(moreSelector, () => { - const $btn = document.querySelector(moreSelector), - icon = moreIcon?.content; + const $btn = document.querySelector(moreSelector); if (!$btn) return; - $btn.ariaLabel = "More"; + if (!$btn.ariaLabel) $btn.ariaLabel = "More"; if (moreButton === "Text") displayLabel($btn); - if (moreButton === "Icon" && icon) displayIcon($btn, icon); + if (moreButton === "Icon" && moreIcon) displayIcon($btn, $moreIcon); if (moreButton === "Disabled" && $btn.style.display !== "none") $btn.style.display = "none"; });