-
-
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 beta
- Loading branch information
Showing
17 changed files
with
349 additions
and
387 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 |
---|---|---|
@@ -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; |
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,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); | ||
}; | ||
} |
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,17 @@ | ||
@import '../styles/helvetica/dark-vars.scss'; | ||
|
||
.contentWrapper { | ||
overflow: hidden; | ||
} | ||
|
||
.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.