-
-
Notifications
You must be signed in to change notification settings - Fork 30
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch 'new-expandable' into stable
- Loading branch information
Showing
16 changed files
with
378 additions
and
393 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,129 +1,168 @@ | ||
import { Component, createRef } from 'react'; | ||
import classnames from 'classnames'; | ||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'; | ||
import cn from 'classnames'; | ||
import { faChevronDown } from '@fortawesome/free-solid-svg-icons'; | ||
import { useEvent } from 'react-use-event-hook'; | ||
import { useSelector } from 'react-redux'; | ||
import { ButtonLink } from './button-link'; | ||
import { Icon } from './fontawesome-icons'; | ||
import style from './expandable.module.scss'; | ||
|
||
import ErrorBoundary from './error-boundary'; | ||
import { ButtonLink } from './button-link'; | ||
// 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. | ||
|
||
const DEFAULT_MAX_LINES = 8; | ||
const DEFAULT_ABOVE_FOLD_LINES = 5; | ||
const DEFAULT_KEY = 'default'; | ||
|
||
export default class Expandable extends Component { | ||
root = createRef(null); | ||
|
||
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 foldedAreas = { | ||
post: 600 * 130, | ||
comment: 500 * 110, | ||
postAnonymous: 860 * 130, // No sidebar, so we need more pixels | ||
commentAnonymous: 700 * 110, | ||
}; | ||
|
||
componentDidMount() { | ||
this.rewrap(); | ||
window.addEventListener('resize', this.rewrap); | ||
} | ||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator?.userAgent ?? ''); | ||
|
||
componentWillUnmount() { | ||
window.removeEventListener('resize', this.rewrap); | ||
} | ||
export function Expandable({ | ||
children, | ||
expanded: givenExpanded = false, | ||
tail = null, | ||
panelClass = null, | ||
contentType = 'post', // or 'comment' | ||
}) { | ||
const authenticated = useSelector((state) => state.authenticated); | ||
const uiScale = useSelector((state) => state.uiScale ?? 100) / 100; | ||
|
||
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> | ||
</div> | ||
); | ||
if (contentType !== 'comment' && contentType !== 'post') { | ||
throw new Error('Unsupported content type'); | ||
} | ||
|
||
userExpand() { | ||
this.setState({ userExpanded: true }); | ||
} | ||
const foldedArea = foldedAreas[contentType + (authenticated ? '' : 'Anonymous')]; | ||
const scaledFoldedArea = foldedArea * uiScale * uiScale; | ||
// Don't fold content that is smaller than this | ||
const scaledMaxUnfoldedArea = scaledFoldedArea * 1.5; | ||
|
||
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 }); | ||
} | ||
} | ||
const content = useRef(null); | ||
// Null means content doesn't need to be expandable | ||
const [maxHeight, setMaxHeight] = useState(null); | ||
|
||
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; | ||
}); | ||
const [expandedByUser, setExpandedByUser] = useState(false); | ||
const expand = useEvent(() => setExpandedByUser(true)); | ||
|
||
const expanded = expandedByUser || givenExpanded; | ||
const clipped = maxHeight !== null && !expanded; | ||
|
||
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 }); | ||
// Update the maxHeight when the content dimensions changes | ||
const update = useEvent(({ width, height }) => { | ||
if (width * height < scaledMaxUnfoldedArea) { | ||
setMaxHeight(null); | ||
} 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; | ||
let targetHeight = scaledFoldedArea / width; | ||
targetHeight = align(content.current, targetHeight); | ||
if (isSafari) { | ||
// Safari has extremely large string heights, so we need to slightly | ||
// reduce the clipping. | ||
targetHeight -= 4; | ||
} | ||
setMaxHeight(`${targetHeight}px`); | ||
} | ||
return res; | ||
}, []); | ||
|
||
const nodeClientRect = node.getBoundingClientRect(); | ||
|
||
return lineRects.map(({ top, bottom, left, right }) => { | ||
return { | ||
top: top - nodeClientRect.top, | ||
bottom: bottom - nodeClientRect.top, | ||
left: left - nodeClientRect.left, | ||
right: nodeClientRect.right - right, | ||
}; | ||
}); | ||
} | ||
|
||
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, | ||
// We use the layout effect just once, to set the initial height without | ||
// flickering. | ||
useLayoutEffect( | ||
() => { | ||
if (!expanded) { | ||
update(content.current.getBoundingClientRect()); | ||
} | ||
}, | ||
// eslint-disable-next-line react-hooks/exhaustive-deps | ||
[], | ||
); | ||
|
||
// Update the maxHeight when the content resizes | ||
useEffect(() => { | ||
if (!expanded) { | ||
return observeResizeOf(content.current, ({ contentRect }) => update(contentRect)); | ||
} | ||
}, [expanded, update]); | ||
|
||
return ( | ||
<> | ||
<div | ||
className={clipped && style.clippedContent} | ||
style={{ maxHeight: expanded ? null : maxHeight }} | ||
> | ||
<div ref={content}>{children}</div> | ||
</div> | ||
{clipped && ( | ||
<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> | ||
)} | ||
</> | ||
); | ||
} | ||
|
||
/** | ||
* 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(); | ||
|
||
// 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; | ||
} | ||
} | ||
} | ||
if (!prev || !current) { | ||
return targetOffset; | ||
} | ||
return Math.abs(current - targetOffset) < Math.abs(prev - targetOffset) ? current : prev; | ||
} | ||
|
||
let resizeObserver = null; | ||
const resizeHandlers = new Map(); | ||
|
||
/** | ||
* 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); | ||
} | ||
}); | ||
} | ||
|
||
resizeHandlers.set(element, callback); | ||
resizeObserver.observe(element); | ||
return () => { | ||
resizeObserver.unobserve(element); | ||
resizeHandlers.delete(element); | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
@import '../styles/helvetica/dark-vars.scss'; | ||
|
||
.clippedContent { | ||
overflow-y: hidden; /* fallback for old Safari */ | ||
overflow-y: clip; | ||
} | ||
|
||
.button { | ||
color: #555599; | ||
|
||
:global(.dark-theme) & { | ||
color: $link-color-dim; | ||
} | ||
} | ||
|
||
.icon { | ||
opacity: 0.6; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.