Skip to content

Commit

Permalink
Merge pull request #398 from StampyAI/connect-more
Browse files Browse the repository at this point in the history
Connect more
  • Loading branch information
mruwnik authored Feb 13, 2024
2 parents 6f6a6b9 + 08fada1 commit 91b01f6
Show file tree
Hide file tree
Showing 19 changed files with 457 additions and 315 deletions.
164 changes: 164 additions & 0 deletions app/components/Article/Contents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import {useRef, useEffect} from 'react'
import type {Glossary, PageId, GlossaryEntry} from '~/server-utils/stampy'

const footnoteHTML = (el: HTMLDivElement, e: HTMLAnchorElement): string | null => {
const id = e.getAttribute('href') || ''
const footnote = el.querySelector(id)

if (!footnote) return null

const elem = document.createElement('div')
elem.innerHTML = footnote.innerHTML

// remove the back link, as it's useless in the popup
if (elem?.firstElementChild?.lastChild)
elem.firstElementChild.removeChild(elem.firstElementChild.lastChild)

return elem.innerHTML
}

const addPopup = (e: HTMLElement, id: string, contents: string): HTMLElement => {
const preexisting = document.getElementById(id)
if (preexisting) return preexisting

const popup = document.createElement('div')
popup.className = 'link-popup bordered'
popup.innerHTML = contents
popup.id = id

e.insertAdjacentElement('afterend', popup)

e.addEventListener('mouseover', () => popup.classList.add('shown'))
e.addEventListener('mouseout', () => popup.classList.remove('shown'))
popup.addEventListener('mouseover', () => popup.classList.add('shown'))
popup.addEventListener('mouseout', () => popup.classList.remove('shown'))

return popup
}

/*
* Recursively go through the child nodes of the provided node, and replace all text nodes
* with the result of calling `textProcessor(textNode)`
*/
const updateTextNodes = (el: Node, textProcessor: (node: Node) => Node) => {
Array.from(el.childNodes).forEach((child) => updateTextNodes(child, textProcessor))

if (el.nodeType == Node.TEXT_NODE && el.textContent) {
const node = textProcessor(el)
el?.parentNode?.replaceChild(node, el)
}
}

/*
* Replace all known glossary words in the given `textNode` with:
* - a span to mark it as a glossary item
* - an on hover popup with a short explaination of the glossary item
* - use each glossary item only once
*/
const glossaryInjecter = (pageid: string, glossary: Glossary) => {
const unusedGlossaryEntries = Object.values(glossary)
.filter((item) => item.pageid != pageid)
.map(({term}) => term)
.sort((a, b) => b.length - a.length)
.map(
(term) =>
[
new RegExp(`(^|[^\\w-])(${term})($|[^\\w-])`, 'i'),
'$1<span class="glossary-entry">$2</span>$3',
] as const
)

return (html: string) => {
return unusedGlossaryEntries.reduce((html, [match, replacement], index) => {
if (html.match(match)) {
unusedGlossaryEntries.splice(index, 1)
return html.replace(match, replacement)
}
return html
}, html)
}
}

const insertGlossary = (pageid: string, glossary: Glossary) => {
const injecter = glossaryInjecter(pageid, glossary)

return (textNode: Node) => {
const html = (textNode.textContent || '').replace('’', "'").replace('—', '-')
// The glossary items have to be injected somewhere, so this does it by manually wrapping any known
// definitions with spans. This is done from the longest to the shortest to make sure that sub strings
// of longer definitions don't override them.
const updated = injecter(html)
if (updated == html) {
return textNode
}

const range = document.createRange()
const fragment = range.createContextualFragment(updated)

/*
* If the provided element is a word in the glossary, return its data.
* This is used to filter out invalid glossary elements
*/
const glossaryEntry = (e: Element) => {
const entry = e.textContent && glossary[e?.textContent.toLowerCase().trim()]
if (
// If the contents of this item aren't simply a glossary item word, then
// something has gone wrong and the glossary-entry should be removed
!entry ||
// It's possible for a glossary entry to contain another one (e.g. 'goodness' and 'good'), so
// if this entry is a subset of a bigger entry, remove it.
e.parentElement?.classList.contains('glossary-entry') ||
// Remove entries that point to the current question
pageid == (entry as GlossaryEntry)?.pageid
) {
return null
}
return entry
}

/*
* Add a popup to all real glossary words in this text node
*/
fragment.querySelectorAll('.glossary-entry').forEach((e) => {
const entry = glossaryEntry(e)
entry &&
addPopup(
e as HTMLSpanElement,
`glossary-${entry.term}`,
`<div>${entry.contents}</div>` +
(entry.pageid ? `<br><a href="/${entry.pageid}">See more...</a>` : '')
)
})

return fragment
}
}

const Contents = ({pageid, html, glossary}: {pageid: PageId; html: string; glossary: Glossary}) => {
const elementRef = useRef<HTMLDivElement>(null)

useEffect(() => {
const el = elementRef.current
if (!el) return

updateTextNodes(el, insertGlossary(pageid, glossary))

// In theory this could be extended to all links
el.querySelectorAll('.footnote-ref > a').forEach((e) => {
const footnote = footnoteHTML(el, e as HTMLAnchorElement)
const footnoteId = (e.getAttribute('href') || '').replace('#', '')
if (footnote) addPopup(e as HTMLAnchorElement, `footnote-${footnoteId}`, footnote)
})
}, [html, glossary, pageid])

return (
<div
className="contents"
dangerouslySetInnerHTML={{
__html: html,
}}
ref={elementRef}
/>
)
}
export default Contents
41 changes: 32 additions & 9 deletions app/components/Article/KeepGoing/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,59 @@ import ListTable from '~/components/Table'
import {ArrowRight} from '~/components/icons-generated'
import useToC from '~/hooks/useToC'
import type {TOCItem} from '~/routes/questions.toc'
import type {Question} from '~/server-utils/stampy'
import type {Question, RelatedQuestion} from '~/server-utils/stampy'
import './keepGoing.css'

const NextArticle = ({category, next}: {category?: string; next?: TOCItem}) =>
const nonContinueSections = ['8TJV']

type NextArticleProps = {
section?: TOCItem
next?: TOCItem
first?: boolean
}
const NextArticle = ({section, next, first}: NextArticleProps) =>
next && (
<>
<h2>Keep going! &#128073;</h2>
<span>Continue with the next article in {category}</span>
<span>
{first ? 'Start' : 'Continue'} with the {first ? 'first' : 'next'} article in{' '}
{section?.category}
</span>
<div className="keepGoing-next">
<span className="keepGoing-next-title">{next.title}</span>
<Button action={`/${next.pageid}`} className="primary-alt">
Next
{first ? 'Start' : 'Next'}
<ArrowRight />
</Button>
</div>
</>
)

export const KeepGoing = ({pageid, relatedQuestions}: Question) => {
const {findSection, getNext} = useToC()
const {category} = findSection(pageid) || {}
const {findSection, getArticle, getNext} = useToC()
const section = findSection(pageid)
const next = getNext(pageid)
const hasRelated = relatedQuestions && relatedQuestions.length > 0
const skipNext = nonContinueSections.includes(section?.pageid || '')

const formatRelated = (related: RelatedQuestion) => {
const relatedSection = findSection(related.pageid)
const subtitle =
relatedSection && relatedSection.pageid !== section?.pageid ? relatedSection.title : undefined
return {...related, subtitle, hasIcon: true}
}

return (
<div className="keepGoing">
<NextArticle category={category} next={next} />
{!skipNext && (
<NextArticle section={section} next={next} first={section?.pageid === pageid} />
)}

{next && hasRelated && <span>Or jump to a related question</span>}
{hasRelated && <ListTable elements={relatedQuestions.map((i) => ({...i, hasIcon: true}))} />}
{next && hasRelated && !skipNext && <span>Or jump to a related question</span>}
{hasRelated && !skipNext && (
<ListTable elements={relatedQuestions.slice(0, 3).map(formatRelated)} />
)}
{skipNext && <ListTable elements={getArticle(pageid)?.children?.map(formatRelated) || []} />}
</div>
)
}
Expand Down
Loading

0 comments on commit 91b01f6

Please sign in to comment.