Skip to content

Commit ded7bec

Browse files
heiskrCopilot
andauthored
Add React body components for the pickers and copy seam (dormant) (#61776)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 310ceff commit ded7bec

4 files changed

Lines changed: 263 additions & 5 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react'
2+
import type { ComponentPropsWithoutRef } from 'react'
3+
import { announce } from '@primer/live-region-element'
4+
5+
type CopyButtonProps = ComponentPropsWithoutRef<'button'> & {
6+
'data-clipboard'?: string
7+
}
8+
9+
// React replacement for the imperative `copy-code.ts` enhancer. The code-block
10+
// header (`content-render/unified/code-header.ts`) emits this button into the
11+
// HTML AST next to a hidden `<pre data-clipboard="<id>">` holding the raw code.
12+
// When the article body is rendered from hast (instead of dangerouslySetInnerHTML),
13+
// `MarkdownContent` maps that `<button class="js-btn-copy">` to this component so
14+
// React owns the node rather than a post-hydration `document.querySelectorAll`.
15+
//
16+
// Analytics is intentionally NOT sent here: a global delegated click listener in
17+
// `events/components/events.ts` already records `.js-btn-copy` clicks.
18+
export function CopyButton({ className, children, ...props }: CopyButtonProps) {
19+
const [copied, setCopied] = useState(false)
20+
const buttonRef = useRef<HTMLButtonElement>(null)
21+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
22+
23+
useEffect(() => {
24+
return () => {
25+
if (timeoutRef.current) clearTimeout(timeoutRef.current)
26+
}
27+
}, [])
28+
29+
const clipboardId = props['data-clipboard']
30+
31+
const handleClick = useCallback(async () => {
32+
if (!clipboardId) return
33+
34+
// The hidden <pre> is a sibling of this button inside the code-block header,
35+
// so look it up locally to avoid copying a different block that happens to
36+
// share the same content hash.
37+
const scope: Element | Document = buttonRef.current?.parentElement ?? document
38+
const pre = scope.querySelector<HTMLElement>(`pre[data-clipboard="${CSS.escape(clipboardId)}"]`)
39+
const text = pre?.innerText
40+
if (!text) return
41+
42+
try {
43+
await navigator.clipboard.writeText(text)
44+
} catch {
45+
// Clipboard write can be blocked (permissions, insecure context, etc.).
46+
// Don't show a false "Copied!" state.
47+
return
48+
}
49+
50+
setCopied(true)
51+
announce('Copied!')
52+
53+
if (timeoutRef.current) clearTimeout(timeoutRef.current)
54+
timeoutRef.current = setTimeout(() => setCopied(false), 2000)
55+
}, [clipboardId])
56+
57+
return (
58+
<button
59+
type="button"
60+
{...props}
61+
ref={buttonRef}
62+
className={copied ? `${className ?? ''} copied`.trim() : className}
63+
onClick={handleClick}
64+
>
65+
{children}
66+
</button>
67+
)
68+
}
Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,76 @@
11
import { memo, ReactNode } from 'react'
2-
import type { JSX } from 'react'
2+
import type { ComponentProps, JSX } from 'react'
3+
import { Fragment, jsx, jsxs } from 'react/jsx-runtime'
4+
import { toJsxRuntime, type Components } from 'hast-util-to-jsx-runtime'
5+
import type { Root as HastRoot } from 'hast'
36
import cx from 'classnames'
47

8+
import { CopyButton } from '@/frame/components/CopyButton'
9+
import { ToggleableContent } from '@/tools/components/ToggleableContent'
10+
import { isToggleClass } from '@/tools/components/SelectionContext'
511
import styles from './MarkdownContent.module.scss'
612

713
export type MarkdownContentPropsT = {
8-
children: string | ReactNode
14+
children?: string | ReactNode
15+
hast?: HastRoot
916
className?: string
1017
as?: keyof JSX.IntrinsicElements
1118
}
1219

20+
// Map specific elements in the HTML AST to interactive React components instead
21+
// of inert markup enhanced by post-hydration DOM mutation (#6619). This only
22+
// applies to the hast rendering path; the dangerouslySetInnerHTML string path is
23+
// untouched. The same map runs during SSR and on the client, so hydration matches.
24+
const markdownComponents = {
25+
button(props: ComponentProps<'button'>) {
26+
const classes = String(props.className || '').split(/\s+/)
27+
if (classes.includes('js-btn-copy')) {
28+
return <CopyButton {...props} />
29+
}
30+
return <button {...props} />
31+
},
32+
// Platform/tool-scoped blocks and inline spans subscribe to SelectionContext so
33+
// the pickers can hide non-matching content via React state instead of the old
34+
// imperative `style.display` DOM mutation (#6619). The className check is cheap
35+
// and runs first, so only the handful of toggleable elements become context
36+
// consumers; every other div/span renders as a plain element with no hook.
37+
div(props: ComponentProps<'div'>) {
38+
if (isToggleClass(props.className)) {
39+
return <ToggleableContent tag="div" {...props} />
40+
}
41+
return <div {...props} />
42+
},
43+
span(props: ComponentProps<'span'>) {
44+
if (isToggleClass(props.className)) {
45+
return <ToggleableContent tag="span" {...props} />
46+
}
47+
return <span {...props} />
48+
},
49+
} as unknown as Partial<Components>
50+
1351
// Memoized so that re-renders of the parent (e.g. when ToolPicker/PlatformPicker
1452
// state updates) don't cause React 19 to re-apply `dangerouslySetInnerHTML` and
1553
// wipe out the inline `display` styles set imperatively by the pickers.
1654
export const MarkdownContent = memo(function MarkdownContent({
1755
children,
56+
hast,
1857
as: Component = 'div',
1958
className,
2059
...restProps
2160
}: MarkdownContentPropsT) {
61+
// When a hast (HTML AST) tree is provided, render it as real React elements
62+
// instead of injecting an HTML string via dangerouslySetInnerHTML (#6619).
63+
const childProps = hast
64+
? { children: toJsxRuntime(hast, { Fragment, jsx, jsxs, components: markdownComponents }) }
65+
: typeof children === 'string'
66+
? { dangerouslySetInnerHTML: { __html: children } }
67+
: { children }
68+
2269
return (
2370
<Component
2471
{...restProps}
2572
className={cx(styles.markdownBody, 'markdown-body', className)}
26-
{...(typeof children === 'string'
27-
? { dangerouslySetInnerHTML: { __html: children } }
28-
: { children })}
73+
{...childProps}
2974
/>
3075
)
3176
})
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { createContext, useContext, useMemo, useState } from 'react'
2+
import type { ReactNode } from 'react'
3+
4+
import { allPlatforms } from '@/tools/lib/all-platforms'
5+
import { allTools } from '@/tools/lib/all-tools'
6+
7+
// React-native replacement for the imperative platform/tool visibility toggling
8+
// that PlatformPicker/ToolPicker used to do by walking the DOM and setting
9+
// `style.display` on `.ghd-tool`/`.platform-*`/`.tool-*` elements (#6619). The
10+
// selected platform + tool live in this context; the article body (rendered from
11+
// hast) maps the relevant elements to <ToggleableContent>, which reads the
12+
// selection and hides non-matching content instead of mutating React-owned nodes.
13+
//
14+
// Selections start empty so that the server render and the first client render
15+
// both show ALL variants (matching the pre-JS markup), which keeps hydration
16+
// stable. The pickers set the real selection in an effect after hydration, the
17+
// same moment the old imperative code used to run.
18+
19+
export type SelectionContextT = {
20+
platform: string
21+
tool: string
22+
setPlatform: (value: string) => void
23+
setTool: (value: string) => void
24+
}
25+
26+
const noop = () => {}
27+
28+
export const SelectionContext = createContext<SelectionContextT>({
29+
platform: '',
30+
tool: '',
31+
setPlatform: noop,
32+
setTool: noop,
33+
})
34+
35+
export function SelectionProvider({ children }: { children: ReactNode }) {
36+
const [platform, setPlatform] = useState('')
37+
const [tool, setTool] = useState('')
38+
39+
const value = useMemo<SelectionContextT>(
40+
() => ({ platform, tool, setPlatform, setTool }),
41+
[platform, tool],
42+
)
43+
44+
return <SelectionContext.Provider value={value}>{children}</SelectionContext.Provider>
45+
}
46+
47+
export function useSelection(): SelectionContextT {
48+
return useContext(SelectionContext)
49+
}
50+
51+
const platformSet = new Set<string>(allPlatforms)
52+
const toolSet = new Set<string>(Object.keys(allTools))
53+
54+
export type ToggleClassification = {
55+
scope: 'platform' | 'tool'
56+
value: string
57+
}
58+
59+
function toClassList(className: unknown): string[] {
60+
if (Array.isArray(className)) return className.map(String)
61+
if (typeof className === 'string') return className.split(/\s+/).filter(Boolean)
62+
return []
63+
}
64+
65+
// Determine whether an element is platform/tool-scoped and which value gates it.
66+
// `.ghd-tool <value>` is a block (the {% mac %}/{% webui %} liquid tags); the
67+
// extra class is the platform or tool value. `platform-<value>`/`tool-<value>`
68+
// are author-written inline spans. We classify strictly against the canonical
69+
// platform/tool vocabularies and return null on anything outside them, so an
70+
// unrecognized class never makes content disappear. When several recognized
71+
// markers are present the first match wins; in practice an element carries
72+
// exactly one platform/tool marker.
73+
export function classifyToggleClass(className: unknown): ToggleClassification | null {
74+
const classes = toClassList(className)
75+
if (!classes.length) return null
76+
77+
if (classes.includes('ghd-tool')) {
78+
const platform = classes.find((c) => platformSet.has(c))
79+
if (platform) return { scope: 'platform', value: platform }
80+
const tool = classes.find((c) => toolSet.has(c))
81+
if (tool) return { scope: 'tool', value: tool }
82+
return null
83+
}
84+
85+
for (const c of classes) {
86+
if (c.startsWith('platform-')) {
87+
const value = c.slice('platform-'.length)
88+
if (platformSet.has(value)) return { scope: 'platform', value }
89+
}
90+
if (c.startsWith('tool-')) {
91+
const value = c.slice('tool-'.length)
92+
if (toolSet.has(value)) return { scope: 'tool', value }
93+
}
94+
}
95+
96+
return null
97+
}
98+
99+
export function isToggleClass(className: unknown): boolean {
100+
return classifyToggleClass(className) !== null
101+
}
102+
103+
// Visible when no selection has been made yet (initial render shows everything,
104+
// matching the pre-JS markup) or when the element's value is the selected one.
105+
export function isContentVisible(
106+
classification: ToggleClassification,
107+
selection: { platform: string; tool: string },
108+
): boolean {
109+
const selected = classification.scope === 'platform' ? selection.platform : selection.tool
110+
if (!selected) return true
111+
return classification.value === selected
112+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { createElement } from 'react'
2+
import type { ReactNode } from 'react'
3+
4+
import {
5+
classifyToggleClass,
6+
isContentVisible,
7+
useSelection,
8+
} from '@/tools/components/SelectionContext'
9+
10+
// Wraps a platform/tool-scoped element from the article body hast and toggles
11+
// its visibility from SelectionContext instead of the old imperative
12+
// `style.display` mutation (#6619). Renders the same element/props/children, but
13+
// sets `hidden` when the current platform/tool selection doesn't match. We keep
14+
// the node in the DOM (hidden) rather than returning null so anchors, IDs, and
15+
// screen-reader traversal behave like the previous `display:none` approach.
16+
type ToggleableContentProps = {
17+
tag: 'div' | 'span'
18+
className?: string
19+
hidden?: boolean
20+
children?: ReactNode
21+
[key: string]: unknown
22+
}
23+
24+
export function ToggleableContent({ tag, ...props }: ToggleableContentProps) {
25+
const { platform, tool } = useSelection()
26+
const classification = classifyToggleClass(props.className)
27+
28+
const hidden = classification
29+
? Boolean(props.hidden) || !isContentVisible(classification, { platform, tool })
30+
: props.hidden
31+
32+
return createElement(tag, { ...props, hidden })
33+
}

0 commit comments

Comments
 (0)