Skip to content

Commit

Permalink
Add glossary tooltip feature (#681)
Browse files Browse the repository at this point in the history
* Create index.tsx

Swizzle component to add glossary tooltip function.

* Add glossary tooltip function

* Add styles for glossary tooltip

* Add `scripts/generate-glossary-json.js` to build step

* Add `/static/glossary.json`

Ignore `/static/glossary.json` since we only need it when building the site in production.

* Create GlossaryInjector.tsx

* Create GlossaryTooltip.tsx

* Create generate-glossary-json.js

* Remove unnecessary ignore

* Put glossary tooltip styles in ABC order

* Refactor code to support tooltips in en-us and ja-jp

* Enable i18n

* Remove unnecessary code comments

* Fix rendering of glossary tooltip

On desktop: Increase padding around definitions.
On mobile: Add styles to keep the tooltip within view on narrow screens.

* Update naming for other tooltip style

To avoid confusion with the glossary tooltip, this commit clarifies the naming for the tooltip for the question mark icon in the header that contains version and edition tags.
  • Loading branch information
josh-wong authored Jan 16, 2025
1 parent 754e07d commit d960f1b
Show file tree
Hide file tree
Showing 6 changed files with 307 additions and 4 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "docusaurus build",
"build": "docusaurus build && node scripts/generate-glossary-json.js",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
Expand Down
31 changes: 31 additions & 0 deletions scripts/generate-glossary-json.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
const fs = require('fs');
const path = require('path');

const glossaries = [
{ src: '../docs/glossary.mdx', output: '../build/docs/glossary.json' },
{ src: '../i18n/versioned_docs/ja-jp/docusaurus-plugin-content-docs/current/glossary.mdx', output: '../build/ja-jp/glossary.json' }
];

const generateGlossaryJson = (glossaryFilePath, outputJsonPath) => {
const glossaryContent = fs.readFileSync(glossaryFilePath, 'utf-8');
const glossaryLines = glossaryContent.split('\n');

let glossary = {};
let currentTerm = '';

glossaryLines.forEach((line) => {
if (line.startsWith('## ')) {
currentTerm = line.replace('## ', '').trim();
} else if (line.startsWith('# ')) {
currentTerm = ''; // Reset the term for heading 1 lines.
} else if (line.trim() !== '' && currentTerm !== '') {
glossary[currentTerm] = line.trim();
}
});

fs.writeFileSync(outputJsonPath, JSON.stringify(glossary, null, 2));
console.log(`${outputJsonPath} generated successfully.`);
};

// Generate both glossaries.
glossaries.forEach(({ src, output }) => generateGlossaryJson(path.join(__dirname, src), path.join(__dirname, output)));
153 changes: 153 additions & 0 deletions src/components/GlossaryInjector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
import GlossaryTooltip from './GlossaryTooltip';

interface GlossaryInjectorProps {
children: React.ReactNode;
}

const GlossaryInjector: React.FC<GlossaryInjectorProps> = ({ children }) => {
const [glossary, setGlossary] = useState<{ [key: string]: string }>({});

useEffect(() => {
const url = window.location.pathname;
let glossaryPath = '/docs/glossary.json'; // Use the English version as the default glossary.

if (process.env.NODE_ENV === 'production') { // The glossary tooltip works only in production environments.
glossaryPath = url.startsWith('/ja-jp/docs') ? '/ja-jp/glossary.json' : '/docs/glossary.json';
} else {
glossaryPath = url.startsWith('/ja-jp/docs') ? '/ja-jp/glossary.json' : '/docs/glossary.json';
}

fetch(glossaryPath)
.then((res) => {
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
return res.json();
})
.then(setGlossary)
.catch((err) => console.error('Failed to load glossary:', err));
}, []);

useEffect(() => {
if (Object.keys(glossary).length === 0) return;

// Sort terms in descending order by length to prioritize multi-word terms.
const terms = Object.keys(glossary).sort((a, b) => b.length - a.length);
const processedTerms = new Set<string>(); // Set to track processed terms.

const wrapTermsInTooltips = (node: HTMLElement) => {
const textNodes = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, null, false);
let currentNode: Node | null;

const modifications: { originalNode: Node; newNodes: Node[] }[] = [];

while ((currentNode = textNodes.nextNode())) {
const parentElement = currentNode.parentElement;

// Check if the parent element is a tab title.
const isTabTitle = parentElement && parentElement.closest('.tabs__item'); // Adjust the selector as necessary.

// Check if the parent element is a code block.
const isCodeBlock = parentElement && parentElement.closest('.prism-code'); // Adjust the selector as necessary.

// Check if the parent element is a Card.
const isCard = parentElement && parentElement.closest('.card__body'); // Adjust the selector as necessary.

// Check if the parent element is a Mermaid diagram.
const isMermaidDiagram = parentElement && parentElement.closest('.docusaurus-mermaid-container'); // Adjust the selector as necessary.

// Only wrap terms in tooltips if the parent is within the target div and not in headings or tab titles.
if (
parentElement &&
parentElement.closest('.theme-doc-markdown.markdown') &&
!/^H[1-6]$/.test(parentElement.tagName) && // Skip headings (H1 to H6).
!isTabTitle && // Skip tab titles.
!isCodeBlock && // Skip code blocks.
!isCard && // Skip Cards.
!isMermaidDiagram // Skip Mermaid diagrams.
) {
let currentText = currentNode.textContent!;
const newNodes: Node[] = [];
let hasReplacements = false;

// Create a regex pattern to match all terms (case-sensitive).
const regexPattern = terms.map(term => `(${term})`).join('|');
const regex = new RegExp(regexPattern, 'g');

let lastIndex = 0;
let match: RegExpExecArray | null;

while ((match = regex.exec(currentText))) {
const matchedTerm = match[0];

if (lastIndex < match.index) {
newNodes.push(document.createTextNode(currentText.slice(lastIndex, match.index)));
}

const isFirstMention = !processedTerms.has(matchedTerm);
const isLink = parentElement && parentElement.tagName === 'A'; // Check if the parent is a link.

if (isFirstMention && !isLink) {
// Create a tooltip only if it's the first mention and not a link.
const tooltipWrapper = document.createElement('span');
tooltipWrapper.setAttribute('data-term', matchedTerm);
tooltipWrapper.className = 'glossary-term';

const definition = glossary[matchedTerm]; // Exact match from glossary.

ReactDOM.render(
<GlossaryTooltip term={matchedTerm} definition={definition}>
{matchedTerm}
</GlossaryTooltip>,
tooltipWrapper
);

newNodes.push(tooltipWrapper);
processedTerms.add(matchedTerm); // Mark this term as processed.
} else if (isLink) {
// If it's a link, we skip this mention but do not mark it as processed.
newNodes.push(document.createTextNode(matchedTerm));
} else {
// If it's not the first mention, just add the plain text.
newNodes.push(document.createTextNode(matchedTerm));
}

lastIndex = match.index + matchedTerm.length;
hasReplacements = true;
}

if (lastIndex < currentText.length) {
newNodes.push(document.createTextNode(currentText.slice(lastIndex)));
}

if (hasReplacements) {
modifications.push({ originalNode: currentNode, newNodes });
}
}
}

// Replace the original nodes with new nodes.
modifications.forEach(({ originalNode, newNodes }) => {
const parentElement = originalNode.parentElement;
if (parentElement) {
newNodes.forEach((newNode) => {
parentElement.insertBefore(newNode, originalNode);
});
parentElement.removeChild(originalNode);
}
});
};

// Target the specific div with the class "theme-doc-markdown markdown".
const targetDiv = document.querySelector('.theme-doc-markdown.markdown');
if (targetDiv) {
wrapTermsInTooltips(targetDiv);
}
}, [glossary]);

return <>{children}</>;
};

export default GlossaryInjector;
57 changes: 57 additions & 0 deletions src/components/GlossaryTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React, { useEffect, useRef, useState } from 'react';

interface GlossaryTooltipProps {
term: string;
definition: string;
children: React.ReactNode;
}

const GlossaryTooltip: React.FC<GlossaryTooltipProps> = ({ term, definition, children }) => {
const tooltipRef = useRef<HTMLDivElement>(null);
const [tooltipPosition, setTooltipPosition] = useState<{ top: number; left: number } | null>(null);

const handleMouseEnter = (event: React.MouseEvent) => {
const target = event.currentTarget;

// Get the bounding rectangle of the target element.
const rect = target.getBoundingClientRect();

// Calculate tooltip position.
const tooltipTop = rect.bottom + window.scrollY; // Position below the term.
const tooltipLeft = rect.left + window.scrollX; // Align with the left edge of the term.

setTooltipPosition({ top: tooltipTop, left: tooltipLeft });
};

const handleMouseLeave = () => {
setTooltipPosition(null);
};

return (
<>
<span
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className="glossary-term"
>
{children}
</span>

{tooltipPosition && (
<div
ref={tooltipRef}
className="tooltip-glossary"
style={{
top: tooltipPosition.top,
left: tooltipPosition.left,
position: 'absolute',
}}
>
{definition}
</div>
)}
</>
);
};

export default GlossaryTooltip;
50 changes: 47 additions & 3 deletions src/css/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -173,14 +173,14 @@ html[data-theme="dark"] a[class^='fa-solid fa-circle-question'] {
}
}

/* Tooltip container */
/* Edition tag bar: Question-mark icon tooltip container */
.tooltip {
position: relative;
display: inline-block;
/* border-bottom: 1px dotted black; */ /* If you want dots under the hoverable text */
}

/* Tooltip text */
/* Question-mark icon tooltip text */
.tooltip .tooltiptext {
background-color: #6c6c6c;
border-radius: 5px;
Expand All @@ -197,7 +197,51 @@ html[data-theme="dark"] a[class^='fa-solid fa-circle-question'] {
left: 125%;
}

/* Show the tooltip text when you mouse over the tooltip container */
/* Show the Question-mark icon tooltip text when you mouse over the tooltip container */
.tooltip:hover .tooltiptext {
visibility: visible;
}

/* Glossary tooltip styles */
.glossary-term {
cursor: help;
text-decoration: underline dotted;
}

.tooltip-glossary {
background-color: #f6f6f6;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
left: 15px;
opacity: 1;
position: absolute;
padding: 10px 15px;
transform: translateY(5px);
visibility: visible;
white-space: normal;
width: 460px;
z-index: 10;
}

html[data-theme="dark"] .tooltip-glossary {
background-color: var(--ifm-dropdown-background-color);
border: 1px solid var(--ifm-table-border-color);
border-radius: 4px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
opacity: 1;
position: absolute;
padding: 10px 15px;
transform: translateY(5px);
visibility: visible;
white-space: normal;
width: 460px;
z-index: 10;
}

@media (max-width: 997px) {
.tooltip-glossary {
left: 15px !important;
width: 333px !important;
}
}
18 changes: 18 additions & 0 deletions src/theme/MDXContent/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from 'react';
import MDXContent from '@theme-original/MDXContent';
import type MDXContentType from '@theme/MDXContent';
import type {WrapperProps} from '@docusaurus/types';
import GlossaryInjector from '../../../src/components/GlossaryInjector';

type Props = WrapperProps<typeof MDXContentType>;

export default function MDXContentWrapper(props: Props, { children }): JSX.Element {
return (
<>
<MDXContent {...props} />
<GlossaryInjector>
{children}
</GlossaryInjector>
</>
);
}

0 comments on commit d960f1b

Please sign in to comment.