feat: scroll to heading on click in outliner

This commit is contained in:
dragonwocky 2024-01-31 23:02:04 +11:00
parent 6661c5559b
commit ba8c660442
Signed by: dragonwocky
GPG Key ID: 7998D08F7D7BD7A8
9 changed files with 89 additions and 100 deletions

View File

@ -64,16 +64,20 @@ const _state = {},
let documentObserver, let documentObserver,
mutationListeners = []; mutationListeners = [];
const mutationQueue = [], const mutationQueue = [],
addMutationListener = (selector, callback, attributesOnly = false) => { addMutationListener = (selector, callback, subtree = true) => {
mutationListeners.push([selector, callback, attributesOnly]); mutationListeners.push([selector, callback, subtree]);
}, },
removeMutationListener = (callback) => { removeMutationListener = (callback) => {
mutationListeners = mutationListeners.filter(([, c]) => c !== callback); mutationListeners = mutationListeners.filter(([, c]) => c !== callback);
}, },
selectorMutated = (mutation, selector, attributesOnly) => { selectorMutated = (mutation, selector, subtree) => {
const matchesTarget = mutation.target?.matches(selector); const target =
if (attributesOnly) return matchesTarget; mutation.type === "characterData"
const descendsFromTarget = mutation.target?.matches(`${selector} *`), ? mutation.target.parentElement
: mutation.target,
matchesTarget = target?.matches(selector);
if (!subtree) return matchesTarget;
const descendsFromTarget = target?.matches(`${selector} *`),
addedToTarget = [...(mutation.addedNodes || [])].some( addedToTarget = [...(mutation.addedNodes || [])].some(
(node) => (node) =>
node instanceof HTMLElement && node instanceof HTMLElement &&
@ -85,8 +89,8 @@ const mutationQueue = [],
handleMutations = () => { handleMutations = () => {
while (mutationQueue.length) { while (mutationQueue.length) {
const mutation = mutationQueue.shift(); const mutation = mutationQueue.shift();
for (const [selector, callback, attributesOnly] of mutationListeners) { for (const [selector, callback, subtree] of mutationListeners) {
const matches = selectorMutated(mutation, selector, attributesOnly); const matches = selectorMutated(mutation, selector, subtree);
if (matches) callback(mutation); if (matches) callback(mutation);
} }
} }
@ -99,6 +103,7 @@ const mutationQueue = [],
}); });
documentObserver.observe(document.body, { documentObserver.observe(document.body, {
attributes: true, attributes: true,
characterData: true,
childList: true, childList: true,
subtree: true, subtree: true,
}); });

View File

@ -99,7 +99,7 @@ const insertMenu = async (api, db) => {
<b>Configure the notion-enhancer and its mods</b> <b>Configure the notion-enhancer and its mods</b>
<//>`.attach($button, "right"); <//>`.attach($button, "right");
addMutationListener(notionSidebar, appendToDom); addMutationListener(notionSidebar, appendToDom);
addMutationListener(".notion-app-inner", updateMenuTheme, true); addMutationListener(".notion-app-inner", updateMenuTheme, false);
appendToDom(); appendToDom();
addKeyListener(openMenuHotkey, (event) => { addKeyListener(openMenuHotkey, (event) => {

View File

@ -13,7 +13,7 @@ function Checkbox({ _get, _set, _requireReload = true, ...props }) {
type="checkbox" type="checkbox"
class="hidden checked:&+div:(px-px class="hidden checked:&+div:(px-px
bg-[color:var(--theme--accent-primary)]) bg-[color:var(--theme--accent-primary)])
not-checked:&+div:(&>div:text-transparent not-checked:&+div:(&>i:text-transparent
border-(& [color:var(--theme--fg-primary)]) border-(& [color:var(--theme--fg-primary)])
hover:bg-[color:var(--theme--bg-hover)])" hover:bg-[color:var(--theme--bg-hover)])"
...${props} ...${props}

View File

@ -1,47 +0,0 @@
/**
* notion-enhancer: outliner
* (c) 2020 CloudHill <rl.cloudhill@gmail.com> (https://github.com/CloudHill)
* (c) 2021 dragonwocky <thedragonring.bod@gmail.com> (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);
}

View File

@ -8,15 +8,16 @@
"use strict"; "use strict";
export default async (api, db) => { export default async (api, db) => {
const { html, addMutationListener, addPanelView } = api, const { html, debounce, addMutationListener, addPanelView } = api,
frame = ".notion-sidebar-container + div", behavior = (await db.get("smoothScrolling")) ? "smooth" : "auto",
scroller = ".notion-frame > .notion-scroller",
page = ".notion-page-content", page = ".notion-page-content",
headings = [ headings = [
".notion-header-block", ".notion-header-block",
".notion-sub_header-block", ".notion-sub_header-block",
".notion-sub_sub_header-block", ".notion-sub_sub_header-block",
], ],
$view = html`<div></div>`; $toc = html`<div></div>`;
addPanelView({ addPanelView({
title: "Outliner", title: "Outliner",
// prettier-ignore // prettier-ignore
@ -28,7 +29,15 @@ export default async (api, db) => {
<path d="M21,15.95H11c-0.55,0-1-0.45-1-1v0c0-0.55,0.45-1,1-1h10c0.55,0,1,0.45,1,1v0C22,15.5,21.55,15.95,21,15.95z" /> <path d="M21,15.95H11c-0.55,0-1-0.45-1-1v0c0-0.55,0.45-1,1-1h10c0.55,0,1,0.45,1,1v0C22,15.5,21.55,15.95,21,15.95z" />
<path d="M17,20.05h-6c-0.55,0-1-0.45-1-1v0c0-0.55,0.45-1,1-1h6c0.55,0,1,0.45,1,1v0C18,19.6,17.55,20.05,17,20.05z"/> <path d="M17,20.05h-6c-0.55,0-1-0.45-1-1v0c0-0.55,0.45-1,1-1h6c0.55,0,1,0.45,1,1v0C18,19.6,17.55,20.05,17,20.05z"/>
</svg>`, </svg>`,
$view, $view: html`<section>
<p
class="py-[12px] pl-[18px]
text-([color:var(--theme--fg-secondary)] [13px])"
>
Click on a heading to jump to it.
</p>
${$toc}
</section>`,
}); });
function Heading({ indent, ...props }, ...children) { function Heading({ indent, ...props }, ...children) {
@ -36,9 +45,9 @@ export default async (api, db) => {
role="button" role="button"
class="notion-enhancer--outliner-heading class="notion-enhancer--outliner-heading
block cursor-pointer select-none text-[14px] block cursor-pointer select-none text-[14px]
m-px py-[6px] pr-[2px] pl-[${indent * 18}px]
decoration-(2 [color:var(--theme--fg-border)]) decoration-(2 [color:var(--theme--fg-border)])
hover:bg-[color:var(--theme--bg-hover)] hover:bg-[color:var(--theme--bg-hover)]
py-[6px] pr-[2px] pl-[${indent * 18}px]
underline-(& offset-4)" underline-(& offset-4)"
...${props} ...${props}
> >
@ -46,29 +55,38 @@ export default async (api, db) => {
</div>`; </div>`;
} }
let $page;
const updatePage = () => {
if (document.contains($page)) return;
$page = document.querySelector(page);
updateHeadings();
};
const getHeadings = () => { const getHeadings = () => {
return [...document.querySelectorAll(headings.join(", "))]; return [...$page.querySelectorAll(headings.join(", "))];
}, },
getHeadingLevel = ($heading) => { getHeadingLevel = ($heading) => {
for (let i = 0; i < headings.length; i++) for (let i = 0; i < headings.length; i++)
if ($heading.matches(headings[i])) return i + 1; if ($heading.matches(headings[i])) return i + 1;
}, },
updateHeadings = () => { updateHeadings = debounce(() => {
$view.innerHTML = ""; $toc.innerHTML = "";
if (!$page) return;
const $frag = document.createDocumentFragment();
for (const $heading of getHeadings()) { for (const $heading of getHeadings()) {
const title = $heading.innerText, const title = $heading.innerText,
indent = getHeadingLevel($heading); indent = getHeadingLevel($heading);
if (!title) continue; if (!title) continue;
const $h = html`<${Heading} indent=${indent} onclick=${() => { const $h = html`<${Heading} indent=${indent} onclick=${() => {
// todo: scroll into view const $scroller = document.querySelector(scroller);
$scroller.scrollTo({ top: $heading.offsetTop - 24, behavior });
}}>${title}</p>`; }}>${title}</p>`;
$view.append($h); $frag.append($h);
} }
}; $toc.append($frag);
});
let $page; const semanticHeadings = '[class$="header-block"] :is(h2, h3, h4)';
addMutationListener(page, () => { addMutationListener(`${page} ${semanticHeadings}`, updateHeadings);
if (!document.contains($page)) $page = document.querySelector(page); addMutationListener(`${page}, ${scroller}`, updatePage, false);
if ($page) updateHeadings();
});
}; };

View File

@ -17,5 +17,13 @@
"avatar": "https://avatars.githubusercontent.com/u/54142180" "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"] "clientScripts": ["client.mjs"]
} }

View File

@ -19,7 +19,7 @@ export default async (api, db) => {
let $scroller; let $scroller;
const $btn = html`<${FloatingButton} const $btn = html`<${FloatingButton}
onclick=${() => $scroller?.scroll({ top: 0, left: 0, behavior })} onclick=${() => $scroller?.scroll({ top: 0, behavior })}
aria-label="Scroll to top" aria-label="Scroll to top"
><i class="i-chevrons-up" /> ><i class="i-chevrons-up" />
<//>`, <//>`,
@ -38,6 +38,6 @@ export default async (api, db) => {
$scroller?.addEventListener("scroll", onScroll); $scroller?.addEventListener("scroll", onScroll);
onScroll(); onScroll();
}; };
addMutationListener(scroller, setup, true); addMutationListener(scroller, setup, false);
setup(); setup();
}; };

View File

@ -20,7 +20,7 @@
{ {
"type": "toggle", "type": "toggle",
"key": "smoothScrolling", "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 "value": true
}, },
{ {

View File

@ -26,77 +26,82 @@ export default async function (api, db) {
$btn.style.padding = "0px 8px"; $btn.style.padding = "0px 8px";
$btn.innerHTML = $btn.ariaLabel; $btn.innerHTML = $btn.ariaLabel;
}, },
displayIcon = ($btn, icon) => { displayIcon = ($btn, $icon) => {
if ($btn.innerHTML === icon) return; if ($btn.contains($icon)) return;
$btn.style.width = "33px"; $btn.style.width = "33px";
$btn.style.padding = "0px"; $btn.style.padding = "0px";
$btn.style.justifyContent = "center"; $btn.style.justifyContent = "center";
$btn.innerHTML = icon; $btn.innerHTML = "";
$btn.append($icon);
}; };
// share button is text by default // share button is text by default
const shareSelector = ".notion-topbar-share-menu", const shareSelector = ".notion-topbar-share-menu",
shareButton = await db.get("shareButton"), shareButton = await db.get("shareButton"),
shareIcon = await db.get("shareIcon"); shareIcon = await db.get("shareIcon"),
$shareIcon = shareIcon
? html(shareIcon.content)
: html`<i class="i-share2 size-[20px]"></i>`;
addMutationListener(shareSelector, () => { addMutationListener(shareSelector, () => {
const $btn = document.querySelector(shareSelector); const $btn = document.querySelector(shareSelector);
let icon = shareIcon?.content;
icon ??= `<i class="i-share2 size-[20px]"></i>`;
if (!$btn) return; if (!$btn) return;
if (shareButton === "Icon") displayIcon($btn, icon); if (shareButton === "Icon") displayIcon($btn, $shareIcon);
if (shareButton === "Disabled" && $btn.style.display !== "none") if (shareButton === "Disabled" && $btn.style.display !== "none")
$btn.style.display = "none"; $btn.style.display = "none";
}); });
const commentsSelector = ".notion-topbar-comments-button", const commentsSelector = ".notion-topbar-comments-button",
commentsButton = await db.get("commentsButton"), commentsButton = await db.get("commentsButton"),
commentsIcon = await db.get("commentsIcon"); commentsIcon = await db.get("commentsIcon"),
$commentsIcon = commentsIcon ? html(commentsIcon.content) : undefined;
addMutationListener(commentsSelector, () => { addMutationListener(commentsSelector, () => {
const $btn = document.querySelector(commentsSelector), const $btn = document.querySelector(commentsSelector);
icon = commentsIcon?.content;
if (!$btn) return; if (!$btn) return;
if (commentsButton === "Text") displayLabel($btn); 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") if (commentsButton === "Disabled" && $btn.style.display !== "none")
$btn.style.display = "none"; $btn.style.display = "none";
}); });
const updatesSelector = ".notion-topbar-updates-button", const updatesSelector = ".notion-topbar-updates-button",
updatesButton = await db.get("updatesButton"), updatesButton = await db.get("updatesButton"),
updatesIcon = await db.get("updatesIcon"); updatesIcon = await db.get("updatesIcon"),
$updatesIcon = updatesIcon ? html(updatesIcon.content) : undefined;
addMutationListener(updatesSelector, () => { addMutationListener(updatesSelector, () => {
const $btn = document.querySelector(updatesSelector), const $btn = document.querySelector(updatesSelector);
icon = updatesIcon?.content;
if (!$btn) return; if (!$btn) return;
if (updatesButton === "Text") displayLabel($btn); 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") if (updatesButton === "Disabled" && $btn.style.display !== "none")
$btn.style.display = "none"; $btn.style.display = "none";
}); });
const favoriteSelector = ".notion-topbar-favorite-button", const favoriteSelector = ".notion-topbar-favorite-button",
favoriteButton = await db.get("favoriteButton"), favoriteButton = await db.get("favoriteButton"),
favoriteIcon = await db.get("favoriteIcon"); favoriteIcon = await db.get("favoriteIcon"),
$favoriteIcon = favoriteIcon ? html(favoriteIcon.content) : undefined;
addMutationListener(favoriteSelector, () => { addMutationListener(favoriteSelector, () => {
const $btn = document.querySelector(favoriteSelector), const $btn = document.querySelector(favoriteSelector);
icon = favoriteIcon?.content;
if (!$btn) return; if (!$btn) return;
if (favoriteButton === "Text") displayLabel($btn); 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") if (favoriteButton === "Disabled" && $btn.style.display !== "none")
$btn.style.display = "none"; $btn.style.display = "none";
}); });
const moreSelector = ".notion-topbar-more-button", const moreSelector = ".notion-topbar-more-button",
moreButton = await db.get("moreButton"), moreButton = await db.get("moreButton"),
moreIcon = await db.get("moreIcon"); moreIcon = await db.get("moreIcon"),
$moreIcon = moreIcon ? html(moreIcon.content) : undefined;
addMutationListener(moreSelector, () => { addMutationListener(moreSelector, () => {
const $btn = document.querySelector(moreSelector), const $btn = document.querySelector(moreSelector);
icon = moreIcon?.content;
if (!$btn) return; if (!$btn) return;
$btn.ariaLabel = "More"; if (!$btn.ariaLabel) $btn.ariaLabel = "More";
if (moreButton === "Text") displayLabel($btn); 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") if (moreButton === "Disabled" && $btn.style.display !== "none")
$btn.style.display = "none"; $btn.style.display = "none";
}); });