Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add glossary tooltip feature #681

Draft
wants to merge 17 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
</>
);
}
Loading