Skip to content

Commit c430e68

Browse files
committed
feat: move to our own implementation of ScrollableContainer
1 parent d0df3dc commit c430e68

File tree

1 file changed

+337
-10
lines changed

1 file changed

+337
-10
lines changed

jsHelper/spicetifyWrapper.js

Lines changed: 337 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -582,7 +582,7 @@ const fnStr = (f) => {
582582
const exportedMemoFRefs = exportedMemos.filter((m) => m.type.$$typeof === Symbol.for("react.forward_ref"));
583583
const exposeReactComponentsUI = ({ modules, functionModules, exportedForwardRefs }) => {
584584
const componentNames = Object.keys(modules.filter(Boolean).find((e) => e.BrowserDefaultFocusStyleProvider));
585-
const componentRegexes = componentNames.map((n) => new RegExp(`"data-encore-id":(?:[a-zA-Z_\$][\w\$]*\\.){2}${n}\\b`));
585+
const componentRegexes = componentNames.map((n) => new RegExp(`"data-encore-id":(?:[a-zA-Z_$][w$]*\\.){2}${n}\\b`));
586586
const componentPairs = [functionModules.map((f) => [f, f]), exportedForwardRefs.map((f) => [f.render, f])]
587587
.flat()
588588
.map(([s, f]) => [componentNames.find((_, i) => fnStr(s)?.match(componentRegexes[i])), f]);
@@ -642,6 +642,339 @@ const fnStr = (f) => {
642642
.filter(Boolean),
643643
];
644644

645+
const _ScrollableContainer = (() => {
646+
const SHOW_BUTTONS = { NEVER: "never", ALWAYS: "always", ON_HOVER: "on-hover" };
647+
const SCROLLING_METHOD = { BY_RATIO: "by-ratio", SNAP: "snap" };
648+
const EDGE_GRADIENTS = { NONE: "none", MASK: "mask", LINEAR_GRADIENT: "linear-gradient" };
649+
const DIRECTION = { START: -1, END: 1 };
650+
651+
const CHEVRON_LEFT = '<path d="M11.521 1.38l-.65-.76L2.23 8l8.641 7.38.65-.76L3.77 8z"/>';
652+
const CHEVRON_RIGHT = '<path d="M5.129.62l-.65.76L12.231 8l-7.752 6.62.65.76L13.771 8z"/>';
653+
let stylesInjected = false;
654+
655+
function injectStyles() {
656+
if (stylesInjected) return;
657+
stylesInjected = true;
658+
const style = document.createElement("style");
659+
style.className = "spicetify-scrollable-container";
660+
style.textContent = `
661+
.spicetify-sc-contentArea { overflow: hidden; position: relative; }
662+
.spicetify-sc-scroller { display: flex; align-items: center; overflow-x: auto; scrollbar-width: none; white-space: nowrap; width: 100%; -ms-overflow-style: none; overscroll-behavior-x: contain; will-change: transform; }
663+
@media (prefers-reduced-motion: no-preference) { .spicetify-sc-scroller { scroll-behavior: smooth; } }
664+
.spicetify-sc-scroller::-webkit-scrollbar { display: none; }
665+
.spicetify-sc-scroller.spicetify-sc-snap { scroll-snap-type: inline mandatory; }
666+
.spicetify-sc-scroller.spicetify-sc-snap .spicetify-sc-snapCenter [data-carousel-item] { scroll-snap-align: center; }
667+
.spicetify-sc-scroller.spicetify-sc-snap .spicetify-sc-snapStart [data-carousel-item] { scroll-snap-align: start; }
668+
.spicetify-sc-scroller.spicetify-sc-wheelEnabled { overscroll-behavior: contain; }
669+
.spicetify-sc-scroller.spicetify-sc-maskGradient { --sc-start-color: #000; --sc-end-color: #000; -webkit-mask-composite: source-in, xor; mask-composite: intersect; -webkit-mask-image: linear-gradient(90deg, var(--sc-start-color) 0, #000 120px), linear-gradient(90deg, #000 calc(100% - 120px), var(--sc-end-color) 100%); mask-image: linear-gradient(90deg, var(--sc-start-color) 0, #000 120px), linear-gradient(90deg, #000 calc(100% - 120px), var(--sc-end-color) 100%); -webkit-mask-size: 100% 100%; mask-size: 100% 100%; }
670+
.spicetify-sc-scroller.spicetify-sc-maskGradient.spicetify-sc-maskStart { --sc-start-color: transparent; }
671+
.spicetify-sc-scroller.spicetify-sc-maskGradient.spicetify-sc-maskEnd { --sc-end-color: transparent; }
672+
.spicetify-sc-linearGradient::before, .spicetify-sc-linearGradient::after { bottom: 0; content: ""; height: 100%; opacity: 0; pointer-events: none; position: absolute; top: 0; transition: opacity .15s ease-out; width: 120px; z-index: 2; }
673+
.spicetify-sc-linearGradient::before { background: linear-gradient(90deg, var(--carousel-start-chevron-gradient, var(--spice-main)) 0, transparent 100%); inset-inline-start: 0; }
674+
.spicetify-sc-linearGradient::after { background: linear-gradient(-90deg, var(--carousel-end-chevron-gradient, var(--spice-main)) 0, transparent 100%); inset-inline-end: 0; }
675+
.spicetify-sc-linearGradient.spicetify-sc-lgStart::before { opacity: 1; }
676+
.spicetify-sc-linearGradient.spicetify-sc-lgEnd::after { opacity: 1; }
677+
.spicetify-sc-carousel { bottom: 0; left: 0; position: absolute; right: 0; top: 0; justify-content: space-between; align-items: center; display: flex; pointer-events: none; }
678+
.spicetify-sc-chevronBtn { display: flex; border: none; border-radius: 50%; cursor: pointer; justify-content: center; align-items: center; backdrop-filter: var(--chevrons-button-backdrop-filter, none); background: transparent; background-color: var(--chevrons-button-color, var(--background-elevated-base)); height: 24px; opacity: 0; position: relative; transition: color .15s ease-out, opacity .15s ease-out, background-color .15s ease-out, translate .15s ease-out; translate: 0; width: 24px; z-index: 3; pointer-events: none; color: var(--text-base, #fff); }
679+
.spicetify-sc-chevronBtn > * { opacity: .7; z-index: 2; }
680+
.spicetify-sc-chevronBtn:hover { background-color: var(--chevrons-button-hover-color, var(--background-elevated-highlight)); }
681+
.spicetify-sc-chevronBtn:hover > * { opacity: 1; }
682+
.spicetify-sc-chevronBtn.spicetify-sc-chevronVisible { opacity: 1; pointer-events: auto; }
683+
.spicetify-sc-onHover .spicetify-sc-chevronBtn { opacity: 0; }
684+
.spicetify-sc-contentArea:hover .spicetify-sc-onHover .spicetify-sc-chevronBtn.spicetify-sc-chevronVisible { opacity: 1; }
685+
.spicetify-sc-contentArea:hover .spicetify-sc-onHover .spicetify-sc-chevronStart.spicetify-sc-chevronVisible { translate: 8px; }
686+
.spicetify-sc-contentArea:hover .spicetify-sc-onHover .spicetify-sc-chevronEnd.spicetify-sc-chevronVisible { translate: -8px; }
687+
body[data-dragging-uri-type] .spicetify-sc-chevronBtn { pointer-events: none; }`;
688+
document.head.appendChild(style);
689+
}
690+
691+
function useDragToScroll({ isDisabled = true } = {}) {
692+
const { useRef, useCallback } = Spicetify.React;
693+
const frameRef = useRef(0);
694+
const savedBehavior = useRef(null);
695+
const savedSnapType = useRef(null);
696+
697+
return useCallback(
698+
isDisabled
699+
? () => {}
700+
: ({ currentTarget, clientX }) => {
701+
if (!(currentTarget instanceof HTMLElement)) return;
702+
const el = currentTarget;
703+
704+
const restore = () => {
705+
el.style.removeProperty("user-select");
706+
if (savedBehavior.current !== null) el.style.scrollBehavior = savedBehavior.current;
707+
if (savedSnapType.current !== null) el.style.scrollSnapType = savedSnapType.current;
708+
};
709+
const fullCleanup = () => {
710+
cancelAnimationFrame(frameRef.current);
711+
restore();
712+
};
713+
714+
fullCleanup();
715+
const computed = window.getComputedStyle(el);
716+
savedBehavior.current = computed.scrollBehavior;
717+
savedSnapType.current = computed.scrollSnapType;
718+
el.style.userSelect = "none";
719+
el.style.scrollBehavior = "auto";
720+
el.style.scrollSnapType = "none";
721+
722+
let dragged = false;
723+
const startScroll = el.scrollLeft;
724+
const startX = clientX;
725+
let velocity = 0;
726+
727+
const coast = () => {
728+
el.scrollLeft += velocity;
729+
velocity *= 0.95;
730+
if (Math.abs(velocity) > 0.5) frameRef.current = requestAnimationFrame(coast);
731+
else fullCleanup();
732+
};
733+
734+
const onMove = (e) => {
735+
const dx = e.clientX - startX;
736+
if (Math.abs(dx) > 10) dragged = true;
737+
const prev = el.scrollLeft;
738+
el.scrollLeft = startScroll - dx;
739+
velocity = el.scrollLeft - prev;
740+
};
741+
742+
document.addEventListener("mousemove", onMove);
743+
document.addEventListener(
744+
"mouseup",
745+
() => {
746+
if (dragged) {
747+
const block = (e) => {
748+
e.preventDefault();
749+
e.stopImmediatePropagation();
750+
};
751+
el.addEventListener("click", block, { once: true, capture: true });
752+
setTimeout(() => el.removeEventListener("click", block, { capture: true }));
753+
}
754+
document.removeEventListener("mousemove", onMove);
755+
cancelAnimationFrame(frameRef.current);
756+
frameRef.current = requestAnimationFrame(coast);
757+
document.addEventListener("wheel", fullCleanup, { once: true });
758+
},
759+
{ once: true }
760+
);
761+
},
762+
[isDisabled]
763+
);
764+
}
765+
766+
function useWheelScroll(onlyHorizontalWheel) {
767+
const { useRef, useCallback } = Spicetify.React;
768+
const isFirst = useRef(true);
769+
const savedBehavior = useRef(null);
770+
const timer = useRef(null);
771+
772+
return useCallback(
773+
(e) => {
774+
if (!e.deltaY) return;
775+
if (onlyHorizontalWheel && Math.abs(e.deltaY) > Math.abs(e.deltaX)) return;
776+
const el = e.currentTarget;
777+
if (isFirst.current) {
778+
isFirst.current = false;
779+
savedBehavior.current = el.style.scrollBehavior;
780+
el.style.scrollBehavior = "auto";
781+
}
782+
783+
el.scrollLeft += e.deltaY + e.deltaX;
784+
clearTimeout(timer.current);
785+
timer.current = setTimeout(() => {
786+
isFirst.current = true;
787+
el.style.scrollBehavior = savedBehavior.current ?? "";
788+
}, 100);
789+
},
790+
[onlyHorizontalWheel]
791+
);
792+
}
793+
794+
function useScrollState(scrollerRef, contentRef) {
795+
const { useState, useCallback, useEffect } = Spicetify.React;
796+
const [canGoStart, setCanGoStart] = useState(false);
797+
const [canGoEnd, setCanGoEnd] = useState(false);
798+
799+
const update = useCallback(() => {
800+
const el = scrollerRef.current;
801+
const child = contentRef.current;
802+
if (!el || !child) return;
803+
const maxScroll = el.scrollWidth - el.clientWidth;
804+
const pos = Math.abs(el.scrollLeft);
805+
const rounded = pos < 1 ? Math.floor(pos) : Math.ceil(pos);
806+
const overflows = child.offsetWidth > el.clientWidth;
807+
setCanGoStart(overflows && rounded !== 0);
808+
setCanGoEnd(overflows && rounded < maxScroll);
809+
}, [scrollerRef, contentRef]);
810+
811+
useEffect(() => {
812+
const el = scrollerRef.current;
813+
const child = contentRef.current;
814+
if (!el || !child) return;
815+
816+
update();
817+
el.addEventListener("scroll", update);
818+
const ro = new ResizeObserver(update);
819+
ro.observe(el);
820+
ro.observe(child);
821+
return () => {
822+
el.removeEventListener("scroll", update);
823+
ro.disconnect();
824+
};
825+
}, [update, scrollerRef, contentRef]);
826+
827+
return { canGoStart, canGoEnd };
828+
}
829+
830+
function ScrollableContainerComponent(props) {
831+
const { useRef, useCallback, useMemo } = Spicetify.React;
832+
const h = Spicetify.ReactJSX.jsx;
833+
const hsf = Spicetify.ReactJSX.jsxs;
834+
const cn = Spicetify.classnames;
835+
836+
const {
837+
children,
838+
className,
839+
chevronsClassName,
840+
showButtons = SHOW_BUTTONS.ALWAYS,
841+
ariaLabel,
842+
onlyHorizontalWheel = false,
843+
wheelScrollEnabled = true,
844+
scrollContentClassName,
845+
scrollerClassName,
846+
scrollRatio = 0.9,
847+
scrollingMethod = SCROLLING_METHOD.BY_RATIO,
848+
scrollPadding,
849+
scrollSnapAlign,
850+
scrollSnapByItems = 1,
851+
edgeGradients = EDGE_GRADIENTS.MASK,
852+
dragToScrollOptions = { isDisabled: true },
853+
onScroll,
854+
activeElementThreshold = 10,
855+
onNavigationClick,
856+
role = "list",
857+
} = props;
858+
859+
injectStyles();
860+
861+
const scrollerRef = useRef(null);
862+
const contentRef = useRef(null);
863+
const lastIndex = useRef(-1);
864+
865+
const { canGoStart, canGoEnd } = useScrollState(scrollerRef, contentRef);
866+
const dragHandler = useDragToScroll(dragToScrollOptions);
867+
const wheelHandler = useWheelScroll(onlyHorizontalWheel);
868+
const isRtl = useMemo(() => document.documentElement.dir === "rtl", []);
869+
870+
const getActiveIndex = useCallback(() => {
871+
const scrollPos = Math.abs(scrollerRef.current?.scrollLeft ?? 0);
872+
let index = -1;
873+
if (contentRef.current?.children) {
874+
let idx = -1;
875+
for (const child of contentRef.current.children) {
876+
if (child instanceof HTMLElement) {
877+
idx++;
878+
if (Math.abs(child.offsetLeft - scrollPos) <= child.offsetWidth / activeElementThreshold) index = idx;
879+
}
880+
}
881+
}
882+
return index;
883+
}, [activeElementThreshold]);
884+
885+
const fireScroll = useCallback(() => {
886+
if (!onScroll) return;
887+
const index = getActiveIndex();
888+
if (lastIndex.current !== index) {
889+
lastIndex.current = index;
890+
onScroll(index);
891+
}
892+
}, [getActiveIndex, onScroll]);
893+
894+
const navigate = useCallback(
895+
(direction) => {
896+
if (!scrollerRef.current) return;
897+
const dir = isRtl ? -1 : 1;
898+
899+
if (scrollingMethod === SCROLLING_METHOD.SNAP) {
900+
const item = contentRef.current?.querySelector("[data-carousel-item]");
901+
if (!item) return;
902+
scrollerRef.current.scrollBy({ left: dir * scrollSnapByItems * item.getBoundingClientRect().width * direction });
903+
} else scrollerRef.current.scrollBy({ left: dir * direction * scrollerRef.current.clientWidth * scrollRatio });
904+
905+
fireScroll();
906+
onNavigationClick?.(direction);
907+
},
908+
[scrollingMethod, fireScroll, isRtl, scrollSnapByItems, scrollRatio, onNavigationClick]
909+
);
910+
911+
const isSnap = scrollingMethod === SCROLLING_METHOD.SNAP;
912+
const isMask = edgeGradients === EDGE_GRADIENTS.MASK;
913+
const isLinearGradient = edgeGradients === EDGE_GRADIENTS.LINEAR_GRADIENT;
914+
915+
const makeChevron = (svgPath, position, visible, dir) =>
916+
h("div", {
917+
className: cn("spicetify-sc-chevronBtn", `spicetify-sc-chevron${position}`, { "spicetify-sc-chevronVisible": visible }),
918+
onClick: (e) => {
919+
e.preventDefault();
920+
e.stopPropagation();
921+
navigate(dir);
922+
},
923+
"aria-hidden": "true",
924+
children: h("svg", { height: 12, width: 12, viewBox: "0 0 16 16", fill: "currentColor", dangerouslySetInnerHTML: { __html: svgPath } }),
925+
});
926+
927+
return hsf("div", {
928+
className: cn("spicetify-sc-contentArea", className, {
929+
"spicetify-sc-linearGradient": isLinearGradient,
930+
"spicetify-sc-lgStart": isLinearGradient && canGoStart,
931+
"spicetify-sc-lgEnd": isLinearGradient && canGoEnd,
932+
}),
933+
children: [
934+
h("div", {
935+
ref: scrollerRef,
936+
className: cn("spicetify-sc-scroller", scrollerClassName, {
937+
"spicetify-sc-snap": isSnap,
938+
"spicetify-sc-maskGradient": isMask,
939+
"spicetify-sc-wheelEnabled": wheelScrollEnabled,
940+
"spicetify-sc-maskStart": isMask && canGoStart,
941+
"spicetify-sc-maskEnd": isMask && canGoEnd,
942+
}),
943+
onScroll: onScroll ? fireScroll : undefined,
944+
onMouseDownCapture: dragHandler,
945+
onWheel: wheelScrollEnabled ? wheelHandler : undefined,
946+
role,
947+
"aria-label": ariaLabel,
948+
style: isSnap ? { scrollPadding } : undefined,
949+
children: h("div", {
950+
ref: contentRef,
951+
role: "presentation",
952+
className: cn(scrollContentClassName, {
953+
"spicetify-sc-snapStart": scrollSnapAlign === "start",
954+
"spicetify-sc-snapCenter": scrollSnapAlign === "center",
955+
}),
956+
children,
957+
}),
958+
}),
959+
showButtons !== SHOW_BUTTONS.NEVER &&
960+
hsf("div", {
961+
className: cn("spicetify-sc-carousel", chevronsClassName, {
962+
"spicetify-sc-onHover": showButtons === SHOW_BUTTONS.ON_HOVER,
963+
}),
964+
children: [makeChevron(CHEVRON_LEFT, "Start", canGoStart, DIRECTION.START), makeChevron(CHEVRON_RIGHT, "End", canGoEnd, DIRECTION.END)],
965+
}),
966+
],
967+
});
968+
}
969+
970+
ScrollableContainerComponent.SHOW_BUTTONS = SHOW_BUTTONS;
971+
ScrollableContainerComponent.SCROLLING_METHOD = SCROLLING_METHOD;
972+
ScrollableContainerComponent.EDGE_GRADIENTS = EDGE_GRADIENTS;
973+
ScrollableContainerComponent.DIRECTION = DIRECTION;
974+
975+
return ScrollableContainerComponent;
976+
})();
977+
645978
Object.assign(Spicetify, {
646979
React: cache.find((m) => m?.useMemo),
647980
ReactJSX: cache.find((m) => m?.jsx),
@@ -706,9 +1039,7 @@ const fnStr = (f) => {
7061039
Routes: functionModules.find((m) => fnStr(m).match(/\([\w$]+\)\{let\{children:[\w$]+,location:[\w$]+\}=[\w$]+/)),
7071040
Route: functionModules.find((m) => fnStr(m).match(/^function [\w$]+\([\w$]+\)\{\(0,[\w$]+\.[\w$]+\)\(!1\)\}$/)),
7081041
StoreProvider: functionModules.find((m) => fnStr(m).includes("notifyNestedSubs") && fnStr(m).includes("serverState")),
709-
ScrollableContainer:
710-
functionModules.find((m) => fnStr(m).includes("scrollLeft") && fnStr(m).includes("showButtons")) ||
711-
exportedMemos?.find((m) => fnStr(m.type).includes("enableStandaloneUBI")),
1042+
ScrollableContainer: _ScrollableContainer,
7121043
IconComponent: reactComponentsUI.Icon,
7131044
...Object.fromEntries(menus),
7141045
},
@@ -752,7 +1083,6 @@ const fnStr = (f) => {
7521083

7531084
(function waitForChunks() {
7541085
const listOfComponents = [
755-
"ScrollableContainer",
7561086
"Slider",
7571087
"Dropdown",
7581088
"Toggle",
@@ -807,9 +1137,6 @@ const fnStr = (f) => {
8071137

8081138
Spicetify.ReactComponent.Slider = wrapProvider(functionModules.find((m) => fnStr(m).includes("progressBarRef")));
8091139
Spicetify.ReactComponent.Toggle = functionModules.find((m) => fnStr(m).includes("onSelected") && fnStr(m).includes('type:"checkbox"'));
810-
Spicetify.ReactComponent.ScrollableContainer =
811-
functionModules.find((m) => fnStr(m).includes("scrollLeft") && fnStr(m).includes("showButtons")) ||
812-
exportedMemos?.find((m) => fnStr(m.type).includes("enableStandaloneUBI"));
8131140
// Object.assign(Spicetify.ReactComponent.Cards, Object.fromEntries(cards));
8141141

8151142
// chunks
@@ -1041,7 +1368,7 @@ const fnStr = (f) => {
10411368
// createURI functions
10421369
const createURIFunctions = URIModules.filter((m) => typeof m === "function" && m.toString().match(/\([\w$]+\./));
10431370
for (const type of Object.keys(Spicetify.URI.Type)) {
1044-
const func = createURIFunctions.find((m) => m.toString().match(new RegExp(`\\([\\w$]+\\.${type}\(?!_\)`)));
1371+
const func = createURIFunctions.find((m) => m.toString().match(new RegExp(`\\([\\w$]+\\.${type}(?!_)`)));
10451372
if (!func) continue;
10461373

10471374
const camelCaseType = type
@@ -1058,7 +1385,7 @@ const fnStr = (f) => {
10581385
// isURI functions
10591386
const isURIFUnctions = URIModules.filter((m) => typeof m === "function" && m.toString().match(/=[\w$]+\./));
10601387
for (const type of Object.keys(Spicetify.URI.Type)) {
1061-
const func = isURIFUnctions.find((m) => m.toString().match(new RegExp(`===[\\w$]+\\.${type}\(?!_\)\\}`)));
1388+
const func = isURIFUnctions.find((m) => m.toString().match(new RegExp(`===[\\w$]+\\.${type}(?!_)\\}`)));
10621389
const camelCaseType = type
10631390
.toLowerCase()
10641391
.split("_")

0 commit comments

Comments
 (0)