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
10 changes: 8 additions & 2 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,18 @@ Slash menu host items can include `keywords` to match hidden terms without chang

Mount `WikilinkHoverCard` inside `MeowdownEditor` and render host-owned preview
content from the hovered wiki link's `target`. Returning `null` renders no
card.
card. The render function may also return a promise: the card stays closed
until it resolves, resolving to `null` (or rejecting) renders no card, and a
result that lands after the pointer moved on is discarded, so a host can look
up local content without ever flashing an empty card.

```tsx
<MeowdownEditor initialMarkdown="See [[Project plan]]">
<WikilinkHoverCard>
{(hit) => <LocalNotePreview key={hit.target} target={hit.target} />}
{async (hit) => {
const note = await readLocalNote(hit.target)
return note == null ? null : <LocalNotePreview note={note} />
}}
</WikilinkHoverCard>
</MeowdownEditor>
```
Expand Down
116 changes: 114 additions & 2 deletions packages/react/src/components/wikilink-hover-card.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import '../testing/index.ts'

import { createRef } from 'react'
import { describe, expect, it } from 'vitest'
import { createRef, type ReactNode } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-react'
import { page } from 'vitest/browser'

Expand Down Expand Up @@ -94,6 +94,118 @@ describe('WikilinkHoverCard', () => {
await expect.element(card, { timeout: 1000 }).toHaveTextContent('Preview: Known')
})

it('opens with the resolved body of an async render function', async () => {
await unhover()
await render(
<MeowdownEditor initialMarkdown="see [[Note]] here" blockHandle={false}>
<WikilinkHoverCard>
{async (hit) => {
await new Promise((resolve) => setTimeout(resolve, 50))
return <div>Async preview: {hit.target}</div>
}}
</WikilinkHoverCard>
</MeowdownEditor>,
)

await hover(pmRoot.getByTestId('wikilink'))
await expect.element(card).not.toBeInTheDocument()
await expect.element(card, { timeout: 2000 }).toHaveTextContent('Async preview: Note')
})

it('renders no card when the promise resolves to null', async () => {
await unhover()
await render(
<MeowdownEditor initialMarkdown="see [[Missing]] here" blockHandle={false}>
<WikilinkHoverCard>
{async () => {
await new Promise((resolve) => setTimeout(resolve, 50))
return null
}}
</WikilinkHoverCard>
</MeowdownEditor>,
)

await hover(pmRoot.getByTestId('wikilink'))
await new Promise((resolve) => setTimeout(resolve, 600))
await expect.element(card).not.toBeInTheDocument()
})

it('renders no card when the render promise rejects', async () => {
await unhover()
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
await render(
<MeowdownEditor initialMarkdown="see [[Broken]] here" blockHandle={false}>
<WikilinkHoverCard>
{async () => {
await new Promise((resolve) => setTimeout(resolve, 50))
throw new Error('load failed')
}}
</WikilinkHoverCard>
</MeowdownEditor>,
)

await hover(pmRoot.getByTestId('wikilink'))
await new Promise((resolve) => setTimeout(resolve, 600))
await expect.element(card).not.toBeInTheDocument()
expect(consoleError).toHaveBeenCalledWith(
'[meowdown] wikilink hover card body rejected:',
expect.any(Error),
)
consoleError.mockRestore()
})

it('discards a result that resolves after the pointer left', async () => {
await unhover()
let resolveBody: ((node: ReactNode) => void) | undefined
await render(
<MeowdownEditor initialMarkdown="see [[Note]] here" blockHandle={false}>
<WikilinkHoverCard>
{() =>
new Promise<ReactNode>((resolve) => {
resolveBody = resolve
})
}
</WikilinkHoverCard>
</MeowdownEditor>,
)

await hover(pmRoot.getByTestId('wikilink'))
await vi.waitFor(() => expect(resolveBody).toBeDefined())
await unhover()
resolveBody?.(<div>Late preview</div>)
await new Promise((resolve) => setTimeout(resolve, 200))
await expect.element(card).not.toBeInTheDocument()
})

it('shows only the newest target when an older promise resolves late', async () => {
await unhover()
const resolvers = new Map<string, (node: ReactNode) => void>()
await render(
<MeowdownEditor initialMarkdown="[[Alpha]] and [[Beta]]" blockHandle={false}>
<WikilinkHoverCard>
{(hit) =>
new Promise<ReactNode>((resolve) => {
resolvers.set(hit.target, resolve)
})
}
</WikilinkHoverCard>
</MeowdownEditor>,
)
const links = pmRoot.getByTestId('wikilink')

await hover(links.nth(0))
await vi.waitFor(() => expect(resolvers.has('Alpha')).toBe(true))
await hover(links.nth(1))
await vi.waitFor(() => expect(resolvers.has('Beta')).toBe(true))

resolvers.get('Alpha')?.(<div>Preview: Alpha</div>)
await new Promise((resolve) => setTimeout(resolve, 100))
await expect.element(card).not.toBeInTheDocument()

resolvers.get('Beta')?.(<div>Preview: Beta</div>)
await expect.element(card, { timeout: 1000 }).toHaveTextContent('Preview: Beta')
})

it('removes the card when the hovered link is deleted', async () => {
await unhover()
const handleRef = createRef<EditorHandle>()
Expand Down
45 changes: 39 additions & 6 deletions packages/react/src/components/wikilink-hover-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@ const CLOSE_DELAY = 200
export interface WikilinkHoverCardProps {
/**
* Render the card body from the hovered wiki link. Returning `null` renders
* no card.
* no card. A returned promise keeps the card closed until it resolves;
* resolving to `null` or rejecting renders no card, and a result that lands
* after the pointer moved on is discarded. The function runs once per
* hovered link rather than on every render, and a new function identity
* re-runs it for the current link.
*/
readonly children: (hit: WikilinkHoverHit) => ReactNode
readonly children: (hit: WikilinkHoverHit) => ReactNode | Promise<ReactNode>
/** Optional class applied to the popup, after the default card surface. */
readonly className?: string
}
Expand All @@ -32,6 +36,7 @@ export function WikilinkHoverCard({ children, className }: WikilinkHoverCardProp
const lastRectRef = useRef<DOMRect>(null)
const [displayed, setDisplayed] = useState<WikilinkHoverHit>()
const [open, setOpen] = useState(false)
const [body, setBody] = useState<ReactNode>(null)

const [hoverExtension] = useState(() => {
return defineWikilinkHoverHandler((nextHit) => setHit(nextHit))
Expand All @@ -50,11 +55,41 @@ export function WikilinkHoverCard({ children, className }: WikilinkHoverCardProp
return { getBoundingClientRect: getRect }
}, [getRect])

// Resolve the body for the current request. A superseded request's result
// is discarded, and the previous body stays until the new one settles, so
// an open card does not flash empty while moving between links.
useEffect(() => {
let stale = false
const resolveBody = async () => {
try {
const resolved = await (displayed ? children(displayed) : null)
if (!stale) setBody(resolved)
} catch (error) {
if (stale) return
console.error('[meowdown] wikilink hover card body rejected:', error)
setBody(null)
}
}
void resolveBody()
return () => {
stale = true
}
}, [children, displayed])

const hasDisplayed = !!displayed
const hasBody = body != null

useEffect(() => {
if (!hit) {
const timer = setTimeout(() => setOpen(false), CLOSE_DELAY)
// Without a visible body there is no close animation to preserve; the
// request drops right away and the next hover dwells afresh.
const timer = setTimeout(
() => {
setOpen(false)
if (!hasBody) setDisplayed(undefined)
},
hasBody ? CLOSE_DELAY : 0,
)
return () => clearTimeout(timer)
}

Expand All @@ -65,9 +100,7 @@ export function WikilinkHoverCard({ children, className }: WikilinkHoverCardProp
setOpen(true)
}, openDelay)
return () => clearTimeout(timer)
}, [hit, hasDisplayed])

const body = displayed ? children(displayed) : null
}, [hit, hasDisplayed, hasBody])

return (
<PreviewCard.Root
Expand Down
Loading