mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat: finalize pretext feed sizing rollout (#1120)
* feat(feeds): add pretext-backed item sizing across board, catalog, and thread replies * feat: finalize pretext feed sizing rollout * fix(catalog): raise multiboard viewport buffer * fix(board): preserve pretext query overrides
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
getFeedPostHeightEstimate,
|
||||
getReplyHeightEstimates,
|
||||
getReplyItemSizeFromElement,
|
||||
resolveCatalogVirtualizationMode,
|
||||
resolveFeedVirtualizationMode,
|
||||
resolveReplyVirtualizationMode,
|
||||
} from '../pretext-height-estimates';
|
||||
|
||||
describe('pretext-height-estimates', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => ({
|
||||
font: '',
|
||||
measureText: vi.fn((text: string) => ({
|
||||
actualBoundingBoxAscent: 10,
|
||||
actualBoundingBoxDescent: 3,
|
||||
fontBoundingBoxAscent: 10,
|
||||
fontBoundingBoxDescent: 3,
|
||||
width: text.length * 7,
|
||||
})),
|
||||
})),
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses reply virtualization mode from search params', () => {
|
||||
expect(resolveReplyVirtualizationMode('?pretextReplies=item-size')).toBe('item-size');
|
||||
expect(resolveReplyVirtualizationMode(new URLSearchParams('pretextReplies=off'))).toBe('off');
|
||||
expect(resolveReplyVirtualizationMode('?pretextReplies=unknown')).toBe('item-size');
|
||||
});
|
||||
|
||||
it('parses feed virtualization mode from search params', () => {
|
||||
expect(resolveFeedVirtualizationMode('?pretextFeed=item-size')).toBe('item-size');
|
||||
expect(resolveFeedVirtualizationMode(new URLSearchParams('pretextFeed=off'))).toBe('off');
|
||||
expect(resolveFeedVirtualizationMode('?pretextFeed=unknown')).toBe('off');
|
||||
});
|
||||
|
||||
it('parses catalog virtualization mode from search params', () => {
|
||||
expect(resolveCatalogVirtualizationMode('?pretextCatalog=item-size')).toBe('item-size');
|
||||
expect(resolveCatalogVirtualizationMode(new URLSearchParams('pretextCatalog=off'))).toBe('off');
|
||||
expect(resolveCatalogVirtualizationMode('?pretextCatalog=unknown')).toBe('off');
|
||||
});
|
||||
|
||||
it('falls back to hash-fragment query params for hash-routed URLs', () => {
|
||||
const previousHref = window.location.href;
|
||||
window.history.replaceState({}, '', '/#/mu?pretextFeed=item-size&pretextReplies=off');
|
||||
|
||||
expect(resolveFeedVirtualizationMode(window.location.search)).toBe('item-size');
|
||||
expect(resolveReplyVirtualizationMode(window.location.search)).toBe('off');
|
||||
expect(resolveCatalogVirtualizationMode(window.location.search)).toBe('off');
|
||||
|
||||
window.history.replaceState({}, '', previousHref);
|
||||
});
|
||||
|
||||
it('prefers cached Pretext height when itemSize reads a reply row', () => {
|
||||
const element = document.createElement('div');
|
||||
Object.defineProperty(element, 'offsetHeight', { configurable: true, value: 321 });
|
||||
Object.defineProperty(element, 'offsetWidth', { configurable: true, value: 654 });
|
||||
element.dataset.pretextHeight = '123';
|
||||
|
||||
expect(getReplyItemSizeFromElement(element, 'offsetHeight')).toBe(123);
|
||||
expect(getReplyItemSizeFromElement(element, 'offsetWidth')).toBe(654);
|
||||
|
||||
delete element.dataset.pretextHeight;
|
||||
expect(getReplyItemSizeFromElement(element, 'offsetHeight')).toBe(321);
|
||||
});
|
||||
|
||||
it('falls back to a nested Pretext height when the wrapper has none', () => {
|
||||
const element = document.createElement('div');
|
||||
const child = document.createElement('div');
|
||||
|
||||
Object.defineProperty(element, 'offsetHeight', { configurable: true, value: 321 });
|
||||
Object.defineProperty(element, 'offsetWidth', { configurable: true, value: 654 });
|
||||
|
||||
child.dataset.pretextHeight = '222';
|
||||
element.appendChild(child);
|
||||
|
||||
expect(getReplyItemSizeFromElement(element, 'offsetHeight')).toBe(222);
|
||||
});
|
||||
|
||||
it('adds desktop board-label height for multiboard text posts without media', () => {
|
||||
const metrics = {
|
||||
abbrFontSizePx: 13,
|
||||
bodyFontFamily: 'Arial, Helvetica, sans-serif',
|
||||
bodyFontSizePx: 13,
|
||||
mobileContentFontSizePx: 15,
|
||||
};
|
||||
const post = {
|
||||
cid: 'post-1',
|
||||
communityAddress: 'music-posting.eth',
|
||||
content: 'A short desktop board post',
|
||||
postCid: 'post-1',
|
||||
state: 'succeeded',
|
||||
};
|
||||
|
||||
const withoutBoardLabel = getFeedPostHeightEstimate({
|
||||
isMobile: false,
|
||||
metrics,
|
||||
post,
|
||||
previewReplies: [],
|
||||
windowWidth: 1280,
|
||||
});
|
||||
const withBoardLabel = getFeedPostHeightEstimate({
|
||||
isMobile: false,
|
||||
metrics,
|
||||
post,
|
||||
previewReplies: [],
|
||||
showBoardLabel: true,
|
||||
windowWidth: 1280,
|
||||
});
|
||||
|
||||
expect(withBoardLabel).toBeGreaterThan(withoutBoardLabel);
|
||||
});
|
||||
|
||||
it('accounts for the board-view long-comment notice on truncated desktop posts', () => {
|
||||
const metrics = {
|
||||
abbrFontSizePx: 13,
|
||||
bodyFontFamily: 'Arial, Helvetica, sans-serif',
|
||||
bodyFontSizePx: 13,
|
||||
mobileContentFontSizePx: 15,
|
||||
};
|
||||
const visiblePrefix = 'word '.repeat(200);
|
||||
const shortPost = {
|
||||
cid: 'post-short',
|
||||
communityAddress: 'music-posting.eth',
|
||||
content: visiblePrefix,
|
||||
postCid: 'post-short',
|
||||
state: 'succeeded',
|
||||
};
|
||||
const truncatedPost = {
|
||||
...shortPost,
|
||||
cid: 'post-long',
|
||||
content: `${visiblePrefix}${'overflow '.repeat(40)}`,
|
||||
postCid: 'post-long',
|
||||
};
|
||||
|
||||
const shortEstimate = getFeedPostHeightEstimate({
|
||||
isMobile: false,
|
||||
metrics,
|
||||
post: shortPost,
|
||||
previewReplies: [],
|
||||
windowWidth: 1280,
|
||||
});
|
||||
const truncatedEstimate = getFeedPostHeightEstimate({
|
||||
isMobile: false,
|
||||
metrics,
|
||||
post: truncatedPost,
|
||||
previewReplies: [],
|
||||
windowWidth: 1280,
|
||||
});
|
||||
|
||||
expect(truncatedEstimate).toBeGreaterThan(shortEstimate);
|
||||
});
|
||||
|
||||
it('adds desktop preview reply estimates without subtracting a per-reply calibration', () => {
|
||||
const metrics = {
|
||||
abbrFontSizePx: 13,
|
||||
bodyFontFamily: 'Arial, Helvetica, sans-serif',
|
||||
bodyFontSizePx: 13,
|
||||
mobileContentFontSizePx: 15,
|
||||
};
|
||||
const post = {
|
||||
cid: 'post-preview-parent',
|
||||
communityAddress: 'music-posting.eth',
|
||||
content: 'A longer desktop board post '.repeat(24),
|
||||
postCid: 'post-preview-parent',
|
||||
state: 'succeeded',
|
||||
};
|
||||
const previewReply = {
|
||||
cid: 'post-preview-reply',
|
||||
communityAddress: 'music-posting.eth',
|
||||
content: 'A preview reply',
|
||||
parentCid: 'post-preview-parent',
|
||||
postCid: 'post-preview-parent',
|
||||
state: 'succeeded',
|
||||
};
|
||||
|
||||
const previewEstimate = getReplyHeightEstimates({
|
||||
isMobile: false,
|
||||
metrics,
|
||||
replies: [previewReply],
|
||||
windowWidth: 1280,
|
||||
})[0];
|
||||
|
||||
const withoutPreview = getFeedPostHeightEstimate({
|
||||
isMobile: false,
|
||||
metrics,
|
||||
post,
|
||||
previewReplies: [],
|
||||
windowWidth: 1280,
|
||||
});
|
||||
const withPreview = getFeedPostHeightEstimate({
|
||||
isMobile: false,
|
||||
metrics,
|
||||
post,
|
||||
previewReplies: [previewReply],
|
||||
previewReplyEstimates: [previewEstimate],
|
||||
windowWidth: 1280,
|
||||
});
|
||||
|
||||
expect(withPreview - withoutPreview).toBe(previewEstimate);
|
||||
});
|
||||
|
||||
it('keeps most mobile preview-reply height on board cards', () => {
|
||||
const metrics = {
|
||||
abbrFontSizePx: 13,
|
||||
bodyFontFamily: 'Arial, Helvetica, sans-serif',
|
||||
bodyFontSizePx: 13,
|
||||
mobileContentFontSizePx: 15,
|
||||
};
|
||||
const post = {
|
||||
cid: 'post-mobile-preview-parent',
|
||||
communityAddress: 'music-posting.eth',
|
||||
content: 'A mobile board post with preview replies',
|
||||
link: 'https://example.com/image.jpg',
|
||||
linkHeight: 720,
|
||||
linkWidth: 1280,
|
||||
postCid: 'post-mobile-preview-parent',
|
||||
state: 'succeeded',
|
||||
};
|
||||
const previewReplies = Array.from({ length: 5 }, (_, index) => ({
|
||||
cid: `post-mobile-preview-reply-${index}`,
|
||||
communityAddress: 'music-posting.eth',
|
||||
content: `Preview reply ${index} ${'text '.repeat(index + 1)}`,
|
||||
parentCid: 'post-mobile-preview-parent',
|
||||
postCid: 'post-mobile-preview-parent',
|
||||
state: 'succeeded',
|
||||
}));
|
||||
|
||||
const previewEstimates = getReplyHeightEstimates({
|
||||
isMobile: true,
|
||||
metrics,
|
||||
replies: previewReplies,
|
||||
windowWidth: 393,
|
||||
});
|
||||
const withoutPreview = getFeedPostHeightEstimate({
|
||||
isMobile: true,
|
||||
metrics,
|
||||
post,
|
||||
previewReplies: [],
|
||||
windowWidth: 393,
|
||||
});
|
||||
const withPreview = getFeedPostHeightEstimate({
|
||||
isMobile: true,
|
||||
metrics,
|
||||
post,
|
||||
previewReplies,
|
||||
previewReplyEstimates: previewEstimates,
|
||||
windowWidth: 393,
|
||||
});
|
||||
|
||||
expect(withPreview).toBeGreaterThan(withoutPreview + 300);
|
||||
expect(withPreview - withoutPreview).toBeGreaterThan(previewEstimates.reduce((sum, value) => sum + value, 0) - 220);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,841 @@
|
||||
import type { Comment } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import { layout, layoutNextLine, prepare, prepareWithSegments } from '@chenglou/pretext';
|
||||
import { getCommentMediaInfo, getHasThumbnail } from './media-utils';
|
||||
import { removeMarkdown } from './post-utils';
|
||||
import { getRenderableMobileBacklinks } from './reply-backlink-utils';
|
||||
|
||||
export type ReplyVirtualizationMode = 'off' | 'estimates' | 'item-size';
|
||||
export type CatalogImageSize = 'Small' | 'Large';
|
||||
type ReplyEstimateContext = 'preview' | 'thread';
|
||||
|
||||
type ReplyHeightAuditSample = {
|
||||
actual: number;
|
||||
cid: string;
|
||||
diff: number;
|
||||
estimate: number;
|
||||
};
|
||||
|
||||
type ReplyHeightAuditStore = {
|
||||
getSummary: () => { maxAbsoluteError: number; meanAbsoluteError: number; sampleCount: number };
|
||||
record: (sample: ReplyHeightAuditSample) => void;
|
||||
reset: () => void;
|
||||
samples: Map<string, ReplyHeightAuditSample>;
|
||||
};
|
||||
|
||||
const PT_TO_PX = 96 / 72;
|
||||
const DESKTOP_REPLY_BASE_HEIGHT = 46;
|
||||
const DESKTOP_REPLY_FILE_TEXT_HEIGHT = 18;
|
||||
const DESKTOP_REPLY_FLOAT_MARGIN_WIDTH = 40;
|
||||
const DESKTOP_REPLY_FLOAT_MARGIN_HEIGHT = 8;
|
||||
const DESKTOP_REPLY_SIDE_ARROWS_WIDTH = 24;
|
||||
const DESKTOP_REPLY_CONTENT_PADDING_X = 80;
|
||||
const DESKTOP_FEED_CARD_BASE_HEIGHT = 56;
|
||||
const DESKTOP_FEED_CARD_HR_HEIGHT = 14;
|
||||
const DESKTOP_FEED_CARD_BOARD_LABEL_HEIGHT = 18;
|
||||
const DESKTOP_FEED_CARD_SUMMARY_HEIGHT = 25;
|
||||
const DESKTOP_FEED_CARD_BASE_CALIBRATION = -59;
|
||||
const DESKTOP_POST_MESSAGE_VERTICAL_PADDING = 26;
|
||||
const DESKTOP_PREVIEW_REPLY_TEXT_CALIBRATION = 6;
|
||||
const DESKTOP_PREVIEW_REPLY_MEDIA_CALIBRATION = -17;
|
||||
const MOBILE_REPLY_BASE_HEIGHT = 64;
|
||||
const MOBILE_REPLY_MEDIA_EXTRA_HEIGHT = 26;
|
||||
const MOBILE_REPLY_CONTENT_PADDING_X = 20;
|
||||
const MOBILE_FEED_CARD_BASE_HEIGHT = 84;
|
||||
const MOBILE_FEED_CARD_OUTER_GAP_HEIGHT = 60;
|
||||
const MOBILE_FEED_CARD_LINK_BAR_HEIGHT = 36;
|
||||
const MOBILE_FEED_CARD_PREVIEW_COUNT_CALIBRATIONS = [105, 85, 95, 120, 150, 180] as const;
|
||||
const DESKTOP_THREAD_REPLY_CALIBRATION = 0;
|
||||
const CATALOG_ROW_PADDING_TOP = 20;
|
||||
const CATALOG_CARD_MARGIN_Y = 4;
|
||||
const CATALOG_CARD_PADDING_TOP = 10;
|
||||
const CATALOG_CARD_PADDING_BOTTOM = 3;
|
||||
const CATALOG_CARD_MEDIA_GAP_HEIGHT = 5;
|
||||
const CATALOG_CARD_META_HEIGHT = 14;
|
||||
const CATALOG_CARD_MAX_HEIGHT = 320;
|
||||
const CATALOG_CARD_TEXT_PADDING_X = 30;
|
||||
const SMALL_CATALOG_CARD_WIDTH = 180;
|
||||
const LARGE_CATALOG_CARD_WIDTH = 270;
|
||||
const SMALL_CATALOG_MEDIA_SIZE = 150;
|
||||
const LARGE_CATALOG_MEDIA_SIZE = 250;
|
||||
const DEFAULT_DESKTOP_HEIGHT = 140;
|
||||
const DEFAULT_MOBILE_HEIGHT = 180;
|
||||
const DEFAULT_CATALOG_ROW_HEIGHT = 220;
|
||||
const MIN_ESTIMATE_HEIGHT = 72;
|
||||
const MIN_TEXT_WIDTH = 48;
|
||||
const MOBILE_BACKLINK_FULL_COST_COUNT = 16;
|
||||
const MOBILE_BACKLINK_TAIL_COST = 14;
|
||||
const COMMENT_TOO_LONG_NOTICE = 'Comment too long. Click here to view the full text.';
|
||||
const DEFAULT_REPLY_VIRTUALIZATION_MODE: ReplyVirtualizationMode = 'item-size';
|
||||
const REPLY_VIRTUALIZATION_MODES: ReplyVirtualizationMode[] = ['off', 'estimates', 'item-size'];
|
||||
|
||||
export const REPLY_HEIGHT_DATA_ATTRIBUTE = 'data-pretext-height';
|
||||
|
||||
type PreparedText = ReturnType<typeof prepare>;
|
||||
type PreparedTextWithSegments = ReturnType<typeof prepareWithSegments>;
|
||||
|
||||
interface ReplyTypographyMetrics {
|
||||
abbrFontSizePx: number;
|
||||
bodyFontFamily: string;
|
||||
bodyFontSizePx: number;
|
||||
mobileContentFontSizePx: number;
|
||||
}
|
||||
|
||||
interface MediaMetrics {
|
||||
blockHeight: number;
|
||||
floatHeight: number;
|
||||
floatWidth: number;
|
||||
}
|
||||
|
||||
interface ReplyBacklinkMaps {
|
||||
directRepliesByParentCid?: Map<string, Comment[]>;
|
||||
quotedByMap?: Map<string, Comment[]>;
|
||||
}
|
||||
|
||||
interface ReplyHeightEstimateOptions extends ReplyBacklinkMaps {
|
||||
context?: ReplyEstimateContext;
|
||||
isMobile: boolean;
|
||||
maxContentChars?: number;
|
||||
metrics: ReplyTypographyMetrics;
|
||||
replies: Comment[];
|
||||
windowWidth: number;
|
||||
}
|
||||
|
||||
interface FeedPostHeightEstimateOptions extends ReplyBacklinkMaps {
|
||||
isMobile: boolean;
|
||||
metrics: ReplyTypographyMetrics;
|
||||
post: Comment | undefined;
|
||||
previewReplies: Comment[];
|
||||
previewReplyEstimates?: number[];
|
||||
showBoardLabel?: boolean;
|
||||
showSummary?: boolean;
|
||||
windowWidth: number;
|
||||
}
|
||||
|
||||
interface CatalogPostHeightEstimateOptions {
|
||||
imageSize: CatalogImageSize;
|
||||
metrics: ReplyTypographyMetrics;
|
||||
post: Comment | undefined;
|
||||
showOPComment: boolean;
|
||||
}
|
||||
|
||||
interface CatalogSingleRowHeightEstimateOptions {
|
||||
imageSize: CatalogImageSize;
|
||||
metrics: ReplyTypographyMetrics;
|
||||
row: Comment[];
|
||||
showOPComment: boolean;
|
||||
}
|
||||
|
||||
interface CatalogRowHeightEstimateOptions {
|
||||
imageSize: CatalogImageSize;
|
||||
metrics: ReplyTypographyMetrics;
|
||||
rows: Comment[][];
|
||||
showOPComment: boolean;
|
||||
}
|
||||
|
||||
const preparedTextCache = new Map<string, PreparedText>();
|
||||
const preparedSegmentCache = new Map<string, PreparedTextWithSegments>();
|
||||
const paragraphHeightCache = new WeakMap<PreparedText, Map<string, number>>();
|
||||
const paragraphFloatHeightCache = new WeakMap<PreparedTextWithSegments, Map<string, number>>();
|
||||
const nestedPretextElementCache = new WeakMap<HTMLElement, HTMLElement | null>();
|
||||
|
||||
let pretextSupport: boolean | undefined;
|
||||
|
||||
const parseCssLengthToPx = (value: string | null | undefined, fallbackPx: number): number => {
|
||||
const trimmedValue = value?.trim();
|
||||
if (!trimmedValue) {
|
||||
return fallbackPx;
|
||||
}
|
||||
|
||||
if (trimmedValue.endsWith('px')) {
|
||||
const parsedValue = parseFloat(trimmedValue);
|
||||
return Number.isFinite(parsedValue) ? parsedValue : fallbackPx;
|
||||
}
|
||||
|
||||
if (trimmedValue.endsWith('pt')) {
|
||||
const parsedValue = parseFloat(trimmedValue);
|
||||
return Number.isFinite(parsedValue) ? parsedValue * PT_TO_PX : fallbackPx;
|
||||
}
|
||||
|
||||
const parsedValue = parseFloat(trimmedValue);
|
||||
return Number.isFinite(parsedValue) ? parsedValue : fallbackPx;
|
||||
};
|
||||
|
||||
const getPreparedText = (text: string, font: string, usePreWrap: boolean): PreparedText => {
|
||||
const cacheKey = `${font}\u0000${usePreWrap ? 'pre-wrap' : 'normal'}\u0000${text}`;
|
||||
const cachedPreparedText = preparedTextCache.get(cacheKey);
|
||||
if (cachedPreparedText) {
|
||||
return cachedPreparedText;
|
||||
}
|
||||
|
||||
const preparedText = prepare(text, font, usePreWrap ? { whiteSpace: 'pre-wrap' } : undefined);
|
||||
preparedTextCache.set(cacheKey, preparedText);
|
||||
return preparedText;
|
||||
};
|
||||
|
||||
const getPreparedSegmentText = (text: string, font: string, usePreWrap: boolean): PreparedTextWithSegments => {
|
||||
const cacheKey = `${font}\u0000${usePreWrap ? 'pre-wrap' : 'normal'}\u0000${text}`;
|
||||
const cachedPreparedText = preparedSegmentCache.get(cacheKey);
|
||||
if (cachedPreparedText) {
|
||||
return cachedPreparedText;
|
||||
}
|
||||
|
||||
const preparedText = prepareWithSegments(text, font, usePreWrap ? { whiteSpace: 'pre-wrap' } : undefined);
|
||||
preparedSegmentCache.set(cacheKey, preparedText);
|
||||
return preparedText;
|
||||
};
|
||||
|
||||
const getCachedParagraphHeight = (preparedText: PreparedText, cacheKey: string, compute: () => number): number => {
|
||||
const cachedByMeasurement = paragraphHeightCache.get(preparedText);
|
||||
const cachedHeight = cachedByMeasurement?.get(cacheKey);
|
||||
if (cachedHeight !== undefined) {
|
||||
return cachedHeight;
|
||||
}
|
||||
|
||||
const measuredHeight = compute();
|
||||
const nextCache = cachedByMeasurement ?? new Map<string, number>();
|
||||
nextCache.set(cacheKey, measuredHeight);
|
||||
if (!cachedByMeasurement) {
|
||||
paragraphHeightCache.set(preparedText, nextCache);
|
||||
}
|
||||
return measuredHeight;
|
||||
};
|
||||
|
||||
const getCachedParagraphFloatHeight = (preparedText: PreparedTextWithSegments, cacheKey: string, compute: () => number): number => {
|
||||
const cachedByMeasurement = paragraphFloatHeightCache.get(preparedText);
|
||||
const cachedHeight = cachedByMeasurement?.get(cacheKey);
|
||||
if (cachedHeight !== undefined) {
|
||||
return cachedHeight;
|
||||
}
|
||||
|
||||
const measuredHeight = compute();
|
||||
const nextCache = cachedByMeasurement ?? new Map<string, number>();
|
||||
nextCache.set(cacheKey, measuredHeight);
|
||||
if (!cachedByMeasurement) {
|
||||
paragraphFloatHeightCache.set(preparedText, nextCache);
|
||||
}
|
||||
return measuredHeight;
|
||||
};
|
||||
|
||||
const canUsePretext = (): boolean => {
|
||||
if (typeof pretextSupport === 'boolean') {
|
||||
return pretextSupport;
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined' || typeof Intl === 'undefined' || typeof Intl.Segmenter === 'undefined') {
|
||||
pretextSupport = false;
|
||||
return pretextSupport;
|
||||
}
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
pretextSupport = Boolean(canvas.getContext?.('2d'));
|
||||
} catch {
|
||||
pretextSupport = false;
|
||||
}
|
||||
|
||||
return pretextSupport;
|
||||
};
|
||||
|
||||
const getLineHeight = (fontSizePx: number): number => Math.ceil(fontSizePx * 1.35);
|
||||
|
||||
const getCatalogCardWidth = (imageSize: CatalogImageSize): number => (imageSize === 'Large' ? LARGE_CATALOG_CARD_WIDTH : SMALL_CATALOG_CARD_WIDTH);
|
||||
|
||||
const getCatalogMediaMaxSize = (imageSize: CatalogImageSize): number => (imageSize === 'Large' ? LARGE_CATALOG_MEDIA_SIZE : SMALL_CATALOG_MEDIA_SIZE);
|
||||
|
||||
const clampEstimateHeight = (value: number): number => Math.max(MIN_ESTIMATE_HEIGHT, Math.ceil(value));
|
||||
|
||||
const getMedianEstimate = (estimates: number[], fallback: number): number => {
|
||||
if (estimates.length === 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const sortedEstimates = [...estimates].sort((leftValue, rightValue) => leftValue - rightValue);
|
||||
return sortedEstimates[Math.floor(sortedEstimates.length / 2)];
|
||||
};
|
||||
|
||||
const isReplyHeightAuditEnabled = (): boolean =>
|
||||
import.meta.env.DEV && typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('pretextReplyAudit') === '1';
|
||||
|
||||
const getReplyHeightAuditStore = (): ReplyHeightAuditStore | null => {
|
||||
if (!isReplyHeightAuditEnabled() || typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const auditWindow = window as Window & { __PRETEXT_REPLY_AUDIT__?: ReplyHeightAuditStore };
|
||||
if (auditWindow.__PRETEXT_REPLY_AUDIT__) {
|
||||
return auditWindow.__PRETEXT_REPLY_AUDIT__;
|
||||
}
|
||||
|
||||
const samples = new Map<string, ReplyHeightAuditSample>();
|
||||
auditWindow.__PRETEXT_REPLY_AUDIT__ = {
|
||||
getSummary: () => {
|
||||
const allSamples = [...samples.values()];
|
||||
const totalAbsoluteError = allSamples.reduce((sum, sample) => sum + Math.abs(sample.diff), 0);
|
||||
return {
|
||||
maxAbsoluteError: allSamples.length === 0 ? 0 : Math.max(...allSamples.map((sample) => Math.abs(sample.diff))),
|
||||
meanAbsoluteError: allSamples.length === 0 ? 0 : Math.round((totalAbsoluteError / allSamples.length) * 10) / 10,
|
||||
sampleCount: allSamples.length,
|
||||
};
|
||||
},
|
||||
record: (sample) => {
|
||||
samples.set(sample.cid, sample);
|
||||
},
|
||||
reset: () => {
|
||||
samples.clear();
|
||||
},
|
||||
samples,
|
||||
};
|
||||
|
||||
return auditWindow.__PRETEXT_REPLY_AUDIT__;
|
||||
};
|
||||
|
||||
const isReplyVirtualizationMode = (value: string | null | undefined): value is ReplyVirtualizationMode =>
|
||||
value !== null && value !== undefined && REPLY_VIRTUALIZATION_MODES.includes(value as ReplyVirtualizationMode);
|
||||
|
||||
const resolveSearchParams = (searchParams: URLSearchParams | string | null | undefined): URLSearchParams | undefined => {
|
||||
if (searchParams instanceof URLSearchParams) {
|
||||
if ([...searchParams.keys()].length > 0) {
|
||||
return searchParams;
|
||||
}
|
||||
} else if (typeof searchParams === 'string') {
|
||||
const trimmedSearch = searchParams.trim();
|
||||
if (trimmedSearch && trimmedSearch !== '?') {
|
||||
return new URLSearchParams(trimmedSearch.startsWith('?') ? trimmedSearch : `?${trimmedSearch}`);
|
||||
}
|
||||
} else if (searchParams) {
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hashQueryIndex = window.location.hash.indexOf('?');
|
||||
if (hashQueryIndex === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new URLSearchParams(window.location.hash.slice(hashQueryIndex));
|
||||
};
|
||||
|
||||
const getMediaBoxDimensions = (comment: Comment | undefined, maxThumbnailSize: number): { height: number; width: number } | null => {
|
||||
if (!comment?.link) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaInfo = getCommentMediaInfo(comment.link, comment.thumbnailUrl, comment.linkWidth, comment.linkHeight);
|
||||
if (!mediaInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let mediaWidth = maxThumbnailSize;
|
||||
let mediaHeight = maxThumbnailSize;
|
||||
|
||||
if (comment.spoiler) {
|
||||
mediaWidth = 100;
|
||||
mediaHeight = 100;
|
||||
} else if (mediaInfo.type === 'audio') {
|
||||
mediaHeight = 54;
|
||||
} else if (mediaInfo.linkWidth && mediaInfo.linkHeight) {
|
||||
const scale = Math.min(1, maxThumbnailSize / Math.max(mediaInfo.linkWidth, mediaInfo.linkHeight));
|
||||
mediaWidth = Math.max(MIN_TEXT_WIDTH, Math.round(mediaInfo.linkWidth * scale));
|
||||
mediaHeight = Math.max(40, Math.round(mediaInfo.linkHeight * scale));
|
||||
}
|
||||
|
||||
return { height: mediaHeight, width: mediaWidth };
|
||||
};
|
||||
|
||||
const getReplyMediaMetrics = (reply: Comment | undefined): MediaMetrics | null => {
|
||||
const mediaBox = getMediaBoxDimensions(reply, 125);
|
||||
if (!mediaBox) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
blockHeight: mediaBox.height + MOBILE_REPLY_MEDIA_EXTRA_HEIGHT,
|
||||
floatHeight: mediaBox.height + DESKTOP_REPLY_FLOAT_MARGIN_HEIGHT,
|
||||
floatWidth: mediaBox.width + DESKTOP_REPLY_FLOAT_MARGIN_WIDTH,
|
||||
};
|
||||
};
|
||||
|
||||
const getFeedPostMediaMetrics = (post: Comment | undefined, isMobile: boolean): MediaMetrics | null => {
|
||||
const mediaBox = getMediaBoxDimensions(post, isMobile ? 125 : 250);
|
||||
if (!mediaBox) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
blockHeight: mediaBox.height + MOBILE_REPLY_MEDIA_EXTRA_HEIGHT,
|
||||
floatHeight: mediaBox.height + DESKTOP_REPLY_FLOAT_MARGIN_HEIGHT,
|
||||
floatWidth: mediaBox.width + DESKTOP_REPLY_FLOAT_MARGIN_WIDTH,
|
||||
};
|
||||
};
|
||||
|
||||
const getCatalogPostMediaHeight = (post: Comment | undefined, imageSize: CatalogImageSize): number => {
|
||||
if (!post?.link) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const mediaInfo = getCommentMediaInfo(post.link, post.thumbnailUrl, post.linkWidth, post.linkHeight);
|
||||
if (!getHasThumbnail(mediaInfo, post.link)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const mediaBox = getMediaBoxDimensions(post, getCatalogMediaMaxSize(imageSize));
|
||||
return mediaBox ? mediaBox.height + CATALOG_CARD_MEDIA_GAP_HEIGHT : 0;
|
||||
};
|
||||
|
||||
const normalizeRenderedCommentText = (rawContent: string): string =>
|
||||
removeMarkdown(rawContent)
|
||||
.replace(/\n \n/g, '\n\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/\s{2,}/g, ' ').trimEnd())
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
const getVisibleCommentBodyText = (comment: Comment | undefined, maxContentChars: number): string => {
|
||||
if (!comment) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const purged = comment.commentModeration?.purged;
|
||||
const deleted = comment.deleted;
|
||||
const removed = comment.removed;
|
||||
const reason = comment.reason?.trim();
|
||||
const rawContent = comment.content || '';
|
||||
const content = normalizeRenderedCommentText(rawContent.slice(0, maxContentChars));
|
||||
|
||||
if (purged) {
|
||||
return 'This post was purged';
|
||||
}
|
||||
|
||||
if (removed) {
|
||||
return reason ? `This post was removed. Reason: ${reason}` : 'This post was removed.';
|
||||
}
|
||||
|
||||
if (deleted) {
|
||||
return reason ? `User deleted this post. Reason: ${reason}` : 'User deleted this post';
|
||||
}
|
||||
|
||||
if (rawContent.length > maxContentChars && content) {
|
||||
return `${content}\n\n${COMMENT_TOO_LONG_NOTICE}`;
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
const getVisibleCommentText = (comment: Comment | undefined, maxContentChars: number): string => {
|
||||
if (!comment) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const title = comment.title?.trim();
|
||||
const content = getVisibleCommentBodyText(comment, maxContentChars);
|
||||
|
||||
if (title && content) {
|
||||
return `${title}: ${content}`;
|
||||
}
|
||||
|
||||
return title || content;
|
||||
};
|
||||
|
||||
const getVisibleReplyText = (reply: Comment | undefined, maxContentChars: number = 2000): string => getVisibleCommentText(reply, maxContentChars);
|
||||
|
||||
const getVisibleCatalogText = (post: Comment | undefined, showOPComment: boolean, hasThumbnail: boolean): string => {
|
||||
if (!post || !(showOPComment || !hasThumbnail)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return getVisibleCommentText(post, 1600);
|
||||
};
|
||||
|
||||
const getMobileBacklinkCount = (reply: Comment, backlinkMaps: ReplyBacklinkMaps): number => {
|
||||
const backlinkCount = getRenderableMobileBacklinks({
|
||||
cid: reply.cid,
|
||||
parentCid: reply.parentCid,
|
||||
directRepliesByParentCid: backlinkMaps.directRepliesByParentCid,
|
||||
quotedByMap: backlinkMaps.quotedByMap,
|
||||
});
|
||||
|
||||
return backlinkCount.directReplyBacklinks.length + backlinkCount.opBacklinks.length + backlinkCount.quotedReplyBacklinks.length;
|
||||
};
|
||||
|
||||
const getMobileBacklinksHeight = (totalBacklinkCount: number, metrics: ReplyTypographyMetrics): number => {
|
||||
const backlinkLineHeight = Math.ceil(metrics.abbrFontSizePx * 2);
|
||||
const fullCostCount = Math.min(totalBacklinkCount, MOBILE_BACKLINK_FULL_COST_COUNT);
|
||||
const tailCount = Math.max(totalBacklinkCount - MOBILE_BACKLINK_FULL_COST_COUNT, 0);
|
||||
|
||||
return fullCostCount * backlinkLineHeight + tailCount * MOBILE_BACKLINK_TAIL_COST;
|
||||
};
|
||||
|
||||
const getMobileThreadReplyCalibration = (hasMedia: boolean, totalBacklinkCount: number): number => {
|
||||
let calibration = hasMedia ? -200 : -145;
|
||||
|
||||
if (totalBacklinkCount >= 4) {
|
||||
calibration -= hasMedia ? 80 : 140;
|
||||
}
|
||||
|
||||
return calibration;
|
||||
};
|
||||
|
||||
const measureParagraphHeight = (text: string, font: string, width: number, lineHeight: number): number => {
|
||||
if (!text.trim()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const safeWidth = Math.max(MIN_TEXT_WIDTH, Math.floor(width));
|
||||
const usePreWrap = text.includes('\n');
|
||||
const preparedText = getPreparedText(text, font, usePreWrap);
|
||||
return getCachedParagraphHeight(preparedText, `${safeWidth}:${lineHeight}`, () => layout(preparedText, safeWidth, lineHeight).height);
|
||||
};
|
||||
|
||||
const measureParagraphWithFloatHeight = (text: string, font: string, width: number, floatWidth: number, floatHeight: number, lineHeight: number): number => {
|
||||
if (!text.trim()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const safeWidth = Math.max(MIN_TEXT_WIDTH, Math.floor(width));
|
||||
const safeFloatWidth = Math.max(0, Math.floor(floatWidth));
|
||||
const safeFloatHeight = Math.max(0, Math.floor(floatHeight));
|
||||
const usePreWrap = text.includes('\n');
|
||||
const preparedText = getPreparedSegmentText(text, font, usePreWrap);
|
||||
return getCachedParagraphFloatHeight(preparedText, `${safeWidth}:${safeFloatWidth}:${safeFloatHeight}:${lineHeight}`, () => {
|
||||
let cursor = { graphemeIndex: 0, segmentIndex: 0 };
|
||||
let currentHeight = 0;
|
||||
|
||||
while (true) {
|
||||
const currentLineWidth = currentHeight < safeFloatHeight ? Math.max(MIN_TEXT_WIDTH, safeWidth - safeFloatWidth) : safeWidth;
|
||||
const line = layoutNextLine(preparedText, cursor, currentLineWidth);
|
||||
if (line === null) {
|
||||
break;
|
||||
}
|
||||
cursor = line.end;
|
||||
currentHeight += lineHeight;
|
||||
}
|
||||
|
||||
return currentHeight;
|
||||
});
|
||||
};
|
||||
|
||||
const estimateDesktopReplyHeight = (
|
||||
reply: Comment,
|
||||
metrics: ReplyTypographyMetrics,
|
||||
windowWidth: number,
|
||||
backlinkMaps: ReplyBacklinkMaps,
|
||||
maxContentChars: number,
|
||||
context: ReplyEstimateContext,
|
||||
): number => {
|
||||
const fontSizePx = metrics.bodyFontSizePx;
|
||||
const font = `${fontSizePx}px ${metrics.bodyFontFamily}`;
|
||||
const lineHeight = getLineHeight(fontSizePx);
|
||||
const replyText = getVisibleReplyText(reply, maxContentChars);
|
||||
const mediaMetrics = getReplyMediaMetrics(reply);
|
||||
const contentWidth = Math.max(MIN_TEXT_WIDTH, windowWidth - DESKTOP_REPLY_SIDE_ARROWS_WIDTH - DESKTOP_REPLY_CONTENT_PADDING_X);
|
||||
const textHeight = mediaMetrics
|
||||
? measureParagraphWithFloatHeight(replyText, font, contentWidth, mediaMetrics.floatWidth, mediaMetrics.floatHeight, lineHeight)
|
||||
: measureParagraphHeight(replyText, font, contentWidth, lineHeight);
|
||||
const mediaHeight = mediaMetrics ? mediaMetrics.floatHeight + DESKTOP_REPLY_FILE_TEXT_HEIGHT : 0;
|
||||
const previewCalibration = mediaMetrics ? DESKTOP_PREVIEW_REPLY_MEDIA_CALIBRATION : DESKTOP_PREVIEW_REPLY_TEXT_CALIBRATION;
|
||||
|
||||
return clampEstimateHeight(
|
||||
DESKTOP_REPLY_BASE_HEIGHT + Math.max(textHeight, mediaHeight) + (context === 'thread' ? DESKTOP_THREAD_REPLY_CALIBRATION : previewCalibration),
|
||||
);
|
||||
};
|
||||
|
||||
const estimateMobileReplyHeight = (
|
||||
reply: Comment,
|
||||
metrics: ReplyTypographyMetrics,
|
||||
windowWidth: number,
|
||||
backlinkMaps: ReplyBacklinkMaps,
|
||||
maxContentChars: number,
|
||||
context: ReplyEstimateContext,
|
||||
): number => {
|
||||
const fontSizePx = metrics.mobileContentFontSizePx;
|
||||
const font = `${fontSizePx}px ${metrics.bodyFontFamily}`;
|
||||
const lineHeight = getLineHeight(fontSizePx);
|
||||
const replyText = getVisibleReplyText(reply, maxContentChars);
|
||||
const mediaMetrics = getReplyMediaMetrics(reply);
|
||||
const contentWidth = Math.max(MIN_TEXT_WIDTH, windowWidth - MOBILE_REPLY_CONTENT_PADDING_X);
|
||||
const textHeight = measureParagraphHeight(replyText, font, contentWidth, lineHeight);
|
||||
const totalBacklinkCount = getMobileBacklinkCount(reply, backlinkMaps);
|
||||
const backlinksHeight = getMobileBacklinksHeight(totalBacklinkCount, metrics);
|
||||
const threadCalibration = context === 'thread' ? getMobileThreadReplyCalibration(Boolean(mediaMetrics), totalBacklinkCount) : 0;
|
||||
|
||||
return clampEstimateHeight(MOBILE_REPLY_BASE_HEIGHT + (mediaMetrics?.blockHeight || 0) + textHeight + backlinksHeight + threadCalibration);
|
||||
};
|
||||
|
||||
export const getTypicalReplyHeight = (estimates: number[], isMobile: boolean): number => {
|
||||
return getMedianEstimate(estimates, isMobile ? DEFAULT_MOBILE_HEIGHT : DEFAULT_DESKTOP_HEIGHT);
|
||||
};
|
||||
|
||||
export const getReplyHeightEstimates = ({
|
||||
context = 'preview',
|
||||
directRepliesByParentCid,
|
||||
isMobile,
|
||||
maxContentChars,
|
||||
metrics,
|
||||
quotedByMap,
|
||||
replies,
|
||||
windowWidth,
|
||||
}: ReplyHeightEstimateOptions): number[] => {
|
||||
if (!canUsePretext() || replies.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const backlinkMaps = { directRepliesByParentCid, quotedByMap };
|
||||
const effectiveMaxContentChars = maxContentChars ?? (isMobile ? 1000 : 2000);
|
||||
|
||||
return replies.map((reply) =>
|
||||
isMobile
|
||||
? estimateMobileReplyHeight(reply, metrics, windowWidth, backlinkMaps, effectiveMaxContentChars, context)
|
||||
: estimateDesktopReplyHeight(reply, metrics, windowWidth, backlinkMaps, effectiveMaxContentChars, context),
|
||||
);
|
||||
};
|
||||
|
||||
const getDesktopOpBacklinkLabels = (post: Comment | undefined, quotedByMap?: Map<string, Comment[]>): string[] => {
|
||||
const cid = post?.cid;
|
||||
if (!cid) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (quotedByMap?.get(cid) || [])
|
||||
.filter((candidateReply) => candidateReply?.cid && typeof candidateReply.number === 'number' && !(candidateReply.deleted || candidateReply.removed))
|
||||
.map((candidateReply) => `>>${candidateReply.number}`);
|
||||
};
|
||||
|
||||
const getMobileFeedCardCalibration = (previewReplyCount: number): number => {
|
||||
const calibrationIndex = Math.max(0, Math.min(previewReplyCount, MOBILE_FEED_CARD_PREVIEW_COUNT_CALIBRATIONS.length - 1));
|
||||
return MOBILE_FEED_CARD_PREVIEW_COUNT_CALIBRATIONS[calibrationIndex];
|
||||
};
|
||||
|
||||
export const getFeedPostHeightEstimate = ({
|
||||
directRepliesByParentCid,
|
||||
isMobile,
|
||||
metrics,
|
||||
post,
|
||||
previewReplies,
|
||||
previewReplyEstimates,
|
||||
quotedByMap,
|
||||
showBoardLabel = false,
|
||||
showSummary = false,
|
||||
windowWidth,
|
||||
}: FeedPostHeightEstimateOptions): number => {
|
||||
if (!canUsePretext() || !post) {
|
||||
return isMobile ? DEFAULT_MOBILE_HEIGHT : DEFAULT_DESKTOP_HEIGHT;
|
||||
}
|
||||
|
||||
const previewHeights =
|
||||
previewReplyEstimates && previewReplyEstimates.length === previewReplies.length
|
||||
? previewReplyEstimates
|
||||
: getReplyHeightEstimates({
|
||||
directRepliesByParentCid,
|
||||
isMobile,
|
||||
maxContentChars: 1000,
|
||||
metrics,
|
||||
quotedByMap,
|
||||
replies: previewReplies,
|
||||
windowWidth,
|
||||
});
|
||||
|
||||
const previewRepliesHeight = previewHeights.reduce((sum, value) => sum + value, 0);
|
||||
const previewReplyCount = previewReplies.length;
|
||||
|
||||
if (isMobile) {
|
||||
const fontSizePx = metrics.mobileContentFontSizePx;
|
||||
const font = `${fontSizePx}px ${metrics.bodyFontFamily}`;
|
||||
const lineHeight = getLineHeight(fontSizePx);
|
||||
const postText = getVisibleCommentText(post, 1000);
|
||||
const mediaMetrics = getFeedPostMediaMetrics(post, true);
|
||||
const contentWidth = Math.max(MIN_TEXT_WIDTH, windowWidth - MOBILE_REPLY_CONTENT_PADDING_X);
|
||||
const textHeight = measureParagraphHeight(postText, font, contentWidth, lineHeight);
|
||||
const backlinksHeight = getMobileBacklinksHeight(getMobileBacklinkCount(post, { directRepliesByParentCid, quotedByMap }), metrics);
|
||||
const rawEstimate =
|
||||
MOBILE_FEED_CARD_OUTER_GAP_HEIGHT +
|
||||
MOBILE_FEED_CARD_BASE_HEIGHT +
|
||||
MOBILE_FEED_CARD_LINK_BAR_HEIGHT +
|
||||
(mediaMetrics?.blockHeight || 0) +
|
||||
textHeight +
|
||||
backlinksHeight +
|
||||
previewRepliesHeight;
|
||||
// Mobile board cards render preview replies more compactly than the thread-reply estimator assumes.
|
||||
const mobileCalibration = getMobileFeedCardCalibration(previewReplyCount);
|
||||
|
||||
return clampEstimateHeight(rawEstimate - mobileCalibration);
|
||||
}
|
||||
|
||||
const fontSizePx = metrics.bodyFontSizePx;
|
||||
const font = `${fontSizePx}px ${metrics.bodyFontFamily}`;
|
||||
const lineHeight = getLineHeight(fontSizePx);
|
||||
const postText = getVisibleCommentBodyText(post, 1000);
|
||||
const mediaMetrics = getFeedPostMediaMetrics(post, false);
|
||||
const contentWidth = Math.max(MIN_TEXT_WIDTH, windowWidth - DESKTOP_REPLY_CONTENT_PADDING_X);
|
||||
const textHeight =
|
||||
(mediaMetrics
|
||||
? measureParagraphWithFloatHeight(postText, font, contentWidth, mediaMetrics.floatWidth, mediaMetrics.floatHeight, lineHeight)
|
||||
: measureParagraphHeight(postText, font, contentWidth, lineHeight)) + (postText.trim().length > 0 ? DESKTOP_POST_MESSAGE_VERTICAL_PADDING : 0);
|
||||
const mediaHeight = mediaMetrics ? mediaMetrics.floatHeight + DESKTOP_REPLY_FILE_TEXT_HEIGHT : 0;
|
||||
const opBacklinks = getDesktopOpBacklinkLabels(post, quotedByMap);
|
||||
const backlinkFontSizePx = metrics.abbrFontSizePx;
|
||||
const backlinkLineHeight = getLineHeight(backlinkFontSizePx);
|
||||
const backlinksHeight = Math.max(
|
||||
0,
|
||||
measureParagraphHeight(opBacklinks.join(' '), `${backlinkFontSizePx}px ${metrics.bodyFontFamily}`, contentWidth, backlinkLineHeight) - backlinkLineHeight,
|
||||
);
|
||||
const boardLabelHeight = showBoardLabel && !post.link && !post.parentCid && post.communityAddress ? DESKTOP_FEED_CARD_BOARD_LABEL_HEIGHT : 0;
|
||||
|
||||
const rawEstimate =
|
||||
DESKTOP_FEED_CARD_HR_HEIGHT +
|
||||
DESKTOP_FEED_CARD_BASE_HEIGHT +
|
||||
Math.max(textHeight, mediaHeight) +
|
||||
backlinksHeight +
|
||||
boardLabelHeight +
|
||||
(showSummary ? DESKTOP_FEED_CARD_SUMMARY_HEIGHT : 0) +
|
||||
previewRepliesHeight;
|
||||
|
||||
return clampEstimateHeight(rawEstimate + DESKTOP_FEED_CARD_BASE_CALIBRATION);
|
||||
};
|
||||
|
||||
export const getCatalogPostHeightEstimate = ({ imageSize, metrics, post, showOPComment }: CatalogPostHeightEstimateOptions): number => {
|
||||
if (!canUsePretext() || !post) {
|
||||
return DEFAULT_CATALOG_ROW_HEIGHT - CATALOG_ROW_PADDING_TOP;
|
||||
}
|
||||
|
||||
const cardWidth = getCatalogCardWidth(imageSize);
|
||||
const fontSizePx = metrics.bodyFontSizePx;
|
||||
const font = `${fontSizePx}px ${metrics.bodyFontFamily}`;
|
||||
const lineHeight = getLineHeight(fontSizePx);
|
||||
const mediaInfo = post.link ? getCommentMediaInfo(post.link, post.thumbnailUrl, post.linkWidth, post.linkHeight) : undefined;
|
||||
const hasThumbnail = getHasThumbnail(mediaInfo, post.link);
|
||||
const visibleCatalogText = getVisibleCatalogText(post, showOPComment, hasThumbnail);
|
||||
const textHeight =
|
||||
visibleCatalogText.trim().length > 0
|
||||
? measureParagraphHeight(visibleCatalogText, font, Math.max(MIN_TEXT_WIDTH, cardWidth - CATALOG_CARD_TEXT_PADDING_X), lineHeight)
|
||||
: 0;
|
||||
const mediaHeight = getCatalogPostMediaHeight(post, imageSize);
|
||||
const rawHeight = Math.min(
|
||||
CATALOG_CARD_MAX_HEIGHT,
|
||||
CATALOG_CARD_MARGIN_Y + CATALOG_CARD_PADDING_TOP + CATALOG_CARD_PADDING_BOTTOM + mediaHeight + CATALOG_CARD_META_HEIGHT + textHeight,
|
||||
);
|
||||
|
||||
return Math.max(MIN_ESTIMATE_HEIGHT, Math.ceil(rawHeight));
|
||||
};
|
||||
|
||||
export const getCatalogRowHeightEstimate = ({ imageSize, metrics, row, showOPComment }: CatalogSingleRowHeightEstimateOptions): number => {
|
||||
const tallestCardHeight = row.reduce((maxHeight, post) => {
|
||||
return Math.max(maxHeight, getCatalogPostHeightEstimate({ imageSize, metrics, post, showOPComment }));
|
||||
}, DEFAULT_CATALOG_ROW_HEIGHT - CATALOG_ROW_PADDING_TOP);
|
||||
|
||||
return clampEstimateHeight(CATALOG_ROW_PADDING_TOP + tallestCardHeight);
|
||||
};
|
||||
|
||||
export const getCatalogRowHeightEstimates = ({ imageSize, metrics, rows, showOPComment }: CatalogRowHeightEstimateOptions): number[] => {
|
||||
if (!canUsePretext() || rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rows.map((row) => getCatalogRowHeightEstimate({ imageSize, metrics, row, showOPComment }));
|
||||
};
|
||||
|
||||
export const getTypicalCatalogRowHeight = (estimates: number[], imageSize: CatalogImageSize): number =>
|
||||
getMedianEstimate(estimates, imageSize === 'Large' ? 340 : DEFAULT_CATALOG_ROW_HEIGHT);
|
||||
|
||||
export const resolveReplyVirtualizationMode = (
|
||||
searchParams: URLSearchParams | string | null | undefined,
|
||||
fallback: ReplyVirtualizationMode = DEFAULT_REPLY_VIRTUALIZATION_MODE,
|
||||
): ReplyVirtualizationMode => {
|
||||
const params = resolveSearchParams(searchParams);
|
||||
const requestedMode = params?.get('pretextReplies');
|
||||
return isReplyVirtualizationMode(requestedMode) ? requestedMode : fallback;
|
||||
};
|
||||
|
||||
export const resolveFeedVirtualizationMode = (
|
||||
searchParams: URLSearchParams | string | null | undefined,
|
||||
fallback: ReplyVirtualizationMode = 'off',
|
||||
): ReplyVirtualizationMode => {
|
||||
const params = resolveSearchParams(searchParams);
|
||||
const requestedMode = params?.get('pretextFeed');
|
||||
return isReplyVirtualizationMode(requestedMode) ? requestedMode : fallback;
|
||||
};
|
||||
|
||||
export const resolveCatalogVirtualizationMode = (
|
||||
searchParams: URLSearchParams | string | null | undefined,
|
||||
fallback: ReplyVirtualizationMode = 'off',
|
||||
): ReplyVirtualizationMode => {
|
||||
const params = resolveSearchParams(searchParams);
|
||||
const requestedMode = params?.get('pretextCatalog');
|
||||
return isReplyVirtualizationMode(requestedMode) ? requestedMode : fallback;
|
||||
};
|
||||
|
||||
export const getPretextItemSizeFromElement = (element: HTMLElement, field: 'offsetHeight' | 'offsetWidth'): number => {
|
||||
if (field === 'offsetWidth') {
|
||||
return element.offsetWidth;
|
||||
}
|
||||
|
||||
const ownEstimatedHeight = Number.parseFloat(element.dataset.pretextHeight || '');
|
||||
if (Number.isFinite(ownEstimatedHeight) && ownEstimatedHeight > 0) {
|
||||
return ownEstimatedHeight;
|
||||
}
|
||||
|
||||
let nestedElement = nestedPretextElementCache.get(element);
|
||||
if (nestedElement && !element.contains(nestedElement)) {
|
||||
nestedPretextElementCache.delete(element);
|
||||
nestedElement = undefined;
|
||||
}
|
||||
if (nestedElement === undefined) {
|
||||
nestedElement = element.querySelector<HTMLElement>(`[${REPLY_HEIGHT_DATA_ATTRIBUTE}]`) ?? null;
|
||||
nestedPretextElementCache.set(element, nestedElement);
|
||||
}
|
||||
const nestedEstimatedHeight = Number.parseFloat(nestedElement?.dataset.pretextHeight || '');
|
||||
return Number.isFinite(nestedEstimatedHeight) && nestedEstimatedHeight > 0 ? nestedEstimatedHeight : element.offsetHeight;
|
||||
};
|
||||
|
||||
export const getReplyItemSizeFromElement = (element: HTMLElement, field: 'offsetHeight' | 'offsetWidth'): number => getPretextItemSizeFromElement(element, field);
|
||||
|
||||
export const reportReplyHeightAuditSample = (element: HTMLElement | null, estimate: number | undefined, cid: string | undefined) => {
|
||||
if (!element || !cid || !Number.isFinite(estimate)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auditStore = getReplyHeightAuditStore();
|
||||
if (!auditStore) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
if (!document.contains(element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actualHeight = element.offsetHeight;
|
||||
const numericEstimate = Math.round(estimate as number);
|
||||
auditStore.record({
|
||||
actual: actualHeight,
|
||||
cid,
|
||||
diff: actualHeight - numericEstimate,
|
||||
estimate: numericEstimate,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const readReplyTypographyMetrics = (): ReplyTypographyMetrics => {
|
||||
if (typeof document === 'undefined') {
|
||||
return {
|
||||
abbrFontSizePx: 13,
|
||||
bodyFontFamily: 'Arial, Helvetica, sans-serif',
|
||||
bodyFontSizePx: 13,
|
||||
mobileContentFontSizePx: 15,
|
||||
};
|
||||
}
|
||||
|
||||
const bodyStyles = window.getComputedStyle(document.body);
|
||||
const bodyFontFamily = bodyStyles.getPropertyValue('--body-font-family').trim() || 'Arial, Helvetica, sans-serif';
|
||||
const bodyFontSizePx = parseCssLengthToPx(bodyStyles.getPropertyValue('--body-font-size'), 13);
|
||||
|
||||
return {
|
||||
abbrFontSizePx: parseCssLengthToPx(bodyStyles.getPropertyValue('--post-mobile-abbr-font-size'), 13),
|
||||
bodyFontFamily,
|
||||
bodyFontSizePx,
|
||||
mobileContentFontSizePx: parseCssLengthToPx(bodyStyles.getPropertyValue('--post-mobile-content-font-size'), 15),
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user