mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(post): align op permalinks to the outer thread container (#1050)
* fix(post): align op permalinks to the outer thread container * perf(scroll-to-reply): stop interval when max attempts/duration reached
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { copyToClipboard } from '../clipboard-utils';
|
||||
import { hashStringToColor, getTextColorForBackground, removeMarkdown } from '../post-utils';
|
||||
import { preloadThemeAssets } from '../preload-utils';
|
||||
import { preloadReplyModal, preloadThemeAssets } from '../preload-utils';
|
||||
import { computeOmittedCount, filterRepliesForDisplay, getPreviewDisplayReplies, getTotalReplyCount } from '../replies-preview-utils';
|
||||
import { getQuotedCidsFromContent, mergeQuotedCids } from '../reply-quote-utils';
|
||||
import { formatUserIDForDisplay, truncateWithEllipsisInMiddle } from '../string-utils';
|
||||
@@ -119,6 +119,38 @@ describe('misc utils', () => {
|
||||
expect(loadedSources).toEqual(['/buttons/default.png', '/buttons/hover.png', '/backgrounds/wallpaper.png']);
|
||||
});
|
||||
|
||||
it('schedules reply modal preload with requestIdleCallback when available', () => {
|
||||
const requestIdleCallback = vi.fn();
|
||||
|
||||
Object.defineProperty(window, 'requestIdleCallback', {
|
||||
configurable: true,
|
||||
value: requestIdleCallback,
|
||||
});
|
||||
|
||||
preloadReplyModal();
|
||||
|
||||
expect(requestIdleCallback).toHaveBeenCalledWith(expect.any(Function), { timeout: 1500 });
|
||||
});
|
||||
|
||||
it('falls back to setTimeout for reply modal preload when requestIdleCallback is unavailable', () => {
|
||||
const originalRequestIdleCallback = window.requestIdleCallback;
|
||||
// @ts-expect-error test fallback path
|
||||
delete window.requestIdleCallback;
|
||||
vi.useFakeTimers();
|
||||
const setTimeoutSpy = vi.spyOn(window, 'setTimeout');
|
||||
|
||||
preloadReplyModal();
|
||||
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 500);
|
||||
|
||||
if (originalRequestIdleCallback) {
|
||||
Object.defineProperty(window, 'requestIdleCallback', {
|
||||
configurable: true,
|
||||
value: originalRequestIdleCallback,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('builds reply previews, omitted counts, and fallback reply totals', () => {
|
||||
expect(filterRepliesForDisplay([{ cid: 'visible' }, { cid: 'deleted', deleted: true }, { cid: 'removed', deleted: false }])).toEqual([
|
||||
{ cid: 'visible' },
|
||||
|
||||
@@ -19,3 +19,33 @@ export const preloadThemeAssets = (): void => {
|
||||
preloadImages(THEME_BUTTON_IMAGES);
|
||||
preloadImages(THEME_BACKGROUND_IMAGES);
|
||||
};
|
||||
|
||||
const scheduleIdlePreload = (callback: () => void): void => {
|
||||
if (typeof globalThis === 'undefined') {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
const requestIdle = (
|
||||
globalThis as typeof globalThis & {
|
||||
requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number;
|
||||
}
|
||||
).requestIdleCallback;
|
||||
|
||||
if (typeof requestIdle === 'function') {
|
||||
requestIdle(() => callback(), { timeout: 1500 });
|
||||
return;
|
||||
}
|
||||
|
||||
globalThis.setTimeout(callback, 500);
|
||||
};
|
||||
|
||||
/**
|
||||
* Preloads the reply modal chunk during idle time so the first `No.` click
|
||||
* doesn't have to wait for the lazy import to resolve.
|
||||
*/
|
||||
export const preloadReplyModal = (): void => {
|
||||
scheduleIdlePreload(() => {
|
||||
void import('../../components/reply-modal');
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
const THREAD_SCROLL_SPACER_ID = 'thread-scroll-spacer';
|
||||
|
||||
const setThreadScrollSpacerHeight = (height: number) => {
|
||||
const existingSpacer = document.getElementById(THREAD_SCROLL_SPACER_ID);
|
||||
if (height <= 0) {
|
||||
existingSpacer?.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const spacer =
|
||||
existingSpacer ||
|
||||
Object.assign(document.createElement('div'), {
|
||||
id: THREAD_SCROLL_SPACER_ID,
|
||||
});
|
||||
|
||||
spacer.setAttribute('aria-hidden', 'true');
|
||||
spacer.style.height = `${Math.ceil(height)}px`;
|
||||
spacer.style.pointerEvents = 'none';
|
||||
spacer.style.opacity = '0';
|
||||
|
||||
if (!existingSpacer) {
|
||||
document.body.appendChild(spacer);
|
||||
}
|
||||
};
|
||||
|
||||
export const clearThreadScrollSpacer = () => {
|
||||
document.getElementById(THREAD_SCROLL_SPACER_ID)?.remove();
|
||||
};
|
||||
|
||||
export const scrollThreadContainerToTop = (cid?: string) => {
|
||||
if (!cid) return false;
|
||||
|
||||
const threadContainer = document.querySelector<HTMLElement>(`[data-thread-container-cid="${cid}"]`);
|
||||
if (!threadContainer) return false;
|
||||
|
||||
const desiredTop = window.scrollY + threadContainer.getBoundingClientRect().top;
|
||||
const maxScrollTop = document.documentElement.scrollHeight - window.innerHeight;
|
||||
const extraSpace = Math.max(0, desiredTop - maxScrollTop);
|
||||
|
||||
setThreadScrollSpacerHeight(extraSpace);
|
||||
window.scrollTo({
|
||||
top: desiredTop,
|
||||
left: 0,
|
||||
behavior: 'auto',
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
Reference in New Issue
Block a user