diff --git a/src/components/DocFeedbackProvider.tsx b/src/components/DocFeedbackProvider.tsx index 21eb6108..8253179b 100644 --- a/src/components/DocFeedbackProvider.tsx +++ b/src/components/DocFeedbackProvider.tsx @@ -46,6 +46,9 @@ export function DocFeedbackProvider({ const [blockMarkdowns, setBlockMarkdowns] = React.useState< Map >(new Map()) + const [blockElements, setBlockElements] = React.useState< + Map + >(new Map()) const [hoveredBlockId, setHoveredBlockId] = React.useState( null, ) @@ -87,15 +90,28 @@ export function DocFeedbackProvider({ const selectorMap = new Map() const hashMap = new Map() const markdownMap = new Map() + const elementMap = new Map() const listeners = new Map< HTMLElement, { enter: (e: MouseEvent) => void; leave: (e: MouseEvent) => void } >() + const findClosestBlock = (target: HTMLElement): HTMLElement | null => { + let closestBlock: HTMLElement | null = null + + for (const candidate of blocks) { + if (!candidate.contains(target)) continue + if (!closestBlock || closestBlock.contains(candidate)) { + closestBlock = candidate + } + } + + return closestBlock + } Promise.all( blocks.map(async (block, index) => { const blockId = `block-${index}` - block.setAttribute('data-block-id', blockId) + elementMap.set(blockId, block) const identifier = await getBlockIdentifier(block) selectorMap.set(blockId, identifier.selector) @@ -106,8 +122,10 @@ export function DocFeedbackProvider({ const handleMouseEnter = (e: MouseEvent) => { // Only handle hover if this is the most specific block being hovered // (prevent parent blocks from showing hover when child blocks are hovered) - const target = e.target as HTMLElement - const closestBlock = target.closest('[data-block-id]') + const target = e.target + if (!(target instanceof HTMLElement)) return + + const closestBlock = findClosestBlock(target) if (closestBlock === block) { setHoveredBlockId(blockId) block.style.backgroundColor = 'rgba(59, 130, 246, 0.05)' // blue with low opacity @@ -118,10 +136,15 @@ export function DocFeedbackProvider({ const handleMouseLeave = (e: MouseEvent) => { // Only clear hover if we're actually leaving this block // (not just entering a child element) - const relatedTarget = e.relatedTarget as HTMLElement + const relatedTarget = + e.relatedTarget instanceof HTMLElement ? e.relatedTarget : null + const closestBlock = relatedTarget + ? findClosestBlock(relatedTarget) + : null if ( + !relatedTarget || !block.contains(relatedTarget) || - relatedTarget?.closest('[data-block-id]') !== block + closestBlock !== block ) { setHoveredBlockId((current) => current === blockId ? null : current, @@ -147,13 +170,14 @@ export function DocFeedbackProvider({ setBlockSelectors(new Map(selectorMap)) setBlockContentHashes(new Map(hashMap)) setBlockMarkdowns(new Map(markdownMap)) + setBlockElements(new Map(elementMap)) // Visual indicators will be updated by the separate effect below }) return () => { + setBlockElements(new Map()) blocks.forEach((block) => { - block.removeAttribute('data-block-id') block.style.backgroundColor = '' block.style.borderRight = '' block.style.paddingRight = '' @@ -173,9 +197,7 @@ export function DocFeedbackProvider({ if (!user || blockSelectors.size === 0) return blockSelectors.forEach((selector, blockId) => { - const block = document.querySelector( - `[data-block-id="${blockId}"]`, - ) as HTMLElement + const block = blockElements.get(blockId) if (!block) return const hasNote = userNotes.some((n) => n.blockSelector === selector) @@ -196,7 +218,7 @@ export function DocFeedbackProvider({ block.style.paddingRight = '' } }) - }, [user, userNotes, userImprovements, blockSelectors]) + }, [user, userNotes, userImprovements, blockSelectors, blockElements]) const handleCloseCreating = React.useCallback(() => { setCreatingState(null) @@ -250,6 +272,8 @@ export function DocFeedbackProvider({ {Array.from(blockSelectors.keys()).map((blockId) => { const selector = blockSelectors.get(blockId) if (!selector) return null + const block = blockElements.get(blockId) + if (!block) return null // Check if this block has a note or improvement (only for logged-in users) const note = user @@ -264,6 +288,7 @@ export function DocFeedbackProvider({ + return })} {/* Render improvements inline */} @@ -298,13 +325,11 @@ export function DocFeedbackProvider({ )?.[0] if (!blockId) return null + const block = blockElements.get(blockId) + if (!block) return null return ( - + ) })} @@ -312,6 +337,7 @@ export function DocFeedbackProvider({ {creatingState && ( ( `[data-button-portal="${blockId}"]`, - ) as HTMLElement + ) if (!portalContainer) { portalContainer = document.createElement('div') @@ -407,7 +429,13 @@ function BlockButton({ } // Component to render note after a block -function NotePortal({ blockId, note }: { blockId: string; note: DocFeedback }) { +function NotePortal({ + block, + note, +}: { + block: HTMLElement + note: DocFeedback +}) { const [mounted, setMounted] = React.useState(false) React.useEffect(() => { @@ -416,24 +444,21 @@ function NotePortal({ blockId, note }: { blockId: string; note: DocFeedback }) { if (!mounted) return null - // Find the block element - const block = document.querySelector(`[data-block-id="${blockId}"]`) - if (!block) return null - // Don't show note if block is inside an editor portal if (block.closest('[data-editor-portal]')) return null // Find the actual insertion point - if block is inside an anchor-heading, insert after the anchor - let insertionPoint = block as HTMLElement + let insertionPoint = block const anchorParent = block.parentElement if (anchorParent?.classList.contains('anchor-heading')) { insertionPoint = anchorParent } // Create portal container after the insertion point - let portalContainer = insertionPoint.parentElement?.querySelector( - `[data-note-portal="${note.id}"]`, - ) as HTMLElement + let portalContainer = + insertionPoint.parentElement?.querySelector( + `[data-note-portal="${note.id}"]`, + ) if (!portalContainer) { portalContainer = document.createElement('div') @@ -454,6 +479,7 @@ function NotePortal({ blockId, note }: { blockId: string; note: DocFeedback }) { // Component to render creating feedback interface after a block function CreatingFeedbackPortal({ blockId, + block, type, blockSelector, blockContentHash, @@ -464,6 +490,7 @@ function CreatingFeedbackPortal({ onClose, }: { blockId: string + block?: HTMLElement type: 'note' | 'improvement' blockSelector: string blockContentHash?: string @@ -480,22 +507,20 @@ function CreatingFeedbackPortal({ }, []) if (!mounted) return null - - // Find the block element - const block = document.querySelector(`[data-block-id="${blockId}"]`) if (!block) return null // Find the actual insertion point - if block is inside an anchor-heading, insert after the anchor - let insertionPoint = block as HTMLElement + let insertionPoint = block const anchorParent = block.parentElement if (anchorParent?.classList.contains('anchor-heading')) { insertionPoint = anchorParent } // Create portal container after the insertion point - let portalContainer = insertionPoint.parentElement?.querySelector( - `[data-creating-portal="${blockId}"]`, - ) as HTMLElement + let portalContainer = + insertionPoint.parentElement?.querySelector( + `[data-creating-portal="${blockId}"]`, + ) if (!portalContainer) { portalContainer = document.createElement('div') diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx index c9e6c422..ce6ba4e5 100644 --- a/src/components/ThemeProvider.tsx +++ b/src/components/ThemeProvider.tsx @@ -78,19 +78,19 @@ const ThemeContext = createContext(undefined) type ThemeProviderProps = { children: ReactNode } -const getResolvedThemeFromDOM = createIsomorphicFn() - .server((): ResolvedTheme => 'light') - .client((): ResolvedTheme => { - return document.documentElement.classList.contains('dark') - ? 'dark' - : 'light' - }) export function ThemeProvider({ children }: ThemeProviderProps) { - const [themeMode, setThemeMode] = useState(getStoredThemeMode) - const [resolvedTheme, setResolvedTheme] = useState( - getResolvedThemeFromDOM, - ) + const [themeMode, setThemeMode] = useState('auto') + const [resolvedTheme, setResolvedTheme] = useState('light') + + useEffect(() => { + const storedThemeMode = getStoredThemeMode() + setThemeMode(storedThemeMode) + updateThemeClass(storedThemeMode) + setResolvedTheme( + storedThemeMode === 'auto' ? getSystemTheme() : storedThemeMode, + ) + }, []) // Listen for system theme changes when in auto mode useEffect(() => { diff --git a/src/components/markdown/MdComponents.tsx b/src/components/markdown/MdComponents.tsx index f27dde54..91bcebe7 100644 --- a/src/components/markdown/MdComponents.tsx +++ b/src/components/markdown/MdComponents.tsx @@ -27,6 +27,7 @@ type MdCommentComponentProps = { 'data-component'?: string 'data-files-meta'?: string 'data-package-manager-meta'?: string + preserveTabPanels?: boolean children?: React.ReactNode } @@ -48,11 +49,25 @@ function isMdFrameworkPanelElement( ) } +function renderPanelChildren( + panels: Array>, + preserveTabPanels: boolean, +) { + return panels.map((panel, index) => { + if (!preserveTabPanels) { + return panel.props.children + } + + return {panel.props.children} + }) +} + export function MdCommentComponent({ 'data-attributes': rawAttributes, 'data-component': componentName, 'data-files-meta': filesMeta, 'data-package-manager-meta': packageManagerMeta, + preserveTabPanels = false, children, }: MdCommentComponentProps) { const parsedAttributes = parseJson(rawAttributes) @@ -122,7 +137,7 @@ export function MdCommentComponent({ return ( - {panels.map((panel) => panel.props.children)} + {renderPanelChildren(panels, preserveTabPanels)} ) } @@ -137,7 +152,7 @@ export function MdCommentComponent({ } return ( - {panels.map((panel) => panel.props.children)} + {renderPanelChildren(panels, preserveTabPanels)} ) } diff --git a/src/styles/app.css b/src/styles/app.css index 1e4cadaf..7136d59a 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -195,6 +195,19 @@ button { @apply opacity-50; } + .anchor-heading-link { + text-decoration: none !important; + @apply ml-2 inline-block opacity-0 transition duration-100; + } + + :hover > .anchor-heading-link { + @apply opacity-50; + } + + .anchor-heading-link:focus { + @apply opacity-75; + } + :has(+ .anchor-heading) { margin-bottom: 0 !important; } diff --git a/src/utils/blog.functions.ts b/src/utils/blog.functions.ts index 3aa7c2a9..90b1cf57 100644 --- a/src/utils/blog.functions.ts +++ b/src/utils/blog.functions.ts @@ -92,7 +92,9 @@ export const fetchBlogPost = createServerFn({ method: 'GET' }) ${post.content}` - const { contentRsc, headings } = await renderMarkdownToRsc(blogContent) + const { contentRsc, headings } = await renderMarkdownToRsc(blogContent, { + preserveTabPanels: true, + }) const isUnpublished = post.draft || !isPublishedDateReleased(post.published) return { diff --git a/src/utils/markdown/processor.rsc.tsx b/src/utils/markdown/processor.rsc.tsx index 99fffa1a..3fe697c6 100644 --- a/src/utils/markdown/processor.rsc.tsx +++ b/src/utils/markdown/processor.rsc.tsx @@ -33,6 +33,10 @@ export type MarkdownJsxResult = { headings: MarkdownHeading[] } +export type MarkdownRenderOptions = { + preserveTabPanels?: boolean +} + function createHeadingComponent( level: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6', ) { @@ -93,25 +97,39 @@ function LinkElement(props: React.AnchorHTMLAttributes) { return } -const markdownComponents = { - a: LinkElement, - code: CodeElement, - h1: createHeadingComponent('h1'), - h2: createHeadingComponent('h2'), - h3: createHeadingComponent('h3'), - h4: createHeadingComponent('h4'), - h5: createHeadingComponent('h5'), - h6: createHeadingComponent('h6'), - iframe: MarkdownIframe, - img: MarkdownImg, - 'md-comment-component': MdCommentComponent, - 'md-framework-panel': MdFrameworkPanel, - 'md-tab-panel': MdTabPanel, - pre: CodeBlock, +function createMarkdownComponents(options: MarkdownRenderOptions = {}) { + function MdCommentComponentWithOptions( + props: React.ComponentProps, + ) { + return ( + + ) + } + + return { + a: LinkElement, + code: CodeElement, + h1: createHeadingComponent('h1'), + h2: createHeadingComponent('h2'), + h3: createHeadingComponent('h3'), + h4: createHeadingComponent('h4'), + h5: createHeadingComponent('h5'), + h6: createHeadingComponent('h6'), + iframe: MarkdownIframe, + img: MarkdownImg, + 'md-comment-component': MdCommentComponentWithOptions, + 'md-framework-panel': MdFrameworkPanel, + 'md-tab-panel': MdTabPanel, + pre: CodeBlock, + } } export async function renderMarkdownToJsx( content: string, + options?: MarkdownRenderOptions, ): Promise { const headings: Array = [] @@ -145,18 +163,24 @@ export async function renderMarkdownToJsx( .use(rehypeSlug) .use(rehypeTransformFrameworkComponents) .use(rehypeTransformCommentComponents) + .use(() => rehypeCollectHeadings(headings)) .use(rehypeAutolinkHeadings, { - behavior: 'wrap', + behavior: 'append', + content: { + type: 'text', + value: '#', + }, properties: { - className: ['anchor-heading'], + ariaHidden: true, + className: ['anchor-heading', 'anchor-heading-link'], + tabIndex: -1, }, }) - .use(() => rehypeCollectHeadings(headings)) .use(rehypeReact, { Fragment: jsxRuntime.Fragment, jsx: jsxRuntime.jsx, jsxs: jsxRuntime.jsxs, - components: markdownComponents, + components: createMarkdownComponents(options), } as any) .process(content) diff --git a/src/utils/markdown/renderRsc.tsx b/src/utils/markdown/renderRsc.tsx index 8c53f540..b697499d 100644 --- a/src/utils/markdown/renderRsc.tsx +++ b/src/utils/markdown/renderRsc.tsx @@ -1,9 +1,18 @@ import { renderServerComponent } from '@tanstack/react-start/rsc' import * as React from 'react' -import { renderMarkdownToJsx } from './processor.rsc' +import { + renderMarkdownToJsx, + type MarkdownRenderOptions, +} from './processor.rsc' -export async function renderMarkdownToRsc(content: string) { - const { content: contentJsx, headings } = await renderMarkdownToJsx(content) +export async function renderMarkdownToRsc( + content: string, + options?: MarkdownRenderOptions, +) { + const { content: contentJsx, headings } = await renderMarkdownToJsx( + content, + options, + ) const contentRsc = await renderServerComponent( React.createElement(React.Fragment, null, contentJsx), )