From c722c7f8543b1e764ef313f3d104af577d0c0b0c Mon Sep 17 00:00:00 2001 From: dragonwocky Date: Thu, 18 Jan 2024 15:32:58 +1100 Subject: [PATCH] fix(panel): reliable/not-weird panel animations --- src/core/client.mjs | 14 ++- src/core/islands/Panel.mjs | 245 +++++++++++++++++++++++-------------- src/shared/markup.js | 1 - 3 files changed, 161 insertions(+), 99 deletions(-) diff --git a/src/core/client.mjs b/src/core/client.mjs index 621e69f..9e92adf 100644 --- a/src/core/client.mjs +++ b/src/core/client.mjs @@ -123,7 +123,8 @@ const insertPanel = async (api, db) => { const notionFrame = ".notion-frame", notionTopbarBtn = ".notion-topbar-more-button", togglePanelHotkey = await db.get("togglePanelHotkey"), - { html, setState, addPanelView } = api; + { addPanelView, addMutationListener, addKeyListener } = api, + { html, setState, useState } = api; const $panel = html`<${Panel} ...${Object.assign( @@ -178,17 +179,18 @@ const insertPanel = async (api, db) => { $notionTopbarBtn?.before($panelTopbarBtn); } }; - api.addMutationListener(`${notionFrame}, ${notionTopbarBtn}`, appendToDom); - api.useState(["panelOpen"], ([panelOpen]) => { + addMutationListener(`${notionFrame}, ${notionTopbarBtn}`, appendToDom); + appendToDom(); + + useState(["panelOpen"], ([panelOpen]) => { if (panelOpen) $panelTopbarBtn.setAttribute("data-active", true); else $panelTopbarBtn.removeAttribute("data-active"); }); - api.useState(["panelViews"], ([panelViews = []]) => { + useState(["panelViews"], ([panelViews = []]) => { $panelTopbarBtn.style.display = panelViews.length ? "" : "none"; }); - appendToDom(); - api.addKeyListener(togglePanelHotkey, (event) => { + addKeyListener(togglePanelHotkey, (event) => { event.preventDefault(); event.stopPropagation(); $panel.toggle(); diff --git a/src/core/islands/Panel.mjs b/src/core/islands/Panel.mjs index 17b17e0..b371bcd 100644 --- a/src/core/islands/Panel.mjs +++ b/src/core/islands/Panel.mjs @@ -8,11 +8,9 @@ import { Tooltip } from "./Tooltip.mjs"; import { Select } from "../menu/islands/Select.mjs"; -// note: these islands do not accept extensible -// properties, i.e. they are not reusable. -// please register your own interfaces via -// globalThis.__enhancerApi.addPanelView and -// not by re-instantiating additional panels +// 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, @@ -90,9 +88,9 @@ function Panel({ }) { const { html, setState, useState } = globalThis.__enhancerApi, { addMutationListener, removeMutationListener } = globalThis.__enhancerApi, - $panel = html``; + + `; - let preDragWidth, - dragStartX = 0; + 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`, @@ -160,120 +209,132 @@ function Panel({ `, showTooltip = (event) => { setTimeout(() => { - const handleHovered = $resizeHandle.matches(":hover"); - if (!handleHovered) return; - const panelOpen = $panel.hasAttribute("open"), + if (!$resizeHandle.matches(":hover")) return; + const open = $panel.hasAttribute("open"), { x } = $resizeHandle.getBoundingClientRect(); - $onResizeClick.innerText = panelOpen ? "close" : "lock open"; + $onResizeClick.innerText = open ? "close" : "lock open"; $resizeTooltip.show(x, event.clientY); }, 200); }, startDrag = async (event) => { dragStartX = event.clientX; - preDragWidth = await _getWidth?.(); - if (isNaN(preDragWidth)) preDragWidth = minWidth; + preDragWidth = await getWidth(); document.addEventListener("mousemove", onDrag); document.addEventListener("mouseup", endDrag); - $panel.style.transitionDuration = "0ms"; }, onDrag = (event) => { event.preventDefault(); + if (!isDragging()) return; $panel.resize(preDragWidth + (dragStartX - event.clientX)); }, endDrag = (event) => { document.removeEventListener("mousemove", onDrag); document.removeEventListener("mouseup", endDrag); - $panel.style.transitionDuration = ""; + 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.prepend($resizeHandle); + $panel.lastElementChild.prepend($resizeHandle); - // pop out panel preview when hovering near the right edge - // of the screen, otherwise collapse panel when closed - const $hoverTrigger = html`
`; - $hoverTrigger.addEventListener("mouseover", () => $panel.resize(0, true)); - $panel.addEventListener("mouseenter", () => $panel.resize()); - $panel.addEventListener("mouseout", () => $panel.resize()); - $panel.append($hoverTrigger); + $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?.(); + width ??= await getWidth(); if (isNaN(width)) width = minWidth; if (!$panel.hasAttribute("open")) width = 0; - const position = $notionHelp.style.getPropertyValue("right"), - destination = `${26 + width}px`, - keyframes = [{ right: position }, { right: destination }], - options = { - duration: transitionDuration, - easing: "cubic-bezier(0.4, 0, 0.2, 1)", - }; - $notionHelp.style.setProperty("right", destination); - $notionHelp.animate(keyframes, options); + 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.resize = async (width, peek = false) => { - $resizeTooltip.hide(); - if (width && !isNaN(width)) { - width = Math.max(width, minWidth); - width = Math.min(width, maxWidth); - _setWidth?.(width); - } else width = await _getWidth?.(); - if (isNaN(width)) width = minWidth; - const panelOpen = $panel.hasAttribute("open"), - panelHovered = $panel.matches(":hover"); - if (panelOpen) { - } else { - if (!panelHovered && !peek) width = 0; - } - const $cssVarTarget = $panel.parentElement || $panel; - $cssVarTarget.style.setProperty("--panel--width", `${width}px`); - if ($cssVarTarget !== $panel) $panel.style.removeProperty("--panel--width"); - repositionHelp(width); - }; - $panel.open = () => { - if (!panelViews.length) return; - $panel.setAttribute("open", true); - $panel.querySelectorAll("[tabindex]").forEach(($el) => ($el.tabIndex = 1)); - setState({ panelOpen: true }); - $panel.onopen?.(); + $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.close = () => { - $resizeTooltip.hide(); - $panel.onbeforeclose?.(); - $panel.removeAttribute("open"); - $panel.style.pointerEvents = "auto"; - $panel.querySelectorAll("[tabindex]").forEach(($el) => ($el.tabIndex = -1)); + $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); - setTimeout(() => { - $panel.style.pointerEvents = ""; - $panel.onclose?.(); - }, transitionDuration); + 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 ($panel.hasAttribute("open")) $panel.close(); - else $panel.open(); + 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.open(); + if (panelViews.length && (await _getOpen())) $panel.pin(); else $panel.close(); }); return $panel; diff --git a/src/shared/markup.js b/src/shared/markup.js index 3ce6a96..d52ac35 100644 --- a/src/shared/markup.js +++ b/src/shared/markup.js @@ -93,7 +93,6 @@ twind.install({ ["children", "&>*"], ["siblings", "&~*"], ["sibling", "&+*"], - ["\\[.+]", (match) => "&" + match.input], [/^&/, (match) => match.input], ], });