Skip to content

Commit e5de5e3

Browse files
committed
Add footnote functionality to rich content: implement marker pairing, smooth scrolling, tooltip previews, and localized labels. Include unit tests for footnote parsing and indexing logic.
1 parent bfb57ad commit e5de5e3

7 files changed

Lines changed: 447 additions & 27 deletions

File tree

common/messages/de.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
{
2+
"bio.footnote.back_to": "Zurück zu Fußnote {label} im Text",
3+
"bio.footnote.go_to": "Fußnote {label}",
24
"404.default_message": "Diese Seite konnte nicht gefunden werden.",
35
"404.help_text": "Falls Sie dies nicht erwartet haben, holen Sie sich ",
46
"404.title": "404: Hoppla!",

common/messages/fr.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
{
2+
"bio.footnote.back_to": "Retour à la note {label} dans le texte",
3+
"bio.footnote.go_to": "Note de bas de page {label}",
24
"404.default_message": "Je ne trouve pas cette page.",
35
"404.help_text": "Si vous ne vous attendiez pas à cela, essayez de recharger la page dans quelques secondes ou obtenez de l'",
46
"404.title": "404 : Oups !",

web/components/bio/profile-bio-block.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export function BioBlock(props: {
3636
<Content
3737
className={clsx('w-full', BIO_PARAGRAPH_SPACING)}
3838
content={profile.bio as JSONContent}
39+
footnotes
3940
/>
4041
</Col>
4142
)}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import clsx from 'clsx'
2+
import {MouseEvent} from 'react'
3+
import {Tooltip} from 'web/components/widgets/tooltip'
4+
import {useT} from 'web/lib/locale'
5+
6+
import {Footnote, footnoteDefId, footnoteRefId} from './footnotes'
7+
8+
const MARKER_CLASS =
9+
'text-primary-700 hover:text-primary-600 cursor-pointer align-super text-[0.75em] font-medium no-underline hover:underline scroll-mt-24'
10+
11+
/** Bring `id` into view and flash it, without the hard jump a raw hash navigation would cause. */
12+
const scrollTo = (id: string) => (e: MouseEvent) => {
13+
e.preventDefault()
14+
const el = typeof document === 'undefined' ? null : document.getElementById(id)
15+
if (!el) return
16+
el.scrollIntoView({behavior: 'smooth', block: 'center'})
17+
// Keep the hash shareable, but replaceState so the smooth scroll isn't overridden.
18+
history.replaceState(null, '', `#${id}`)
19+
el.classList.add('bg-primary-100', 'transition-colors', 'duration-500', 'rounded')
20+
setTimeout(() => el.classList.remove('bg-primary-100'), 1800)
21+
}
22+
23+
/** An in-text footnote marker: hovering previews the footnote, clicking scrolls down to it. */
24+
export function FootnoteRef(props: {footnote: Footnote; text: string; anchor: boolean}) {
25+
const {footnote, text, anchor} = props
26+
const t = useT()
27+
28+
return (
29+
<Tooltip
30+
text={
31+
<span className="block max-h-64 overflow-y-auto whitespace-pre-line text-left">
32+
{footnote.text}
33+
</span>
34+
}
35+
hasSafePolygon
36+
noTap
37+
className="inline"
38+
>
39+
<a
40+
id={anchor ? footnoteRefId(footnote.label) : undefined}
41+
href={`#${footnoteDefId(footnote.label)}`}
42+
onClick={scrollTo(footnoteDefId(footnote.label))}
43+
className={MARKER_CLASS}
44+
aria-label={t('bio.footnote.go_to', 'Footnote {label}', {label: footnote.label})}
45+
>
46+
{text}
47+
</a>
48+
</Tooltip>
49+
)
50+
}
51+
52+
/** The marker at the head of a footnote definition — scrolls back up to where it was referenced. */
53+
export function FootnoteBackLink(props: {label: string; text: string}) {
54+
const {label, text} = props
55+
const t = useT()
56+
57+
return (
58+
<a
59+
href={`#${footnoteRefId(label)}`}
60+
onClick={scrollTo(footnoteRefId(label))}
61+
className={clsx(MARKER_CLASS, 'mr-1')}
62+
aria-label={t('bio.footnote.back_to', 'Back to footnote {label} in the text', {label})}
63+
>
64+
{text}
65+
</a>
66+
)
67+
}

web/components/editor/footnotes.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import {JSONContent} from '@tiptap/core'
2+
3+
/**
4+
* Google Docs "date me docs" are usually written with comments, and pasting one in brings the comments
5+
* along as a block of footnotes at the very bottom: each in-text marker (`[a]`, `[b]`, ...) is a link to
6+
* `#cmnt_refN`, and the comment text sits in a trailing paragraph that starts with the same marker.
7+
*
8+
* Neither end of that pairing survives the paste: tiptap's link mark keeps `href` but drops `id`, so every
9+
* `#cmnt_refN` anchor points at a target that no longer exists. Rather than try to preserve Google's ids,
10+
* we re-derive the pairing from the markers themselves — which also fixes the footnotes whose marker lost
11+
* its link on the way in.
12+
*/
13+
14+
/** A marker as it stands alone in the text: `[a]`, `[12]`. Docs uses letters; other exports use numbers. */
15+
const MARKER = /^\[([a-z]{1,3}|\d{1,3})\]$/i
16+
17+
/** The same marker opening a trailing paragraph, followed by the footnote's text. */
18+
const DEFINITION = /^\[([a-z]{1,3}|\d{1,3})\]\s*/i
19+
20+
/** Google Docs' own anchor names, kept as aliases so old `#cmnt_ref2` links still land somewhere. */
21+
const GOOGLE_ANCHOR = /^#(cmnt[a-z_]*\d+)$/i
22+
23+
/**
24+
* How many unlabelled paragraphs may sit between two definitions before we assume we've walked out of the
25+
* footnote block and back into the bio. A long footnote can run to several paragraphs, but not many.
26+
*/
27+
const MAX_CONTINUATION_RUN = 8
28+
29+
export type Footnote = {
30+
/** lowercased marker, e.g. `b` */
31+
label: string
32+
/** the comment body, marker stripped; paragraphs joined by blank lines */
33+
text: string
34+
/** Google Docs anchor names found on this definition's marker, e.g. `cmnt_ref2` */
35+
aliases: string[]
36+
}
37+
38+
export type FootnoteIndex = {
39+
byLabel: Record<string, Footnote>
40+
/** top-level node index -> label of the definition starting there */
41+
definitionAt: Map<number, string>
42+
}
43+
44+
export const footnoteDefId = (label: string) => `fn-${label}`
45+
export const footnoteRefId = (label: string) => `fnref-${label}`
46+
47+
/** The label this text refers to, if it is a bare marker for a known footnote. */
48+
export const footnoteLabelOf = (text: string, index: FootnoteIndex): string | undefined => {
49+
const label = MARKER.exec(text.trim())?.[1]?.toLowerCase()
50+
return label && index.byLabel[label] ? label : undefined
51+
}
52+
53+
/**
54+
* The marker at the head of `label`'s definition, if this text starts with it. The paste sometimes merges
55+
* the marker into the same text run as the footnote body, so it isn't always a text node of its own.
56+
*/
57+
export const footnoteDefinitionPrefix = (text: string, label: string): string | undefined =>
58+
text.toLowerCase().startsWith(`[${label}]`) ? text.slice(0, label.length + 2) : undefined
59+
60+
/** True for the now-dangling `#cmnt…` anchors Google Docs leaves behind. */
61+
export const isGoogleCommentAnchor = (href: string | undefined | null) =>
62+
!!href && GOOGLE_ANCHOR.test(href.slice(href.indexOf('#')))
63+
64+
const textOf = (node: JSONContent): string =>
65+
node.type === 'text' ? (node.text ?? '') : (node.content ?? []).map(textOf).join('')
66+
67+
const forEachTextNode = (node: JSONContent, fn: (node: JSONContent) => void) => {
68+
if (node.type === 'text') fn(node)
69+
else (node.content ?? []).forEach((child) => forEachTextNode(child, fn))
70+
}
71+
72+
const aliasesOf = (node: JSONContent, label: string): string[] => {
73+
const aliases: string[] = []
74+
forEachTextNode(node, (text) => {
75+
if ((text.text ?? '').trim().toLowerCase() !== `[${label}]`) return
76+
for (const mark of text.marks ?? []) {
77+
const href: string | undefined = mark.attrs?.href
78+
const alias = href && GOOGLE_ANCHOR.exec(href.slice(href.indexOf('#')))?.[1]
79+
if (alias) aliases.push(alias)
80+
}
81+
})
82+
return aliases
83+
}
84+
85+
/**
86+
* Pair up the footnote markers in a document with the definitions at the bottom of it.
87+
*
88+
* Returns undefined unless the document really does end in a footnote block whose markers are used in the
89+
* body — an ordinary bio that happens to contain `[a]` somewhere shouldn't grow tooltips.
90+
*/
91+
export function buildFootnoteIndex(doc: JSONContent | undefined): FootnoteIndex | undefined {
92+
const nodes = doc?.content
93+
if (!nodes?.length) return undefined
94+
95+
const byLabel: Record<string, Footnote> = {}
96+
const definitionAt = new Map<number, string>()
97+
let continuations: string[] = []
98+
99+
// Walk up from the end of the document: continuation paragraphs are seen before the definition they
100+
// belong to, so they queue up until a marker turns up.
101+
for (let i = nodes.length - 1; i >= 0; i--) {
102+
const node = nodes[i]
103+
if (node.type !== 'paragraph') break
104+
105+
const text = textOf(node).trim()
106+
const match = DEFINITION.exec(text)
107+
if (!match) {
108+
if (!text) continue // blank spacer paragraph
109+
if (continuations.length >= MAX_CONTINUATION_RUN) break
110+
continuations.unshift(text)
111+
continue
112+
}
113+
114+
const label = match[1].toLowerCase()
115+
if (byLabel[label]) break // a repeated marker means we've walked back into ordinary prose
116+
117+
byLabel[label] = {
118+
label,
119+
text: [text.slice(match[0].length), ...continuations].filter(Boolean).join('\n\n'),
120+
aliases: aliasesOf(node, label),
121+
}
122+
definitionAt.set(i, label)
123+
continuations = []
124+
}
125+
126+
if (!definitionAt.size) return undefined
127+
128+
// Keep only the footnotes the body actually refers to.
129+
const bodyEnd = Math.min(...definitionAt.keys())
130+
const referenced = new Set<string>()
131+
for (let i = 0; i < bodyEnd; i++) {
132+
forEachTextNode(nodes[i], (text) => {
133+
const label = MARKER.exec((text.text ?? '').trim())?.[1]?.toLowerCase()
134+
if (label) referenced.add(label)
135+
})
136+
}
137+
138+
for (const [i, label] of [...definitionAt]) {
139+
if (referenced.has(label)) continue
140+
delete byLabel[label]
141+
definitionAt.delete(i)
142+
}
143+
144+
return definitionAt.size ? {byLabel, definitionAt} : undefined
145+
}

0 commit comments

Comments
 (0)