Skip to content

Commit

Permalink
Merge branch 'new-expandable' into beta
Browse files Browse the repository at this point in the history
  • Loading branch information
davidmz committed Jul 21, 2024
2 parents d695537 + 3e0476f commit eef6199
Show file tree
Hide file tree
Showing 17 changed files with 349 additions and 387 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Experimental
### Changed
- The 'Expandable' component was rewritten using the modern browser APIs. It now
properly handles the content updates and does not require the content to be
fully inline.

## [1.134.2] - 2024-07-20
### Fixed
Expand Down
8 changes: 2 additions & 6 deletions src/components/drafts-page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { faComment, faEdit } from '@fortawesome/free-regular-svg-icons';
import { useSelector } from 'react-redux';
import { deleteDraft, getAllDrafts, subscribeToDraftChanges } from '../services/drafts';
import { pluralForm } from '../utils';
import { postReadmoreConfig } from '../utils/readmore-config';
import { READMORE_STYLE_COMPACT } from '../utils/frontend-preferences-options';
import ErrorBoundary from './error-boundary';
import TimeDisplay from './time-display';
Expand All @@ -13,7 +12,7 @@ import { Icon } from './fontawesome-icons';
import { useBool } from './hooks/bool';
import { UserPicture } from './user-picture';
import { faCommentPlus } from './fontawesome-custom-icons';
import Expandable from './expandable';
import { Expandable } from './expandable';

export default function DraftsPage() {
const allDrafts = useSyncExternalStore(subscribeToDraftChanges, getAllDrafts);
Expand Down Expand Up @@ -118,10 +117,7 @@ function DraftEntry({ draftKey, data }) {
</div>
<div className="single-event__content">
{data.text ? (
<Expandable
expanded={readMoreStyle === READMORE_STYLE_COMPACT}
config={postReadmoreConfig}
>
<Expandable expanded={readMoreStyle === READMORE_STYLE_COMPACT}>
<PieceOfText text={data.text} readMoreStyle={readMoreStyle} />
</Expandable>
) : (
Expand Down
6 changes: 6 additions & 0 deletions src/components/expandable-constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Pixel areas (before the UI scale applied) of the folded text rectangles for
// posts and comments. The Expandable component reduces the height of the text
// to approximately match this area.

export const postFoldedArea = 600 * 130;
export const commentFoldedArea = 500 * 110;
225 changes: 113 additions & 112 deletions src/components/expandable.jsx
Original file line number Diff line number Diff line change
@@ -1,129 +1,130 @@
import { Component, createRef } from 'react';
import classnames from 'classnames';
import { useLayoutEffect, useRef, useState } from 'react';
import cn from 'classnames';
import { faChevronDown } from '@fortawesome/free-solid-svg-icons';
import { Icon } from './fontawesome-icons';

import ErrorBoundary from './error-boundary';
import { useEvent } from 'react-use-event-hook';
import { useSelector } from 'react-redux';
import { ButtonLink } from './button-link';
import { Icon } from './fontawesome-icons';
import { postFoldedArea } from './expandable-constants';
import style from './expandable.module.scss';

const DEFAULT_MAX_LINES = 8;
const DEFAULT_ABOVE_FOLD_LINES = 5;
const DEFAULT_KEY = 'default';
export function Expandable({
children,
expanded: givenExpanded = false,
tail = null,
panelClass = null,
// See presets in ./expandable-constants.js
foldedArea = postFoldedArea,
}) {
const uiScale = useSelector((state) => state.uiScale ?? 100) / 100;
const scaledFoldedArea = foldedArea * uiScale * uiScale;
// Don't fold content that is smaller than this
const scaledMaxUnfoldedArea = scaledFoldedArea * 1.5;

export default class Expandable extends Component {
root = createRef(null);
const content = useRef(null);
// Do content need to be expandable?
const [needExpand, setNeedExpand] = useState(false);
// A round approximation before the first measurement
const [maxHeight, setMaxHeight] = useState(`${foldedArea / 600}px`);

constructor(props) {
super(props);
this.state = {
expanded: false,
userExpanded: false,
maxHeight: 5000,
};
this.userExpand = this.userExpand.bind(this);
this.rewrap = this.rewrap.bind(this);
}
const [expanded, setExpanded] = useState(givenExpanded);
const expand = useEvent(() => setExpanded(true));

componentDidMount() {
this.rewrap();
window.addEventListener('resize', this.rewrap);
}
useLayoutEffect(() => {
if (expanded) {
return;
}

componentWillUnmount() {
window.removeEventListener('resize', this.rewrap);
}
return observeResizeOf(content.current, ({ target, contentRect }) => {
const { width, height } = contentRect;
if (width * height < scaledMaxUnfoldedArea) {
setNeedExpand(false);
setMaxHeight(null);
} else {
let targetHeight = scaledFoldedArea / width;
targetHeight = align(target, targetHeight);
setNeedExpand(true);
setMaxHeight(`${targetHeight}px`);
}
});
}, [expanded, scaledFoldedArea, scaledMaxUnfoldedArea]);

render() {
const expanded = this.state.expanded || this.state.userExpanded || this.props.expanded;
const cn = classnames(['expandable', { expanded, folded: !expanded }]);
const style = { maxHeight: expanded ? '' : `${this.state.maxHeight}px` };
return (
<div className={cn} style={style} ref={this.root}>
<ErrorBoundary>
{this.props.children}
{!expanded && (
<div className="expand-panel">
<div className="expand-button">
<ButtonLink tag="i" onClick={this.userExpand} aria-hidden>
<Icon icon={faChevronDown} className="expand-icon" /> Read more
</ButtonLink>{' '}
{this.props.bonusInfo}
</div>
</div>
)}
</ErrorBoundary>
return (
<>
<div className={style.contentWrapper} style={{ maxHeight: expanded ? null : maxHeight }}>
<div ref={content}>{children}</div>
</div>
);
}

userExpand() {
this.setState({ userExpanded: true });
}

rewrap() {
const { maxLines, aboveFoldLines } = chooseLineCounts(this.props.config, window.innerWidth);
const node = this.root.current;
const lines = gatherContentLines(node, '.Linkify', '.p-break');
const shouldExpand = lines.length <= (maxLines || DEFAULT_MAX_LINES);
const [readMorePanel] = node.querySelectorAll('.expand-panel');
const readMorePanelHeight = readMorePanel ? readMorePanel.clientHeight : 0;
const foldedLines = aboveFoldLines || maxLines || DEFAULT_ABOVE_FOLD_LINES;
const maxHeight = shouldExpand ? '5000' : lines[foldedLines - 1].bottom + readMorePanelHeight;
this.setState({ expanded: shouldExpand, maxHeight });
}
{needExpand && !expanded && (
<div className={cn('expand-button', panelClass)}>
<ButtonLink className={style.button} tag="i" onClick={expand} aria-hidden>
<Icon icon={faChevronDown} className={style.icon} /> Read more
</ButtonLink>{' '}
{tail}
</div>
)}
</>
);
}

function gatherContentLines(node, contentSelector, breakSelector) {
const [content] = node.querySelectorAll(contentSelector || '.wrapper');
if (!content) {
return [];
}
const breaks = [...content.querySelectorAll(breakSelector || '.text')];
const rects = [...content.getClientRects()];
const breakRects = breaks.map((br) => br.getBoundingClientRect());
const breakTops = new Set(breakRects.map((br) => br.top));

const lines = rects.filter((rect) => {
const isRectABreak = breakTops.has(rect.top);
return !isRectABreak;
});
/**
* Align the given offset to the bottom of the closest text string
*
* @param {Element} rootElement
* @param {number} targetOffset
* @returns {number}
*/
function align(rootElement, targetOffset) {
const { top } = rootElement.getBoundingClientRect();

const lineRects = lines.reduce((res, { top, bottom, left, right }) => {
if (res.length === 0) {
res.push({ top, bottom, left, right });
} else if (res[res.length - 1].top !== top) {
res.push({ top, bottom, left, right });
} else {
const last = res[res.length - 1];
res[res.length - 1].bottom = last.bottom > bottom ? last.bottom : bottom;
res[res.length - 1].left = last.left < left ? last.left : left;
res[res.length - 1].right = last.right > right ? last.right : right;
// Iterate over all the text nodes
const nodeIterator = document.createNodeIterator(rootElement, NodeFilter.SHOW_TEXT);
const range = document.createRange();
let node;
let prev = null;
let current = null;
mainLoop: while ((node = nodeIterator.nextNode())) {
range.selectNode(node);
// In every text node we check all the rects
for (const { bottom } of range.getClientRects()) {
prev = current;
current = bottom - top;
if (current >= targetOffset) {
break mainLoop;
}
}
return res;
}, []);
}
if (!prev || !current) {
return targetOffset;
}
return Math.abs(current - targetOffset) < Math.abs(prev - targetOffset) ? current : prev;
}

const nodeClientRect = node.getBoundingClientRect();
let resizeObserver = null;
const resizeHandlers = new Map();

return lineRects.map(({ top, bottom, left, right }) => {
return {
top: top - nodeClientRect.top,
bottom: bottom - nodeClientRect.top,
left: left - nodeClientRect.left,
right: nodeClientRect.right - right,
};
});
}
/**
* Subscribe to resize of the given element
*
* @param {Element} element
* @param {(entry: ResizeObserverEntry) => void} callback
* @returns {() => void} unsubscribe function
*/
function observeResizeOf(element, callback) {
if (process.env.NODE_ENV === 'test') {
return;
}
if (!resizeObserver) {
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
resizeHandlers.get(entry.target)?.(entry);
}
});
}

function chooseLineCounts(config = {}, windowWidth) {
const breakpoints = Object.keys(config)
.filter((key) => key !== DEFAULT_KEY)
.map(Number)
.sort((a, b) => a - b);
const breakpointToUse = breakpoints.find((b) => b >= windowWidth) || DEFAULT_KEY;
return (
config[breakpointToUse] || {
maxLines: DEFAULT_MAX_LINES,
aboveFoldLines: DEFAULT_ABOVE_FOLD_LINES,
}
);
resizeHandlers.set(element, callback);
resizeObserver.observe(element);
return () => {
resizeObserver.unobserve(element);
resizeHandlers.delete(element);
};
}
17 changes: 17 additions & 0 deletions src/components/expandable.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
@import '../styles/helvetica/dark-vars.scss';

.contentWrapper {
overflow: hidden;
}

.button {
color: #555599;

:global(.dark-theme) & {
color: $link-color-dim;
}
}

.icon {
opacity: 0.6;
}
8 changes: 2 additions & 6 deletions src/components/notifications.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { uniq } from 'lodash-es';
import { faBell } from '@fortawesome/free-regular-svg-icons';
import { getCommentsByIds, getPostsByIds } from '../redux/action-creators';
import { READMORE_STYLE_COMPACT } from '../utils/frontend-preferences-options';
import { postReadmoreConfig } from '../utils/readmore-config';
import { Throbber } from './throbber';
import TimeDisplay from './time-display';
import PaginatedView from './paginated-view';
Expand All @@ -16,9 +15,9 @@ import UserName from './user-name';
import { SignInLink } from './sign-in-link';
import { UserPicture } from './user-picture';
import PieceOfText from './piece-of-text';
import Expandable from './expandable';
import { Icon } from './fontawesome-icons';
import { useBool } from './hooks/bool';
import { Expandable } from './expandable';

const getAuthorName = ({ postAuthor, createdUser, group }) => {
if (group && group.username) {
Expand Down Expand Up @@ -499,10 +498,7 @@ function Notification({ event }) {
</div>
<div className="single-event__content">
{content ? (
<Expandable
expanded={readMoreStyle === READMORE_STYLE_COMPACT}
config={postReadmoreConfig}
>
<Expandable expanded={readMoreStyle === READMORE_STYLE_COMPACT}>
<PieceOfText text={content} readMoreStyle={readMoreStyle} />
</Expandable>
) : contentSource ? (
Expand Down
9 changes: 5 additions & 4 deletions src/components/post/post-comment-preview.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ import {
READMORE_STYLE_COMPACT,
} from '../../utils/frontend-preferences-options';

import { commentReadmoreConfig } from '../../utils/readmore-config';
import Expandable from '../expandable';
import PieceOfText from '../piece-of-text';
import { Separated } from '../separated';
import TimeDisplay from '../time-display';
import UserName from '../user-name';

import { commentFoldedArea } from '../expandable-constants';
import { Expandable } from '../expandable';
import styles from './post-comment-preview.module.scss';
import { CommentProvider } from './post-comment-provider';

Expand Down Expand Up @@ -158,11 +158,12 @@ export function PostCommentPreview({
) : (
<Expandable
expanded={frontPreferences.readMoreStyle === READMORE_STYLE_COMPACT}
config={commentReadmoreConfig}
bonusInfo={commentTail}
foldedArea={commentFoldedArea}
tail={commentTail}
>
<PieceOfText
text={commentBody}
readMoreStyle={frontPreferences.readMoreStyle}
arrowHover={arrowHoverHandlers}
arrowClick={onArrowClick}
/>
Expand Down
Loading

0 comments on commit eef6199

Please sign in to comment.