diff --git a/docusaurus.config.js b/docusaurus.config.js index 09ea574fb68154..18af478496d066 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -164,6 +164,12 @@ module.exports = { url: "https://developer.4d.com/", organizationName: "4D", projectName: "docs", + // Source repo used to build "View as markdown" / "Ask AI" links in the doc footer. + // Production docs live in 4d/docs, pre-production in doc4d/docs. + customFields: { + githubDocsRepo: isProduction ? "4d/docs" : "doc4d/docs", + githubDocsBranch: "main", + }, favicon: "img/favicon/4d.gif", trailingSlash: false, onBrokenLinks: "ignore", diff --git a/src/theme/DocItem/Footer/PageActions.js b/src/theme/DocItem/Footer/PageActions.js new file mode 100644 index 00000000000000..1ace8d6fbf0796 --- /dev/null +++ b/src/theme/DocItem/Footer/PageActions.js @@ -0,0 +1,222 @@ +/** + * Toolbar of page actions shown in the doc footer: + * - Copy link: copies the current page URL to the clipboard + * - Comment: opens a GitHub issue (the swizzled editUrl), icon-only with tooltip + * - Ask AI: dropdown to view the page as raw markdown or open it in ChatGPT / Claude + */ +import React, {useState} from 'react'; +import clsx from 'clsx'; +import Link from '@docusaurus/Link'; +import {translate} from '@docusaurus/Translate'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import styles from './pageActions.module.css'; + +function LinkIcon() { + return ( + + ); +} + +function CheckIcon() { + return ( + + ); +} + +function CommentIcon() { + return ( + + ); +} + +function AiIcon() { + return ( + + ); +} + +function ChevronIcon() { + return ( + + ); +} + +export default function PageActions({editUrl, source, placement = 'footer'}) { + const {siteConfig} = useDocusaurusContext(); + const [copied, setCopied] = useState(false); + + const repo = siteConfig.customFields?.githubDocsRepo ?? '4d/docs'; + const branch = siteConfig.customFields?.githubDocsBranch ?? 'main'; + const sourcePath = source ? source.replace(/^@site\//, '') : null; + const markdownUrl = sourcePath + ? `https://raw.githubusercontent.com/${repo}/${branch}/${sourcePath}` + : null; + + const aiPrompt = markdownUrl + ? `Read from ${markdownUrl} so I can ask questions about it` + : null; + const chatGptUrl = aiPrompt + ? `https://chatgpt.com/?prompt=${encodeURIComponent(aiPrompt)}` + : null; + const claudeUrl = aiPrompt + ? `https://claude.ai/new?q=${encodeURIComponent(aiPrompt)}` + : null; + + const commentLabel = translate({ + id: 'theme.common.editThisPage', + message: 'Comment on this page', + description: 'The link label to comment on the current page', + }); + const copyLabel = translate({ + id: 'theme.docs.pageActions.copyLink', + message: 'Copy link', + description: 'Tooltip for the copy-link button in the doc footer', + }); + const copiedLabel = translate({ + id: 'theme.docs.pageActions.copied', + message: 'Copied!', + description: 'Tooltip shown after the link has been copied', + }); + + async function handleCopy() { + try { + await navigator.clipboard.writeText(window.location.href); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard API unavailable (e.g. non-secure context); silently ignore. + } + } + + return ( +
+ + + {editUrl && ( + + + + )} + + {markdownUrl && ( +
+ +
    +
  • + + {translate({ + id: 'theme.docs.pageActions.viewMarkdown', + message: 'View as markdown', + })} + +
  • +
  • + + {translate({ + id: 'theme.docs.pageActions.askChatGpt', + message: 'Ask ChatGPT', + })} + +
  • +
  • + + {translate({ + id: 'theme.docs.pageActions.askClaude', + message: 'Ask Claude', + })} + +
  • +
+
+ )} +
+ ); +} diff --git a/src/theme/DocItem/Footer/index.js b/src/theme/DocItem/Footer/index.js new file mode 100644 index 00000000000000..1d6dbdc28070b1 --- /dev/null +++ b/src/theme/DocItem/Footer/index.js @@ -0,0 +1,61 @@ +/** + * Swizzled (ejected) from @docusaurus/theme-classic. + * + * Replaces the single "Comment on this page" link with a compact toolbar of + * icon buttons (copy link, comment, and an "Ask AI" menu). The tags row and the + * "last updated" info are preserved from the original component. + */ +import React from 'react'; +import clsx from 'clsx'; +import {ThemeClassNames} from '@docusaurus/theme-common'; +import {useDoc} from '@docusaurus/plugin-content-docs/client'; +import TagsListInline from '@theme/TagsListInline'; +import LastUpdated from '@theme/LastUpdated'; +import PageActions from './PageActions'; + +export default function DocItemFooter() { + const {metadata} = useDoc(); + const {editUrl, lastUpdatedAt, lastUpdatedBy, tags, source} = metadata; + + const canDisplayTagsRow = tags.length > 0; + const canDisplayLastUpdated = !!(lastUpdatedAt || lastUpdatedBy); + const canDisplayFooter = canDisplayTagsRow || canDisplayLastUpdated || !!editUrl; + + if (!canDisplayFooter) { + return null; + } + + return ( + + ); +} diff --git a/src/theme/DocItem/Footer/pageActions.module.css b/src/theme/DocItem/Footer/pageActions.module.css new file mode 100644 index 00000000000000..48acbc12c81915 --- /dev/null +++ b/src/theme/DocItem/Footer/pageActions.module.css @@ -0,0 +1,111 @@ +.actions { + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} + +/* Breakpoint matches Infima's desktop breakpoint (997px). */ + +/* Footer toolbar: visible on mobile/tablet, hidden on desktop (moves up top). */ +.placementFooter { + display: flex; +} + +/* Floating toolbar: hidden on mobile, top-right by the title on desktop. */ +.placementFloating { + display: none; +} + +@media (min-width: 997px) { + .placementFooter { + display: none; + } + + .placementFloating { + display: flex; + position: absolute; + top: 48px; + right: 0; + z-index: 2; + } +} + +/* Icon buttons (copy link, comment) */ +.action { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + height: 2rem; + min-width: 2rem; + padding: 0 0.45rem; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: var(--ifm-global-radius); + background: var(--ifm-background-surface-color); + color: var(--ifm-font-color-base); + cursor: pointer; + transition: background var(--ifm-transition-fast), + border-color var(--ifm-transition-fast), color var(--ifm-transition-fast); +} + +.action:hover { + background: var(--ifm-color-emphasis-100); + border-color: var(--ifm-color-primary); + color: var(--ifm-color-primary); + text-decoration: none; +} + +.aiButton { + font-size: 0.85rem; + font-weight: 500; +} + +.aiLabel { + white-space: nowrap; +} + +/* Tooltip on hover/focus for icon-only buttons */ +.tooltip { + position: relative; +} + +.tooltip::after { + content: attr(data-tooltip); + position: absolute; + bottom: calc(100% + 0.4rem); + left: 50%; + transform: translateX(-50%); + padding: 0.25rem 0.5rem; + border-radius: var(--ifm-global-radius); + background: var(--ifm-color-emphasis-800); + color: var(--ifm-color-emphasis-0); + font-size: 0.75rem; + line-height: 1.2; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity var(--ifm-transition-fast); + z-index: 5; +} + +.tooltip:hover::after, +.tooltip:focus-visible::after { + opacity: 1; +} + +/* AI dropdown */ +.menu :global(.dropdown__menu) { + min-width: 12rem; +} + +/* Positioning context for the floating (desktop) toolbar. */ +.floatingHost { + position: relative; +} + +@media print { + .actions { + display: none; + } +} diff --git a/src/theme/DocItem/Layout/index.js b/src/theme/DocItem/Layout/index.js new file mode 100644 index 00000000000000..b0a2c0fca36783 --- /dev/null +++ b/src/theme/DocItem/Layout/index.js @@ -0,0 +1,72 @@ +/** + * Swizzled (ejected) from @docusaurus/theme-classic. + * + * Adds a floating page-actions toolbar (copy link, comment, Ask AI) at the + * top-right of the article on desktop. On mobile the same toolbar is rendered + * in the footer instead (see DocItem/Footer + PageActions placement classes). + */ +import React from 'react'; +import clsx from 'clsx'; +import {useWindowSize} from '@docusaurus/theme-common'; +import {useDoc} from '@docusaurus/plugin-content-docs/client'; +import DocItemPaginator from '@theme/DocItem/Paginator'; +import DocVersionBanner from '@theme/DocVersionBanner'; +import DocVersionBadge from '@theme/DocVersionBadge'; +import DocItemFooter from '@theme/DocItem/Footer'; +import DocItemTOCMobile from '@theme/DocItem/TOC/Mobile'; +import DocItemTOCDesktop from '@theme/DocItem/TOC/Desktop'; +import DocItemContent from '@theme/DocItem/Content'; +import DocBreadcrumbs from '@theme/DocBreadcrumbs'; +import ContentVisibility from '@theme/ContentVisibility'; +import PageActions from '../Footer/PageActions'; +import pageActionStyles from '../Footer/pageActions.module.css'; +import styles from './styles.module.css'; +/** + * Decide if the toc should be rendered, on mobile or desktop viewports + */ +function useDocTOC() { + const {frontMatter, toc} = useDoc(); + const windowSize = useWindowSize(); + const hidden = frontMatter.hide_table_of_contents; + const canRender = !hidden && toc.length > 0; + const mobile = canRender ? : undefined; + const desktop = + canRender && (windowSize === 'desktop' || windowSize === 'ssr') ? ( + + ) : undefined; + return { + hidden, + mobile, + desktop, + }; +} +export default function DocItemLayout({children}) { + const docTOC = useDocTOC(); + const {metadata} = useDoc(); + return ( +
+
+ + +
+
+ + + {/* Absolutely positioned (top-right on desktop); DOM order is irrelevant + to its placement, so it stays after the breadcrumbs first-child reset. */} + + {docTOC.mobile} + {children} + +
+ +
+
+ {docTOC.desktop &&
{docTOC.desktop}
} +
+ ); +} diff --git a/src/theme/DocItem/Layout/styles.module.css b/src/theme/DocItem/Layout/styles.module.css new file mode 100644 index 00000000000000..d5aaec1322c923 --- /dev/null +++ b/src/theme/DocItem/Layout/styles.module.css @@ -0,0 +1,10 @@ +.docItemContainer header + *, +.docItemContainer article > *:first-child { + margin-top: 0; +} + +@media (min-width: 997px) { + .docItemCol { + max-width: 75% !important; + } +}