Skip to content
Merged
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
105 changes: 65 additions & 40 deletions src/components/DocFeedbackProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export function DocFeedbackProvider({
const [blockMarkdowns, setBlockMarkdowns] = React.useState<
Map<string, string>
>(new Map())
const [blockElements, setBlockElements] = React.useState<
Map<string, HTMLElement>
>(new Map())
const [hoveredBlockId, setHoveredBlockId] = React.useState<string | null>(
null,
)
Expand Down Expand Up @@ -87,15 +90,28 @@ export function DocFeedbackProvider({
const selectorMap = new Map<string, string>()
const hashMap = new Map<string, string>()
const markdownMap = new Map<string, string>()
const elementMap = new Map<string, HTMLElement>()
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)
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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 = ''
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -264,6 +288,7 @@ export function DocFeedbackProvider({
<BlockButton
key={blockId}
blockId={blockId}
block={block}
isHovered={isHovered}
hasNote={!!note}
hasImprovement={!!improvement}
Expand All @@ -286,8 +311,10 @@ export function DocFeedbackProvider({
)?.[0]

if (!blockId) return null
const block = blockElements.get(blockId)
if (!block) return null

return <NotePortal key={note.id} blockId={blockId} note={note} />
return <NotePortal key={note.id} block={block} note={note} />
})}

{/* Render improvements inline */}
Expand All @@ -298,20 +325,19 @@ export function DocFeedbackProvider({
)?.[0]

if (!blockId) return null
const block = blockElements.get(blockId)
if (!block) return null

return (
<NotePortal
key={improvement.id}
blockId={blockId}
note={improvement}
/>
<NotePortal key={improvement.id} block={block} note={improvement} />
)
})}

{/* Render creating interface */}
{creatingState && (
<CreatingFeedbackPortal
blockId={creatingState.blockId}
block={blockElements.get(creatingState.blockId)}
type={creatingState.type}
blockSelector={blockSelectors.get(creatingState.blockId) || ''}
blockContentHash={blockContentHashes.get(creatingState.blockId) || ''}
Expand All @@ -329,6 +355,7 @@ export function DocFeedbackProvider({
// Component to render floating button for a block
function BlockButton({
blockId,
block,
isHovered,
hasNote,
hasImprovement,
Expand All @@ -339,6 +366,7 @@ function BlockButton({
onShowNote,
}: {
blockId: string
block: HTMLElement
isHovered: boolean
hasNote: boolean
hasImprovement: boolean
Expand All @@ -356,12 +384,6 @@ function BlockButton({

if (!mounted) return null

// Find the block element
const block = document.querySelector(
`[data-block-id="${blockId}"]`,
) as HTMLElement
if (!block) return null

// Don't show button if block is inside an editor or note portal
if (block.closest('[data-editor-portal], [data-note-portal]')) return null

Expand All @@ -372,9 +394,9 @@ function BlockButton({
if (!isHovered && !isMenuOpen) return null

// Create portal container for button positioned at top-right of block
let portalContainer = document.querySelector(
let portalContainer = block.querySelector<HTMLElement>(
`[data-button-portal="${blockId}"]`,
) as HTMLElement
)

if (!portalContainer) {
portalContainer = document.createElement('div')
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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<HTMLElement>(
`[data-note-portal="${note.id}"]`,
)

if (!portalContainer) {
portalContainer = document.createElement('div')
Expand All @@ -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,
Expand All @@ -464,6 +490,7 @@ function CreatingFeedbackPortal({
onClose,
}: {
blockId: string
block?: HTMLElement
type: 'note' | 'improvement'
blockSelector: string
blockContentHash?: string
Expand All @@ -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<HTMLElement>(
`[data-creating-portal="${blockId}"]`,
)

if (!portalContainer) {
portalContainer = document.createElement('div')
Expand Down
22 changes: 11 additions & 11 deletions src/components/ThemeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,19 +78,19 @@ const ThemeContext = createContext<ThemeContextProps | undefined>(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<ThemeMode>(getStoredThemeMode)
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(
getResolvedThemeFromDOM,
)
const [themeMode, setThemeMode] = useState<ThemeMode>('auto')
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>('light')

useEffect(() => {
const storedThemeMode = getStoredThemeMode()
setThemeMode(storedThemeMode)
updateThemeClass(storedThemeMode)
setResolvedTheme(
storedThemeMode === 'auto' ? getSystemTheme() : storedThemeMode,
)
}, [])

// Listen for system theme changes when in auto mode
useEffect(() => {
Expand Down
19 changes: 17 additions & 2 deletions src/components/markdown/MdComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type MdCommentComponentProps = {
'data-component'?: string
'data-files-meta'?: string
'data-package-manager-meta'?: string
preserveTabPanels?: boolean
children?: React.ReactNode
}

Expand All @@ -48,11 +49,25 @@ function isMdFrameworkPanelElement(
)
}

function renderPanelChildren(
panels: Array<React.ReactElement<MdTabPanelProps>>,
preserveTabPanels: boolean,
) {
return panels.map((panel, index) => {
if (!preserveTabPanels) {
return panel.props.children
}

return <React.Fragment key={index}>{panel.props.children}</React.Fragment>
})
}

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)
Expand Down Expand Up @@ -122,7 +137,7 @@ export function MdCommentComponent({

return (
<FileTabs tabs={tabs}>
{panels.map((panel) => panel.props.children)}
{renderPanelChildren(panels, preserveTabPanels)}
</FileTabs>
)
}
Expand All @@ -137,7 +152,7 @@ export function MdCommentComponent({
}

return (
<Tabs tabs={tabs}>{panels.map((panel) => panel.props.children)}</Tabs>
<Tabs tabs={tabs}>{renderPanelChildren(panels, preserveTabPanels)}</Tabs>
)
}

Expand Down
13 changes: 13 additions & 0 deletions src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading
Loading