/** * notion-enhancer * (c) 2023 dragonwocky (https://dragonwocky.me/) * (c) 2021 CloudHill (https://github.com/CloudHill) * (https://notion-enhancer.github.io/) under the MIT license */ "use strict"; import { Tooltip } from "./Tooltip.mjs"; import { TopbarButton } from "./TopbarButton.mjs"; import { Select } from "../menu/islands/Select.mjs"; // note: these islands are not reusable. // panel views can be added via addPanelView, // do not instantiate additional panels let panelViews = [], // "$icon" may either be an actual dom element, // or an icon name from the lucide icons set addPanelView = ({ title, $icon, $view }) => { panelViews.push([{ title, $icon }, $view]); panelViews.sort(([{ title: a }], [{ title: b }]) => a.localeCompare(b)); const { setState } = globalThis.__enhancerApi; setState?.({ panelViews }); }, removePanelView = ($view) => { panelViews = panelViews.filter(([, v]) => v !== $view); const { setState } = globalThis.__enhancerApi; setState?.({ panelViews }); }; function View({ _get }) { const { html, useState } = globalThis.__enhancerApi, $container = html`
`; useState(["rerender"], async () => { const openView = await _get?.(), $view = panelViews.find(([{ title }]) => { return title === openView; })?.[1] || panelViews[0]?.[1]; if (!$container.contains($view)) { $container.innerHTML = ""; $container.append($view); } }); return $container; } function Switcher({ _get, _set, minWidth, maxWidth }) { const { html, useState } = globalThis.__enhancerApi, $switcher = html`
`, setView = (view) => _set?.(view); useState(["panelViews"], ([panelViews = []]) => { const values = panelViews.map(([{ title, $icon }]) => { // panel switcher internally uses the select island, // which expects an option value rather than a title return { value: title, $icon }; }); $switcher.innerHTML = ""; $switcher.append(html`<${Select} popupMode="dropdown" class="w-full text-left" maxWidth=${maxWidth - 56} minWidth=${minWidth - 56} ...${{ _get, _set: setView, values }} />`); }); return $switcher; } function Panel({ hotkey, _getWidth, _setWidth, _getOpen, _setOpen, _getView, _setView, minWidth = 256, maxWidth = 640, transitionDuration = 300, }) { const { modDatabase, isEnabled } = globalThis.__enhancerApi, { html, useState, addKeyListener } = globalThis.__enhancerApi, { addMutationListener, removeMutationListener } = globalThis.__enhancerApi, $panelToggle = html``, $panel = html`
`; const topbarId = "e0700ce3-a9ae-45f5-92e5-610ded0e348d", topbarFavorite = ".notion-topbar-favorite-button", $topbarToggle = html`<${TopbarButton} aria-label="Toggle side panel" icon="panel-right" />`, addToTopbar = () => { if (document.contains($topbarToggle)) removeMutationListener(addToTopbar); document.querySelector(topbarFavorite)?.after($topbarToggle); }; $panelToggle.onclick = $topbarToggle.onclick = () => $panel.toggle(); addMutationListener(topbarFavorite, addToTopbar); addToTopbar(topbarFavorite); isEnabled(topbarId).then(async (topbarEnabled) => { if (!topbarEnabled) return; const topbarDatabase = await modDatabase(topbarId), panelButton = await topbarDatabase.get("panelButton"), panelIcon = await topbarDatabase.get("panelIcon"); if (panelButton === "Text") { $topbarToggle.innerHTML = `${$topbarToggle.ariaLabel}`; } else if (panelIcon?.content) $topbarToggle.innerHTML = panelIcon.content; }); let preDragWidth, dragStartX, _animatedAt; const getWidth = async (width) => { if (width && !isNaN(width)) { width = Math.max(width, minWidth); width = Math.min(width, maxWidth); } else width = await _getWidth?.(); if (isNaN(width)) width = minWidth; return width; }, setInteractive = (interactive) => { $panel .querySelectorAll("[tabindex]") .forEach(($el) => ($el.tabIndex = interactive ? 1 : -1)); }, isAnimated = () => { if (!_animatedAt) return false; return Date.now() - _animatedAt <= transitionDuration; }, isDragging = () => !isNaN(preDragWidth) && !isNaN(dragStartX), isPinned = () => $panel.hasAttribute("data-pinned"), isPeeked = () => $panel.hasAttribute("data-peeked"), isClosed = () => !isPinned() && !isPeeked(); const closedWidth = { width: "0px" }, openWidth = { width: "var(--panel--width, 0px)" }, peekAnimation = { height: "calc(100vh - 120px)", bottom: "60px", borderTopWidth: "1px", borderBottomWidth: "1px", borderTopLeftRadius: "8px", borderBottomLeftRadius: "8px", boxShadow: document.body.classList.contains("dark") ? "rgba(15, 15, 15, 0.1) 0px 0px 0px 1px, rgba(15, 15, 15, 0.2) 0px 3px 6px, rgba(15, 15, 15, 0.4) 0px 9px 24px" : "rgba(15, 15, 15, 0.05) 0px 0px 0px 1px, rgba(15, 15, 15, 0.1) 0px 3px 6px, rgba(15, 15, 15, 0.2) 0px 9px 24px", }, pinAnimation = { height: "calc(100vh - 45px)", bottom: "0px", borderTopWidth: "0px", borderBottomWidth: "0px", borderTopLeftRadius: "0px", borderBottomLeftRadius: "0px", boxShadow: "none", }; const animationState = { ...closedWidth }, easing = "cubic-bezier(0.4, 0, 0.2, 1)", animate = ($target, keyframes) => { const opts = { fill: "forwards", duration: transitionDuration, easing }; $target.animate(keyframes, opts); }, animatePanel = (to) => { _animatedAt = Date.now(); animate($panel.lastElementChild, [animationState, to]); Object.assign(animationState, to); }; // dragging the resize handle horizontally will // adjust the width of the panel correspondingly const $resizeHandle = html`
`, startDrag = async (event) => { dragStartX = event.clientX; preDragWidth = await getWidth(); document.addEventListener("mousemove", onDrag); document.addEventListener("mouseup", endDrag); }, onDrag = (event) => { event.preventDefault(); if (!isDragging()) return; $panel.resize(preDragWidth + (dragStartX - event.clientX)); }, endDrag = (event) => { document.removeEventListener("mousemove", onDrag); document.removeEventListener("mouseup", endDrag); if (!isDragging()) return; $panel.resize(preDragWidth + (dragStartX - event.clientX)); // toggle panel if not resized if (dragStartX - event.clientX === 0) $panel.toggle(); preDragWidth = dragStartX = undefined; }; $resizeHandle.addEventListener("mousedown", startDrag); $panel.lastElementChild.prepend($resizeHandle); // add tooltips to panel pin/unpin toggles const $resizeTooltipClick = html``, $resizeTooltip = html`<${Tooltip} onbeforeshow=${() => { $resizeTooltipClick.innerText = isPinned() ? "close" : "lock open"; }} >Drag to resize
Click to ${$resizeTooltipClick} `, $toggleTooltipClick = html``, $toggleTooltip = html`<${Tooltip} onbeforeshow=${() => { $toggleTooltipClick.innerText = isPinned() ? "Close sidebar" : "Lock sidebar open"; }} >${$toggleTooltipClick}
${hotkey} `; $resizeTooltip.attach($resizeHandle, "left"); $toggleTooltip.attach($topbarToggle, "bottom"); $toggleTooltip.attach($panelToggle, "bottom"); // hovering over the peek trigger will temporarily // pop out an interactive preview of the panel let _peekDebounce; const $peekTrigger = html`
`; $panel.prepend($peekTrigger); $panel.addEventListener("mouseout", () => { if (isDragging() || isAnimated() || isPinned()) return; if (!$panel.matches(":hover")) $panel.close(); }); $panel.addEventListener("mouseover", () => { _peekDebounce ??= setTimeout(() => { if (isClosed() && $panel.matches(":hover")) $panel.peek(); _peekDebounce = undefined; }, 100); }); // moves help button out of the way of open panel. // normally would place outside of an island, but in // this case is necessary for syncing up animations const floatingButtons = ".notion-enhancer--floating-buttons, .notion-help-button", repositionFloatingButtons = async (width) => { const $floatingButtons = document.querySelector(floatingButtons); if (!$floatingButtons) return; width ??= await getWidth(); if (isNaN(width)) width = minWidth; if (!isPinned()) width = 0; const to = `${26 + width}px`, from = $floatingButtons.style.getPropertyValue("right"); if (from === to) return; $floatingButtons.style.setProperty("right", to); animate($floatingButtons, [({ right: from }, { right: to })]); removeMutationListener(repositionFloatingButtons); }; addMutationListener(floatingButtons, repositionFloatingButtons); $panel.pin = () => { if (isPinned() || !panelViews.length) return; if (isClosed()) Object.assign(animationState, pinAnimation); animatePanel({ ...openWidth, ...pinAnimation }); animate($panel, [closedWidth, openWidth]); $panel.removeAttribute("data-peeked"); $panel.dataset.pinned = true; $topbarToggle.setAttribute("data-active", true); setInteractive(true); _setOpen(true); $panel.resize(); }; $panel.peek = () => { if (isPeeked() || !panelViews.length) return; if (isClosed()) Object.assign(animationState, peekAnimation); animatePanel({ ...openWidth, ...peekAnimation }); // closing on mouseout is disabled mid-animation, // queue close in case mouse is no longer peeking // after the initial animation is complete setTimeout(() => { if (!isDragging() && !$panel.matches(":hover")) $panel.close(); }, transitionDuration); $panel.removeAttribute("data-pinned"); $panel.dataset.peeked = true; setInteractive(true); $panel.resize(); }; $panel.close = async () => { if (isClosed()) return; if (panelViews.length) _setOpen(false); $topbarToggle.removeAttribute("data-active"); const width = (animationState.width = `${await getWidth()}px`); // only animate container close if it is actually taking up space, // otherwise will unnaturally grow + retrigger peek on peek mouseout if (isPinned()) animate($panel, [{ width }, closedWidth]); if (!$panel.matches(":hover")) { $panel.removeAttribute("data-pinned"); $panel.removeAttribute("data-peeked"); animatePanel(closedWidth); setInteractive(false); $panel.resize(); } else $panel.peek(); }; $panel.toggle = () => { if (isPinned()) $panel.close(); else $panel.pin(); }; // resizing handles visual resizes (inc. setting width to 0 // if closed) and actual resizes on drag (inc. saving to db) $panel.resize = async (width) => { $resizeTooltip.hide(); width = await getWidth(width); _setWidth?.(width); // works in conjunction with animations, acts as fallback // plus updates dependent styles e.g. page skeleton padding if (isClosed()) width = 0; const $parent = $panel.parentElement || $panel; $parent.style.setProperty("--panel--width", `${width}px`); if ($parent !== $panel) $panel.style.removeProperty("--panel--width"); repositionFloatingButtons(width); }; useState(["panelViews"], async ([panelViews = []]) => { $topbarToggle.style.display = panelViews.length ? "" : "none"; if (panelViews.length && (await _getOpen())) $panel.pin(); else $panel.close(); }); if (!hotkey) return $panel; addKeyListener(hotkey, (event) => { event.preventDefault(); event.stopPropagation(); $panel.toggle(); }); return $panel; } Object.assign((globalThis.__enhancerApi ??= {}), { addPanelView, removePanelView, }); export { Panel };