/** * notion-enhancer * (c) 2023 dragonwocky (https://dragonwocky.me/) * (c) 2021 CloudHill (https://github.com/CloudHill) * (https://notion-enhancer.github.io/) under the MIT license */ import { Tooltip } from "./Tooltip.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, setState, useState } = globalThis.__enhancerApi, $switcher = html`
`, setView = (view) => { _set?.(view); setState({ activePanelView: 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({ _getWidth, _setWidth, _getOpen, _setOpen, _getView, _setView, minWidth = 256, maxWidth = 640, transitionDuration = 300, }) { const { html, setState, useState } = globalThis.__enhancerApi, { addMutationListener, removeMutationListener } = globalThis.__enhancerApi, $panel = html`
`; let preDragWidth, dragStartX; 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)); }, 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: "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", }, pinAnimation = { height: "calc(100vh - 45px)", bottom: "0px", borderTopWidth: "0px", borderBottomWidth: "0px", borderTopLeftRadius: "0px", borderBottomLeftRadius: "0px", boxShadow: "none", }; const animationState = {}, easing = "cubic-bezier(0.4, 0, 0.2, 1)", animate = ($target, keyframes) => { const opts = { fill: "forwards", duration: transitionDuration, easing }; $target.animate(keyframes, opts); console.log($target, keyframes); }, animatePanel = (to) => { 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`
`, $onResizeClick = html`close`, $resizeTooltip = html`<${Tooltip}> Drag to resize
Click to ${$onResizeClick} `, showTooltip = (event) => { setTimeout(() => { if (!$resizeHandle.matches(":hover")) return; const open = $panel.hasAttribute("open"), { x } = $resizeHandle.getBoundingClientRect(); $onResizeClick.innerText = open ? "close" : "lock open"; $resizeTooltip.show(x, event.clientY); }, 200); }, 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("mouseout", $resizeTooltip.hide); $resizeHandle.addEventListener("mousedown", startDrag); $resizeHandle.addEventListener("mouseover", showTooltip); $panel.lastElementChild.prepend($resizeHandle); // hovering over the peek trigger will temporarily // pop out an interactive preview of the panel const $peekTrigger = html`
`; $panel.prepend($peekTrigger); $panel.addEventListener("mouseout", () => { if (isDragging() || isPinned()) return; if (!$panel.matches(":hover")) $panel.close(); }); $panel.addEventListener("mouseover", () => { if (isClosed() && $panel.matches(":hover")) $panel.peek(); }); // 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 notionHelp = ".notion-help-button", repositionHelp = async (width) => { const $notionHelp = document.querySelector(notionHelp); if (!$notionHelp) return; width ??= await getWidth(); if (isNaN(width)) width = minWidth; if (!$panel.hasAttribute("open")) width = 0; const to = `${26 + width}px`, from = $notionHelp.style.getPropertyValue("right"), opts = { duration: transitionDuration, easing }; if (from === to) return; $notionHelp.style.setProperty("right", to); animate($notionHelp, [({ right: from }, { right: to })]); removeMutationListener(repositionHelp); }; addMutationListener(notionHelp, repositionHelp); $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; setInteractive(true); _setOpen(true); setState({ panelOpen: true }); $panel.resize(); }; $panel.peek = () => { if (isPeeked() || !panelViews.length) return; if (isClosed()) Object.assign(animationState, peekAnimation); animatePanel({ ...openWidth, ...peekAnimation }); $panel.removeAttribute("data-pinned"); $panel.dataset.peeked = true; setInteractive(true); $panel.resize(); }; $panel.close = async () => { if (isClosed()) return; setState({ panelOpen: false }); if (panelViews.length) _setOpen(false); 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"); repositionHelp(width); }; useState(["panelViews"], async ([panelViews = []]) => { if (panelViews.length && (await _getOpen())) $panel.pin(); else $panel.close(); }); return $panel; } globalThis.__enhancerApi ??= {}; Object.assign(globalThis.__enhancerApi, { addPanelView, removePanelView, }); export { Panel };