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,
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,
});

View File

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

View File

@ -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}

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";
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`<div></div>`;
$toc = html`<div></div>`;
addPanelView({
title: "Outliner",
// 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="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>`,
$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) {
@ -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) => {
</div>`;
}
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}</p>`;
$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);
};

View File

@ -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"]
}

View File

@ -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"
><i class="i-chevrons-up" />
<//>`,
@ -38,6 +38,6 @@ export default async (api, db) => {
$scroller?.addEventListener("scroll", onScroll);
onScroll();
};
addMutationListener(scroller, setup, true);
addMutationListener(scroller, setup, false);
setup();
};

View File

@ -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
},
{

View File

@ -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`<i class="i-share2 size-[20px]"></i>`;
addMutationListener(shareSelector, () => {
const $btn = document.querySelector(shareSelector);
let icon = shareIcon?.content;
icon ??= `<i class="i-share2 size-[20px]"></i>`;
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";
});