fix(panel): reliable/not-weird panel animations

This commit is contained in:
dragonwocky 2024-01-18 15:32:58 +11:00
parent a2efca4ca6
commit c722c7f854
Signed by: dragonwocky
GPG Key ID: 7998D08F7D7BD7A8
3 changed files with 161 additions and 99 deletions

View File

@ -123,7 +123,8 @@ const insertPanel = async (api, db) => {
const notionFrame = ".notion-frame", const notionFrame = ".notion-frame",
notionTopbarBtn = ".notion-topbar-more-button", notionTopbarBtn = ".notion-topbar-more-button",
togglePanelHotkey = await db.get("togglePanelHotkey"), togglePanelHotkey = await db.get("togglePanelHotkey"),
{ html, setState, addPanelView } = api; { addPanelView, addMutationListener, addKeyListener } = api,
{ html, setState, useState } = api;
const $panel = html`<${Panel} const $panel = html`<${Panel}
...${Object.assign( ...${Object.assign(
@ -178,17 +179,18 @@ const insertPanel = async (api, db) => {
$notionTopbarBtn?.before($panelTopbarBtn); $notionTopbarBtn?.before($panelTopbarBtn);
} }
}; };
api.addMutationListener(`${notionFrame}, ${notionTopbarBtn}`, appendToDom); addMutationListener(`${notionFrame}, ${notionTopbarBtn}`, appendToDom);
api.useState(["panelOpen"], ([panelOpen]) => { appendToDom();
useState(["panelOpen"], ([panelOpen]) => {
if (panelOpen) $panelTopbarBtn.setAttribute("data-active", true); if (panelOpen) $panelTopbarBtn.setAttribute("data-active", true);
else $panelTopbarBtn.removeAttribute("data-active"); else $panelTopbarBtn.removeAttribute("data-active");
}); });
api.useState(["panelViews"], ([panelViews = []]) => { useState(["panelViews"], ([panelViews = []]) => {
$panelTopbarBtn.style.display = panelViews.length ? "" : "none"; $panelTopbarBtn.style.display = panelViews.length ? "" : "none";
}); });
appendToDom();
api.addKeyListener(togglePanelHotkey, (event) => { addKeyListener(togglePanelHotkey, (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
$panel.toggle(); $panel.toggle();

View File

@ -8,11 +8,9 @@
import { Tooltip } from "./Tooltip.mjs"; import { Tooltip } from "./Tooltip.mjs";
import { Select } from "../menu/islands/Select.mjs"; import { Select } from "../menu/islands/Select.mjs";
// note: these islands do not accept extensible // note: these islands are not reusable.
// properties, i.e. they are not reusable. // panel views can be added via addPanelView,
// please register your own interfaces via // do not instantiate additional panels
// globalThis.__enhancerApi.addPanelView and
// not by re-instantiating additional panels
let panelViews = [], let panelViews = [],
// "$icon" may either be an actual dom element, // "$icon" may either be an actual dom element,
@ -90,9 +88,9 @@ function Panel({
}) { }) {
const { html, setState, useState } = globalThis.__enhancerApi, const { html, setState, useState } = globalThis.__enhancerApi,
{ addMutationListener, removeMutationListener } = globalThis.__enhancerApi, { addMutationListener, removeMutationListener } = globalThis.__enhancerApi,
$panel = html`<aside $panel = html`<div
class="notion-enhancer--panel group/panel class="notion-enhancer--panel group/panel order-2
order-2 shrink-0 [open]:w-[var(--panel--width,0)]" shrink-0 &[data-pinned]:w-[var(--panel--width,0)]"
> >
<style> <style>
.notion-frame { .notion-frame {
@ -106,12 +104,11 @@ function Panel({
overflow-x: clip; overflow-x: clip;
} }
</style> </style>
<div <aside
class="absolute right-0 bottom-0 bg-[color:var(--theme--bg-primary)] class="border-(l-1 [color:var(--theme--fg-border)]) w-0
z-20 border-(l-1 [color:var(--theme--fg-border)]) w-[var(--panel--width,0)] group-&[data-pinned]/panel:(w-[var(--panel--width,0)]) h-[calc(100vh-45px)] bottom-0)
transition-[width,bottom,top,border-radius] duration-[${transitionDuration}ms] absolute right-0 z-20 bg-[color:var(--theme--bg-primary)] group-&[data-peeked]/panel:(
hover:transition-[height,width,bottom,top,border-radius] h-[calc(100vh-45px)] w-[var(--panel--width,0)] h-[calc(100vh-120px)] bottom-[60px] rounded-l-[8px] border-(t-1 b-1)
group-not-[open]/panel:(bottom-[60px] h-[calc(100vh-120px)] rounded-l-[8px] border-(t-1 b-1)
shadow-[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])" shadow-[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])"
> >
<div <div
@ -130,27 +127,79 @@ function Panel({
> >
<i <i
class="i-chevrons-left w-[20px] h-[20px] class="i-chevrons-left w-[20px] h-[20px]
group-[open]/panel:rotate-180 duration-[${transitionDuration}ms] text-[color:var(--theme--fg-secondary)] transition-transform
transition-transform text-[color:var(--theme--fg-secondary)]" group-&[data-pinned]/panel:rotate-180 duration-[${transitionDuration}ms]"
/> />
</button> </button>
</div> </div>
<${View} ...${{ _get: _getView }} /> <${View} ...${{ _get: _getView }} />
</div> </aside>
</aside>`; </div>`;
let preDragWidth, let preDragWidth, dragStartX;
dragStartX = 0; 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`<div const $resizeHandle = html`<div
class="absolute h-full w-[3px] left-[-2px] class="absolute opacity-0 h-full w-[3px] left-[-2px]
z-20 active:cursor-text transition duration-300 active:cursor-text bg-[color:var(--theme--fg-border)] z-20
bg-[color:var(--theme--fg-border)] opacity-0 transition duration-300 hover:(cursor-col-resize opacity-100)
group-not-[open]/panel:(w-[8px] left-[-1px] rounded-l-[7px]) group-&[data-peeked]/panel:(w-[8px] left-[-1px] rounded-l-[7px])"
hover:(cursor-col-resize opacity-100)"
> >
<div <div
class="ml-[2px] bg-[color:var(--theme--bg-primary)] class="ml-[2px] bg-[color:var(--theme--bg-primary)]
group-not-[open]/panel:(my-px h-[calc(100%-2px)] rounded-l-[6px])" group-&[data-peeked]/panel:(my-px h-[calc(100%-2px)] rounded-l-[6px])"
></div> ></div>
</div>`, </div>`,
$onResizeClick = html`<span>close</span>`, $onResizeClick = html`<span>close</span>`,
@ -160,120 +209,132 @@ function Panel({
<//>`, <//>`,
showTooltip = (event) => { showTooltip = (event) => {
setTimeout(() => { setTimeout(() => {
const handleHovered = $resizeHandle.matches(":hover"); if (!$resizeHandle.matches(":hover")) return;
if (!handleHovered) return; const open = $panel.hasAttribute("open"),
const panelOpen = $panel.hasAttribute("open"),
{ x } = $resizeHandle.getBoundingClientRect(); { x } = $resizeHandle.getBoundingClientRect();
$onResizeClick.innerText = panelOpen ? "close" : "lock open"; $onResizeClick.innerText = open ? "close" : "lock open";
$resizeTooltip.show(x, event.clientY); $resizeTooltip.show(x, event.clientY);
}, 200); }, 200);
}, },
startDrag = async (event) => { startDrag = async (event) => {
dragStartX = event.clientX; dragStartX = event.clientX;
preDragWidth = await _getWidth?.(); preDragWidth = await getWidth();
if (isNaN(preDragWidth)) preDragWidth = minWidth;
document.addEventListener("mousemove", onDrag); document.addEventListener("mousemove", onDrag);
document.addEventListener("mouseup", endDrag); document.addEventListener("mouseup", endDrag);
$panel.style.transitionDuration = "0ms";
}, },
onDrag = (event) => { onDrag = (event) => {
event.preventDefault(); event.preventDefault();
if (!isDragging()) return;
$panel.resize(preDragWidth + (dragStartX - event.clientX)); $panel.resize(preDragWidth + (dragStartX - event.clientX));
}, },
endDrag = (event) => { endDrag = (event) => {
document.removeEventListener("mousemove", onDrag); document.removeEventListener("mousemove", onDrag);
document.removeEventListener("mouseup", endDrag); document.removeEventListener("mouseup", endDrag);
$panel.style.transitionDuration = ""; if (!isDragging()) return;
$panel.resize(preDragWidth + (dragStartX - event.clientX)); $panel.resize(preDragWidth + (dragStartX - event.clientX));
// toggle panel if not resized // toggle panel if not resized
if (dragStartX - event.clientX === 0) $panel.toggle(); if (dragStartX - event.clientX === 0) $panel.toggle();
preDragWidth = dragStartX = undefined;
}; };
$resizeHandle.addEventListener("mouseout", $resizeTooltip.hide); $resizeHandle.addEventListener("mouseout", $resizeTooltip.hide);
$resizeHandle.addEventListener("mousedown", startDrag); $resizeHandle.addEventListener("mousedown", startDrag);
$resizeHandle.addEventListener("mouseover", showTooltip); $resizeHandle.addEventListener("mouseover", showTooltip);
$panel.prepend($resizeHandle); $panel.lastElementChild.prepend($resizeHandle);
// pop out panel preview when hovering near the right edge // hovering over the peek trigger will temporarily
// of the screen, otherwise collapse panel when closed // pop out an interactive preview of the panel
const $hoverTrigger = html`<div const $peekTrigger = html`<div
class="z-10 absolute right-0 bottom-[60px] h-[calc(100vh-120px)] class="absolute z-10 right-0 h-[calc(100vh-120px)] bottom-[60px] w-[64px]
w-[64px] transition-[width] duration-[${transitionDuration}ms]" group-&[data-peeked]/panel:(w-[calc(var(--panel--width,0)+8px)])
group-&[data-pinned]/panel:(w-[calc(var(--panel--width,0)+8px)])"
></div>`; ></div>`;
$hoverTrigger.addEventListener("mouseover", () => $panel.resize(0, true)); $panel.prepend($peekTrigger);
$panel.addEventListener("mouseenter", () => $panel.resize()); $panel.addEventListener("mouseout", () => {
$panel.addEventListener("mouseout", () => $panel.resize()); if (isDragging() || isPinned()) return;
$panel.append($hoverTrigger); 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 // normally would place outside of an island, but in
// this case is necessary for syncing up animations // this case is necessary for syncing up animations
const notionHelp = ".notion-help-button", const notionHelp = ".notion-help-button",
repositionHelp = async (width) => { repositionHelp = async (width) => {
const $notionHelp = document.querySelector(notionHelp); const $notionHelp = document.querySelector(notionHelp);
if (!$notionHelp) return; if (!$notionHelp) return;
width ??= await _getWidth?.(); width ??= await getWidth();
if (isNaN(width)) width = minWidth; if (isNaN(width)) width = minWidth;
if (!$panel.hasAttribute("open")) width = 0; if (!$panel.hasAttribute("open")) width = 0;
const position = $notionHelp.style.getPropertyValue("right"), const to = `${26 + width}px`,
destination = `${26 + width}px`, from = $notionHelp.style.getPropertyValue("right"),
keyframes = [{ right: position }, { right: destination }], opts = { duration: transitionDuration, easing };
options = { if (from === to) return;
duration: transitionDuration, $notionHelp.style.setProperty("right", to);
easing: "cubic-bezier(0.4, 0, 0.2, 1)", animate($notionHelp, [({ right: from }, { right: to })]);
};
$notionHelp.style.setProperty("right", destination);
$notionHelp.animate(keyframes, options);
removeMutationListener(repositionHelp); removeMutationListener(repositionHelp);
}; };
addMutationListener(notionHelp, repositionHelp); addMutationListener(notionHelp, repositionHelp);
$panel.resize = async (width, peek = false) => { $panel.pin = () => {
$resizeTooltip.hide(); if (isPinned() || !panelViews.length) return;
if (width && !isNaN(width)) { if (isClosed()) Object.assign(animationState, pinAnimation);
width = Math.max(width, minWidth); animatePanel({ ...openWidth, ...pinAnimation });
width = Math.min(width, maxWidth); animate($panel, [closedWidth, openWidth]);
_setWidth?.(width); $panel.removeAttribute("data-peeked");
} else width = await _getWidth?.(); $panel.dataset.pinned = true;
if (isNaN(width)) width = minWidth; setInteractive(true);
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?.();
_setOpen(true); _setOpen(true);
setState({ panelOpen: true });
$panel.resize(); $panel.resize();
}; };
$panel.close = () => { $panel.peek = () => {
$resizeTooltip.hide(); if (isPeeked() || !panelViews.length) return;
$panel.onbeforeclose?.(); if (isClosed()) Object.assign(animationState, peekAnimation);
$panel.removeAttribute("open"); animatePanel({ ...openWidth, ...peekAnimation });
$panel.style.pointerEvents = "auto"; $panel.removeAttribute("data-pinned");
$panel.querySelectorAll("[tabindex]").forEach(($el) => ($el.tabIndex = -1)); $panel.dataset.peeked = true;
setInteractive(true);
$panel.resize(); $panel.resize();
};
$panel.close = async () => {
if (isClosed()) return;
setState({ panelOpen: false }); setState({ panelOpen: false });
if (panelViews.length) _setOpen(false); if (panelViews.length) _setOpen(false);
setTimeout(() => { const width = (animationState.width = `${await getWidth()}px`);
$panel.style.pointerEvents = ""; // only animate container close if it is actually taking up space,
$panel.onclose?.(); // otherwise will unnaturally grow + retrigger peek on peek mouseout
}, transitionDuration); 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 = () => { $panel.toggle = () => {
if ($panel.hasAttribute("open")) $panel.close(); if (isPinned()) $panel.close();
else $panel.open(); 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 = []]) => { useState(["panelViews"], async ([panelViews = []]) => {
if (panelViews.length && (await _getOpen())) $panel.open(); if (panelViews.length && (await _getOpen())) $panel.pin();
else $panel.close(); else $panel.close();
}); });
return $panel; return $panel;

View File

@ -93,7 +93,6 @@ twind.install({
["children", "&>*"], ["children", "&>*"],
["siblings", "&~*"], ["siblings", "&~*"],
["sibling", "&+*"], ["sibling", "&+*"],
["\\[.+]", (match) => "&" + match.input],
[/^&/, (match) => match.input], [/^&/, (match) => match.input],
], ],
}); });