Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Scroll to Bottom on Timeline V2 (modal branch) #1492

Closed
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
4ded22d
allow scroll to bottom on "embedded blog view"
marcustyphoon Feb 15, 2023
2238907
WIP: allow scroll to bottom on modal blog view
marcustyphoon Feb 16, 2023
21743e7
fix missing optional chaining crash
marcustyphoon Feb 22, 2023
a6aa4e0
Revert "Scroll to bottom: Don't deactivate on "embedded blog view" (#…
marcustyphoon Mar 6, 2023
b055c56
Merge branch 'master' into scroll-to-bottom-modal
marcustyphoon Mar 6, 2023
d76f47c
stop scrolling on modal close
marcustyphoon Mar 15, 2023
a7c946f
refine scroll cancel logic
marcustyphoon Mar 15, 2023
db84b3e
Merge branch 'master' into scroll-to-bottom-modal
marcustyphoon May 9, 2023
ec85f70
Remove style attribute manipulation
marcustyphoon Jul 28, 2023
be4736f
Extract identical logic
marcustyphoon Jul 28, 2023
1c28031
Fix button color desync
marcustyphoon Jul 28, 2023
875992f
add missing clean logic
marcustyphoon Jul 28, 2023
d6a8777
factor out keydown listener
marcustyphoon Jul 28, 2023
0d6cb2b
Merge branch 'master' into scroll-to-bottom-modal
marcustyphoon Jul 28, 2023
ffe08bc
Fix button color desync when navigating normally
marcustyphoon Aug 7, 2023
41a0c26
Merge branch 'master' into scroll-to-bottom-modal
marcustyphoon Aug 17, 2023
123e5ec
Merge branch 'master' into scroll-to-bottom-modal
AprilSylph Sep 25, 2023
9d844f1
Merge branch 'master' into scroll-to-bottom-modal
marcustyphoon Mar 26, 2024
38df853
Update manifest to require `:has()` support
marcustyphoon Mar 15, 2024
f86426d
hide regular button in modal blog view
marcustyphoon Mar 26, 2024
1ca19d0
Update manifest to require `:has()` support
marcustyphoon Mar 26, 2024
9a59a57
override active modal button background color
marcustyphoon Mar 26, 2024
ff4c38d
refine css
marcustyphoon Mar 26, 2024
1ec8d6f
cleanup; reduce duplication
marcustyphoon Mar 26, 2024
1b0010e
cleanup
marcustyphoon Mar 26, 2024
b879a48
Revert "Update manifest to require `:has()` support"
marcustyphoon May 28, 2024
f89811c
Revert "Update manifest to require `:has()` support"
marcustyphoon May 28, 2024
e24734d
Merge branch 'master' into scroll-to-bottom-modal
marcustyphoon Jun 11, 2024
82c6efc
scroll to bottom timeline v2 experiment
marcustyphoon Jun 11, 2024
7139b77
Merge branch 'master' into scroll-to-bottom-timeline-v2
marcustyphoon Jun 22, 2024
3dc45c3
Merge branch 'master' into scroll-to-bottom-timeline-v2
marcustyphoon Jun 23, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 101 additions & 37 deletions src/features/scroll_to_bottom.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,89 +3,153 @@ import { translate } from '../utils/language_data.js';
import { pageModifications } from '../utils/mutations.js';
import { buildStyle } from '../utils/interface.js';

const scrollToBottomButtonId = 'xkit-scroll-to-bottom-button';
$(`[id="${scrollToBottomButtonId}"]`).remove();
const buttonClass = 'xkit-scroll-to-bottom-button';
$(`.${buttonClass}`).remove();
const activeClass = 'xkit-scroll-to-bottom-active';

const loaderSelector = `
${keyToCss('timeline', 'blogRows')} > :last-child,
${keyToCss('notifications')} + ${keyToCss('loader')}
${keyToCss('timeline', 'blogRows')} > ${keyToCss('scrollContainer')} + div,
${keyToCss('notifications')} + div
`;
const knightRiderLoaderSelector = `:is(${loaderSelector}) > ${keyToCss('knightRiderLoader')}`;

const modalScrollContainerSelector = `${keyToCss('drawerContent')} > ${keyToCss('scrollContainer')}`;

let scrollToBottomButton;
let active = false;
let modalScrollToBottomButton;
let activeElement = false;

const styleElement = buildStyle(`
.${buttonClass} {
margin-top: 0.5ch;
transform: rotate(180deg);
}
.${buttonClass}.modal {
margin-top: 1ch;
}

#base-container:has(> #glass-container ${modalScrollContainerSelector}) .${buttonClass}.normal {
opacity: 0;
pointer-events: none;
}

.${activeClass} svg use {
--icon-color-primary: rgb(var(--yellow));
}
.${activeClass}.modal {
background-color: rgb(var(--black)) !important;
}
`);

const getScrollElement = () =>
document.querySelector(modalScrollContainerSelector) ||
document.documentElement;

const getObserveElement = () =>
document.querySelector(modalScrollContainerSelector)?.firstElementChild ||
document.documentElement;

let timeoutID;

const onLoadersAdded = loaders => {
if (activeElement && loaders.some(loader => activeElement.contains(loader))) {
clearTimeout(timeoutID);
}
};

const scrollToBottom = () => {
window.scrollTo({ top: document.documentElement.scrollHeight });
const loaders = [...document.querySelectorAll(knightRiderLoaderSelector)];
activeElement.scrollTo({ top: activeElement.scrollHeight });
clearTimeout(timeoutID);

const buttonConnected = scrollToBottomButton?.isConnected || modalScrollToBottomButton?.isConnected;

if (loaders.length === 0) {
if (!buttonConnected || activeElement !== getScrollElement()) {
stopScrolling();
return;
}

timeoutID = setTimeout(() => {
if (!activeElement.querySelector(knightRiderLoaderSelector)) {
stopScrolling();
}
}, 150);
};
const observer = new ResizeObserver(scrollToBottom);

const startScrolling = () => {
observer.observe(document.documentElement);
active = true;
scrollToBottomButton.classList.add(activeClass);
scrollToBottomButton?.classList.add(activeClass);
modalScrollToBottomButton?.classList.add(activeClass);

activeElement = getScrollElement();
observer.observe(getObserveElement());
scrollToBottom();
};

const stopScrolling = () => {
observer.disconnect();
active = false;
activeElement = false;

clearTimeout(timeoutID);

scrollToBottomButton?.classList.remove(activeClass);
modalScrollToBottomButton?.classList.remove(activeClass);
};

const onClick = () => active ? stopScrolling() : startScrolling();
const onClick = () => activeElement ? stopScrolling() : startScrolling();
const onKeyDown = ({ key }) => key === '.' && stopScrolling();

const checkForButtonRemoved = () => {
const buttonWasRemoved = document.documentElement.contains(scrollToBottomButton) === false;
if (buttonWasRemoved) {
if (active) stopScrolling();
pageModifications.unregister(checkForButtonRemoved);
}
const cloneButton = (target, mode) => {
const clonedButton = target.cloneNode(true);
keyToClasses('hidden').forEach(className => clonedButton.classList.remove(className));
clonedButton.removeAttribute('aria-label');
clonedButton.addEventListener('click', onClick);
clonedButton.classList.add(buttonClass, mode);

clonedButton.classList[activeElement ? 'add' : 'remove'](activeClass);
return clonedButton;
};

const addButtonToPage = async function ([scrollToTopButton]) {
if (!scrollToBottomButton) {
const hiddenClasses = keyToClasses('hidden');

scrollToBottomButton = scrollToTopButton.cloneNode(true);
hiddenClasses.forEach(className => scrollToBottomButton.classList.remove(className));
scrollToBottomButton.removeAttribute('aria-label');
scrollToBottomButton.style.marginTop = '0.5ch';
scrollToBottomButton.style.transform = 'rotate(180deg)';
scrollToBottomButton.addEventListener('click', onClick);
scrollToBottomButton.id = scrollToBottomButtonId;

scrollToBottomButton.classList[active ? 'add' : 'remove'](activeClass);
}
scrollToBottomButton ??= cloneButton(scrollToTopButton, 'normal');

scrollToTopButton.after(scrollToBottomButton);
scrollToTopButton.addEventListener('click', stopScrolling);
document.documentElement.addEventListener('keydown', onKeyDown);
pageModifications.register('*', checkForButtonRemoved);
};

const modalButtonColorObserver = new MutationObserver(([mutation]) => {
modalScrollToBottomButton.style = mutation.target.style.cssText;
});

const addModalButtonToPage = async function ([modalScrollToTopButton]) {
modalScrollToBottomButton ??= cloneButton(modalScrollToTopButton, 'modal');

modalScrollToTopButton.after(modalScrollToBottomButton);
modalScrollToTopButton.addEventListener('click', stopScrolling);

modalScrollToBottomButton.style = modalScrollToTopButton.style.cssText;
modalButtonColorObserver.observe(modalScrollToTopButton, { attributeFilter: ['style'] });
};

export const main = async function () {
pageModifications.register(`button[aria-label="${translate('Scroll to top')}"]`, addButtonToPage);
pageModifications.register(`button[aria-label="${translate('Back to top')}"]`, addModalButtonToPage);
pageModifications.register(knightRiderLoaderSelector, onLoadersAdded);
document.documentElement.addEventListener('keydown', onKeyDown);

document.documentElement.append(styleElement);
};

export const clean = async function () {
pageModifications.unregister(addButtonToPage);
pageModifications.unregister(checkForButtonRemoved);
stopScrolling();
scrollToBottomButton?.remove();

pageModifications.unregister(addButtonToPage);
pageModifications.unregister(addModalButtonToPage);
pageModifications.unregister(onLoadersAdded);
document.documentElement.removeEventListener('keydown', onKeyDown);

styleElement.remove();

scrollToBottomButton?.remove();
modalScrollToBottomButton?.remove();
modalButtonColorObserver.disconnect();
};