diff --git a/packages/core/README.md b/packages/core/README.md index 2ea77847..70b38d4b 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -112,7 +112,7 @@ Two things the variable list cannot show: `--meowdown-gutter` is the horizontal Tags (`#tag`) render as pills via the `.md-tag` class, tinted from `--meowdown-accent`. Wire click handling with `defineTagClickHandler(({ tag, event }) => ...)` (or `@meowdown/react`'s `onTagClick` prop); `tag` is read from the rendered text without the leading `#`. -Wikilinks (`[[target]]`/`[[target|alias]]`) render in place via a mark view as an immutable label (the alias, or the target when there is no alias), with the raw source hidden in hide and focus modes and shown dimmed in show mode. The label uses the `.md-wikilink-view-label` class, dashed-underlined and colored by `--meowdown-accent`. In every mark mode the link is a single immutable caret stop: arrowing onto it selects the whole source (ringed with `--meowdown-node-outline` in hide and focus, the native selection over the visible source in show), and Backspace/Delete remove it as a unit. Wire click navigation with `defineWikilinkClickHandler(({ target, event }) => ...)` (or `@meowdown/react`'s `onWikilinkClick` prop); `Mod-Enter` with the caret on a wikilink, tag, or Markdown link fires the same handler, with the `KeyboardEvent` as `event`. +Wikilinks (`[[target]]`/`[[target|alias]]`) render in place via a mark view as an immutable label (the alias, or the target when there is no alias), with the raw source hidden in hide and focus modes and shown dimmed in show mode. The label uses the `.md-wikilink-view-label` class, dashed-underlined and colored by `--meowdown-accent`. In every mark mode the link is a single immutable caret stop: arrowing onto it selects the whole source (ringed with `--meowdown-node-outline` in hide and focus, the native selection over the visible source in show), and Backspace/Delete remove it as a unit. Wire click navigation with `defineWikilinkClickHandler(({ target, event }) => ...)` (or `@meowdown/react`'s `onWikilinkClick` prop); `Mod-Enter` with the caret on a wikilink, tag, or Markdown link fires the same handler, with the `KeyboardEvent` as `event`. `defineWikilinkHoverHandler` reports the hovered target, source range, and visible anchor element, then reports `undefined` on leave, deletion, replacement, or editor teardown. Markdown links (`[text](url)`) render the label as an `` with the `.md-link` class, colored by `--meowdown-accent`; the `[`, `]`, and `(url)` syntax dims in show mode and hides in hide and focus modes. Wire click handling with `defineLinkClickHandler(({ href, event }) => ...)` (or `@meowdown/react`'s `onLinkClick` prop). diff --git a/packages/core/src/extensions/link-hover.test.ts b/packages/core/src/extensions/link-hover.test.ts new file mode 100644 index 00000000..c7daaf8e --- /dev/null +++ b/packages/core/src/extensions/link-hover.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest' +import { page } from 'vitest/browser' + +import { setupFixture } from '../testing/index.ts' + +import { defineLinkHoverHandler, type LinkHoverHandler } from './link-hover.ts' + +const markdownLink = page.locate('.ProseMirror .md-link') + +function applyHoverable(markdown: string, onHoverChange: LinkHoverHandler) { + const fixture = setupFixture() + fixture.editor.use(defineLinkHoverHandler(onHoverChange)) + fixture.set(fixture.n.doc(fixture.n.paragraph(markdown))) + fixture.editor.commands.setMarkMode('hide') + return fixture +} + +describe('Markdown-link hover callback', () => { + it('keeps the hovered link active through an unrelated transaction', async () => { + const onHoverChange = vi.fn() + using fixture = applyHoverable('before [Docs](https://example.com)', onHoverChange) + + await markdownLink.hover() + fixture.view.dispatch(fixture.state.tr.insertText('new ', 1)) + + expect(onHoverChange.mock.calls.map(([hit]) => hit?.payload.href)).toEqual([ + 'https://example.com', + ]) + }) + + it('leaves when the hovered link is deleted without pointer movement', async () => { + const onHoverChange = vi.fn() + using fixture = applyHoverable('before [Docs](https://example.com)', onHoverChange) + + await markdownLink.hover() + fixture.set(fixture.n.doc(fixture.n.paragraph('before Docs'))) + + expect(onHoverChange.mock.calls.map(([hit]) => hit?.payload.href)).toEqual([ + 'https://example.com', + undefined, + ]) + }) + + it('leaves when the hovered link destination is replaced', async () => { + const onHoverChange = vi.fn() + using fixture = applyHoverable('[Docs](https://example.com)', onHoverChange) + + await markdownLink.hover() + fixture.set(fixture.n.doc(fixture.n.paragraph('[Docs](https://example.org)'))) + + expect(onHoverChange.mock.calls.map(([hit]) => hit?.payload.href)).toEqual([ + 'https://example.com', + undefined, + ]) + }) +}) diff --git a/packages/core/src/extensions/link-hover.ts b/packages/core/src/extensions/link-hover.ts index d4755147..a4c91fd6 100644 --- a/packages/core/src/extensions/link-hover.ts +++ b/packages/core/src/extensions/link-hover.ts @@ -11,6 +11,9 @@ export function defineLinkHoverHandler(onHoverChange: LinkHoverHandler): PlainEx findPayloadAt: (state, pos): LinkUnit | undefined => { return getLinkUnitAt(state, pos) }, + isSamePayload: (previous, next) => { + return previous.href === next.href && previous.title === next.title + }, onHoverChange, }) } diff --git a/packages/core/src/extensions/mark-click.ts b/packages/core/src/extensions/mark-click.ts index 4dbb54de..a3a1278b 100644 --- a/packages/core/src/extensions/mark-click.ts +++ b/packages/core/src/extensions/mark-click.ts @@ -1,5 +1,6 @@ import { definePlugin, type PlainExtension } from '@prosekit/core' import { Plugin, type EditorState, type PluginKey } from '@prosekit/pm/state' +import type { EditorView } from '@prosekit/pm/view' export interface MarkClickConfig { key: PluginKey @@ -7,6 +8,8 @@ export interface MarkClickConfig { selector: string /** The payload for the mark covering `pos`, or `undefined` when the click misses it. */ findPayloadAt: (state: EditorState, pos: number) => Payload | undefined + /** Resolve atom mark views from their hidden content holder instead of click coordinates. */ + findPayloadForElement?: (view: EditorView, element: HTMLElement) => Payload | undefined /** Fired when a click lands on the mark. */ onClick: (payload: Payload, event: MouseEvent) => void /** Stops native handling (e.g. `` navigation) before firing. */ @@ -24,8 +27,11 @@ export function defineMarkClickHandler(config: MarkClickConfig props: { handleClick: (view, pos, event) => { const target = event.target as HTMLElement | null - if (!target?.closest?.(config.selector)) return false - const payload = config.findPayloadAt(view.state, pos) + const element = target?.closest?.(config.selector) + if (!element) return false + const payload = config.findPayloadForElement + ? config.findPayloadForElement(view, element) + : config.findPayloadAt(view.state, pos) if (payload == null) return false if (config.preventDefault) event.preventDefault() config.onClick(payload, event) diff --git a/packages/core/src/extensions/mark-hover.ts b/packages/core/src/extensions/mark-hover.ts index 8847c7d9..d0b88f3f 100644 --- a/packages/core/src/extensions/mark-hover.ts +++ b/packages/core/src/extensions/mark-hover.ts @@ -1,6 +1,6 @@ import { isElementLike } from '@ocavue/utils' -import { defineDOMEventHandler, type PlainExtension, union } from '@prosekit/core' -import type { EditorState } from '@prosekit/pm/state' +import { definePlugin, type PlainExtension } from '@prosekit/core' +import { Plugin, type EditorState } from '@prosekit/pm/state' import type { EditorView } from '@prosekit/pm/view' export interface MarkHoverHit { @@ -13,38 +13,93 @@ export interface MarkHoverConfig { selector: string /** The payload for the mark covering `pos`, or `undefined` on a miss. */ findPayloadAt: (state: EditorState, pos: number) => Payload | undefined + /** + * Resolve a hit from its rendered element. Atom mark views should use their + * hidden content holder rather than the event coordinates, which can land on + * an adjacent mark's document boundary. + */ + findPayloadForElement?: (view: EditorView, element: HTMLElement) => Payload | undefined + /** Whether a transaction left the hovered mark semantically unchanged. */ + isSamePayload: (previous: Payload, next: Payload) => boolean /** Fired with the hit on enter, and with `undefined` on leave. */ onHoverChange: (hit: MarkHoverHit | undefined) => void } +/** + * Delegate hover tracking for a rendered mark to the editor root. + * + * Movement within a mark is de-duplicated. The active hit is also revalidated + * after every editor update, so deleting, replacing, or rewriting a hovered + * mark emits leave even when the pointer itself never moves. Destroying the + * editor or removing the extension emits leave as well. + */ export function defineMarkHoverHandler(config: MarkHoverConfig): PlainExtension { - let current: HTMLElement | undefined + let current: MarkHoverHit | undefined + + const findPayloadForElement = (view: EditorView, element: HTMLElement): Payload | undefined => { + return config.findPayloadForElement + ? config.findPayloadForElement(view, element) + : config.findPayloadAt(view.state, view.posAtDOM(element, 0)) + } + + const leave = (): void => { + if (!current) return + current = undefined + config.onHoverChange(undefined) + } const handleOver = (view: EditorView, event: MouseEvent): void => { const target = event.target if (!target || !isElementLike(target)) return const element = target.closest(config.selector) - if (!element || element === current) return + if (!element || !view.dom.contains(element) || element === current?.element) return - const pos = view.posAtDOM(element, 0) - const payload = config.findPayloadAt(view.state, pos) + leave() + const payload = findPayloadForElement(view, element) if (payload == null) return - current = element - config.onHoverChange({ payload, element }) + current = { payload, element } + config.onHoverChange(current) } const handleOut = (event: MouseEvent): void => { if (!current) return // `mouseout` also fires when moving onto a child of the same mark; ignore it. - const related = event.relatedTarget as Node | undefined - if (related && current.contains(related)) return - current = undefined - config.onHoverChange(undefined) + const related = event.relatedTarget + if (related instanceof Node && current.element.contains(related)) return + leave() } - return union( - defineDOMEventHandler('mouseover', (view, event) => handleOver(view, event)), - defineDOMEventHandler('mouseout', (_view, event) => handleOut(event)), + return definePlugin( + new Plugin({ + props: { + handleDOMEvents: { + mouseover: (view, event) => { + handleOver(view, event) + return false + }, + mouseout: (_view, event) => { + handleOut(event) + return false + }, + }, + }, + view: () => ({ + update: (view) => { + if (!current) return + if (!current.element.isConnected || !view.dom.contains(current.element)) { + leave() + return + } + const payload = findPayloadForElement(view, current.element) + if (payload == null || !config.isSamePayload(current.payload, payload)) { + leave() + return + } + current = { ...current, payload } + }, + destroy: leave, + }), + }), ) } diff --git a/packages/core/src/extensions/wikilink-click.test.ts b/packages/core/src/extensions/wikilink-click.test.ts index 520efe49..8d32fd0b 100644 --- a/packages/core/src/extensions/wikilink-click.test.ts +++ b/packages/core/src/extensions/wikilink-click.test.ts @@ -97,10 +97,18 @@ describe('wikilink click callback', () => { expect(onWikilinkClick).not.toHaveBeenCalled() }) - // Known limitation: clicking the non-editable label resolves the document - // position from the click coordinates, so a wide alias label can overshoot the - // source boundary and adjacent `[[a]][[b]]` labels resolve to the neighbor. - // `findWikilinkAt` itself resolves the right range per position (see the unit - // test above); a follow-up should resolve label clicks from the mark view's - // content holder via `posAtDOM`, the way `defineImageClickHandler` does. + it('resolves adjacent wide aliases from their own hidden content holders', async () => { + const onWikilinkClick = vi.fn() + using fixture = setupFixture() + applyClickable( + fixture, + '[[Alpha|An alias much wider than its source]][[Beta|Another very wide alias]]', + onWikilinkClick, + ) + const links = pmRoot.getByTestId('wikilink') + await expect.element(links.nth(0)).toBeInTheDocument() + await userEvent.click(links.nth(0)) + await userEvent.click(links.nth(1)) + expect(onWikilinkClick.mock.calls.map(([payload]) => payload.target)).toEqual(['Alpha', 'Beta']) + }) }) diff --git a/packages/core/src/extensions/wikilink-click.ts b/packages/core/src/extensions/wikilink-click.ts index 5f70df66..ac9b301a 100644 --- a/packages/core/src/extensions/wikilink-click.ts +++ b/packages/core/src/extensions/wikilink-click.ts @@ -1,5 +1,6 @@ import type { PlainExtension } from '@prosekit/core' import { PluginKey, type EditorState } from '@prosekit/pm/state' +import type { EditorView } from '@prosekit/pm/view' import { getMarkRangeAt } from './get-mark-range-at.ts' import type { MdWikilinkAttrs } from './inline-marks.ts' @@ -21,6 +22,24 @@ export function findWikilinkAt(state: EditorState, pos: number): WikilinkHit | u return { from: range.from, to: range.to, target } } +/** + * Resolve the wiki link represented by a visible mark-view element. + * + * The preview label is non-editable and can be much wider than its Markdown + * source. Resolving from click coordinates therefore risks landing on the + * next adjacent mark; the hidden content holder has the exact source position. + */ +export function findWikilinkForElement( + view: EditorView, + element: HTMLElement, +): WikilinkHit | undefined { + const content = element + .closest('.md-wikilink-view') + ?.querySelector('.md-wikilink-view-content') + if (!content) return + return findWikilinkAt(view.state, view.posAtDOM(content, 0)) +} + export interface WikilinkClickPayload { target: string /** The originating click, or the `Mod-Enter` key press that followed the link. */ @@ -35,6 +54,7 @@ export function defineWikilinkClickHandler(onClick: WikilinkClickHandler): Plain selector: '.md-wikilink-view-preview', preventDefault: false, findPayloadAt: (state, pos) => findWikilinkAt(state, pos)?.target, + findPayloadForElement: (view, element) => findWikilinkForElement(view, element)?.target, onClick: (target, event) => onClick({ target, event }), }) } diff --git a/packages/core/src/extensions/wikilink-hover.test.ts b/packages/core/src/extensions/wikilink-hover.test.ts new file mode 100644 index 00000000..e438c72e --- /dev/null +++ b/packages/core/src/extensions/wikilink-hover.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest' +import { page } from 'vitest/browser' + +import { setupFixture } from '../testing/index.ts' + +import { defineWikilinkHoverHandler, type WikilinkHoverHandler } from './wikilink-hover.ts' + +const pmRoot = page.locate('.ProseMirror') + +function applyHoverable(markdown: string, onHoverChange: WikilinkHoverHandler) { + const fixture = setupFixture() + fixture.editor.use(defineWikilinkHoverHandler(onHoverChange)) + fixture.set(fixture.n.doc(fixture.n.paragraph(markdown))) + fixture.editor.commands.setMarkMode('hide') + return fixture +} + +describe('wiki-link hover callback', () => { + it('emits one enter while moving among one link label and its children', async () => { + const onHoverChange = vi.fn() + using fixture = applyHoverable('see [[Note|Wide alias]] here', onHoverChange) + void fixture + const preview = pmRoot.getByTestId('wikilink') + const label = preview.locate('.md-wikilink-view-label') + + await preview.hover() + label.element().dispatchEvent( + new MouseEvent('mouseover', { + bubbles: true, + relatedTarget: preview.element(), + }), + ) + + expect(onHoverChange).toHaveBeenCalledTimes(1) + expect(onHoverChange).toHaveBeenCalledWith( + expect.objectContaining({ target: 'Note', element: preview.element() }), + ) + }) + + it('leaves one adjacent link before entering the next', async () => { + const onHoverChange = vi.fn() + using fixture = applyHoverable( + '[[Alpha|A wide alias]][[Beta|Another wide alias]]', + onHoverChange, + ) + void fixture + const links = pmRoot.getByTestId('wikilink') + + await links.nth(0).hover() + await links.nth(1).hover() + + expect(onHoverChange.mock.calls.map(([hit]) => hit?.target)).toEqual([ + 'Alpha', + undefined, + 'Beta', + ]) + }) + + it('leaves when the hovered link is deleted without pointer movement', async () => { + const onHoverChange = vi.fn() + using fixture = applyHoverable('before [[Note]] after', onHoverChange) + + await pmRoot.getByTestId('wikilink').hover() + fixture.set(fixture.n.doc(fixture.n.paragraph('before after'))) + + expect(onHoverChange.mock.calls.map(([hit]) => hit?.target)).toEqual(['Note', undefined]) + }) + + it('leaves when the hovered link is replaced', async () => { + const onHoverChange = vi.fn() + using fixture = applyHoverable('[[Alpha]]', onHoverChange) + + await pmRoot.getByTestId('wikilink').hover() + fixture.set(fixture.n.doc(fixture.n.paragraph('[[Beta]]'))) + + expect(onHoverChange.mock.calls.map(([hit]) => hit?.target)).toEqual(['Alpha', undefined]) + }) + + it('keeps the same hovered element active through an unrelated transaction', async () => { + const onHoverChange = vi.fn() + using fixture = applyHoverable('before [[Note]]', onHoverChange) + + await pmRoot.getByTestId('wikilink').hover() + fixture.view.dispatch(fixture.state.tr.insertText('new ', 1)) + + expect(onHoverChange.mock.calls.map(([hit]) => hit?.target)).toEqual(['Note']) + }) + + it('leaves when the editor is destroyed', async () => { + const onHoverChange = vi.fn() + { + using fixture = applyHoverable('[[Note]]', onHoverChange) + void fixture + await pmRoot.getByTestId('wikilink').hover() + } + + expect(onHoverChange.mock.calls.map(([hit]) => hit?.target)).toEqual(['Note', undefined]) + }) +}) diff --git a/packages/core/src/extensions/wikilink-hover.ts b/packages/core/src/extensions/wikilink-hover.ts new file mode 100644 index 00000000..a3fca3a2 --- /dev/null +++ b/packages/core/src/extensions/wikilink-hover.ts @@ -0,0 +1,32 @@ +import type { PlainExtension } from '@prosekit/core' + +import { defineMarkHoverHandler } from './mark-hover.ts' +import { findWikilinkAt, findWikilinkForElement, type WikilinkHit } from './wikilink-click.ts' + +/** A wiki link currently under the pointer. */ +export interface WikilinkHoverHit extends WikilinkHit { + /** The rendered wiki-link label used as the popup anchor. */ + element: HTMLElement +} + +/** Called once on wiki-link enter and with `undefined` on leave or invalidation. */ +export type WikilinkHoverHandler = (hit: WikilinkHoverHit | undefined) => void + +/** + * Track the wiki link under the pointer without attaching per-link listeners. + * + * The handler is revalidated after document transactions and receives leave + * when the hovered link is deleted, replaced, or changes target. Moving among + * descendants of one label is de-duplicated. + */ +export function defineWikilinkHoverHandler(onHoverChange: WikilinkHoverHandler): PlainExtension { + return defineMarkHoverHandler({ + selector: '.md-wikilink-view-preview', + findPayloadAt: findWikilinkAt, + findPayloadForElement: findWikilinkForElement, + isSamePayload: (previous, next) => previous.target === next.target, + onHoverChange: (hit) => { + onHoverChange(hit ? { ...hit.payload, element: hit.element } : undefined) + }, + }) +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9663ac8f..2d3f9e65 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -130,6 +130,11 @@ export { type WikilinkClickHandler, type WikilinkClickPayload, } from './extensions/wikilink-click.ts' +export { + defineWikilinkHoverHandler, + type WikilinkHoverHandler, + type WikilinkHoverHit, +} from './extensions/wikilink-hover.ts' export { defineWikilinkTrigger } from './extensions/wikilink-trigger.ts' export type { PositionRange } from './utils/range.ts' export { getSelectedText } from './utils/selected-text.ts' diff --git a/packages/react/README.md b/packages/react/README.md index a06beba0..3d1a1c4a 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -56,6 +56,35 @@ See the full API reference [here](https://npmx.dev/package-docs/@meowdown%2Freac Slash menu host items can include `keywords` to match hidden terms without changing the displayed label. +### Wiki-link hover cards + +Mount `WikilinkHoverCard` inside `MeowdownEditor` and render host-owned preview +content from the wiki-link target. The component handles a 300ms dwell, +transaction-aware dismissal, direct element anchoring, viewport collisions, and +focus neutrality. Call `dismiss` when a target has no preview; the callback is +scoped so a stale request cannot close a newer card. + +```tsx + + + {({ target, dismiss }) => } + + +``` + +For local-only passive preview content, render Markdown with both interaction +and remote embeds disabled. Supply a resolver that accepts only trusted local +image sources. + +```tsx + +``` + ## Styling Import both stylesheets: `@meowdown/core/style.css` (the editor theme and variables) and `@meowdown/react/style.css` (the component layout). The core theme is documented in [`@meowdown/core`](https://www.npmjs.com/package/@meowdown/core). diff --git a/packages/react/src/components/markdown-view.test.tsx b/packages/react/src/components/markdown-view.test.tsx index f525a367..01a2323b 100644 --- a/packages/react/src/components/markdown-view.test.tsx +++ b/packages/react/src/components/markdown-view.test.tsx @@ -74,6 +74,51 @@ describe('MarkdownView', () => { .toHaveAttribute('src', 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ') }) + it('omits recognized embeds before resolving images when renderEmbeds is false', async () => { + const resolveImageUrl = vi.fn((src: string) => src) + await renderView('![](https://x.com/jack/status/20)\n\n![](https://youtu.be/dQw4w9WgXcQ)', { + renderEmbeds: false, + resolveImageUrl, + }) + + expect(view.element().querySelector('iframe')).toBeNull() + expect(resolveImageUrl).not.toHaveBeenCalled() + }) + + it('renders a passive tree when interactive is false', async () => { + const onWikilinkClick = vi.fn() + const onLinkClick = vi.fn() + const onImageClick = vi.fn() + const onTaskClick = vi.fn() + await renderView( + '[[Note]] [Docs](https://example.com) ![cat](https://example.com/cat.png)\n\n![](https://x.com/jack/status/20)\n\n+ [ ] task', + { + interactive: false, + onWikilinkClick, + onLinkClick, + onImageClick, + onTaskClick, + }, + ) + + const root = view.element() + expect(root.querySelector('a')).toBeNull() + expect(root.querySelector('iframe')?.tabIndex).toBe(-1) + expect(root.querySelector('iframe')?.parentElement?.hasAttribute('inert')).toBe(true) + expect( + root.querySelectorAll( + 'button, input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])', + ), + ).toHaveLength(0) + await wikilink.click() + await view.getByText('Docs').click() + await view.getByAltText('cat').click() + expect(onWikilinkClick).not.toHaveBeenCalled() + expect(onLinkClick).not.toHaveBeenCalled() + expect(onImageClick).not.toHaveBeenCalled() + expect(onTaskClick).not.toHaveBeenCalled() + }) + it('highlights a code block with syntax tokens', async () => { await renderView('```rust\nfn main() {}\n```') await expect diff --git a/packages/react/src/components/markdown-view.tsx b/packages/react/src/components/markdown-view.tsx index 191a0fc9..0f24e6cb 100644 --- a/packages/react/src/components/markdown-view.tsx +++ b/packages/react/src/components/markdown-view.tsx @@ -84,6 +84,18 @@ export interface MarkdownViewProps { markMode?: MarkMode /** Peel a leading YAML frontmatter block before rendering. Off by default. */ frontmatter?: boolean + /** + * Whether rendered links, images, and task checkboxes can be activated. + * Defaults to `true`. When `false`, callbacks are ignored and the rendered + * tree contains no anchors or focusable task controls. + */ + interactive?: boolean + /** + * Whether recognized tweet and YouTube image URLs render remote iframes. + * Defaults to `true`. When `false`, recognized embeds are omitted before any + * image resolver runs. + */ + renderEmbeds?: boolean /** Map an image `src` to a displayable URL, or `undefined` to skip it. */ resolveImageUrl?: (src: string) => string | undefined /** Called when a rendered wiki link is clicked. Pass a stable function. */ @@ -99,6 +111,8 @@ export interface MarkdownViewProps { } interface RenderContext { + interactive: boolean + renderEmbeds: boolean resolveImageUrl?: (src: string) => string | undefined onWikilinkClick?: WikilinkClickHandler onLinkClick?: LinkClickHandler @@ -134,6 +148,10 @@ function outputSpecToReact( if (tag === 'input' && attrs?.['type'] === 'checkbox') { reactProps.readOnly = true + if (!context.interactive) { + reactProps.disabled = true + reactProps.tabIndex = -1 + } } const reactChildren = rest.map((child) => outputSpecToReact(child, content, context)) @@ -167,7 +185,13 @@ function WikilinkChip(props: { ) } -function EmbedFrame({ embed }: { embed: EmbedDescriptor }): ReactElement { +function EmbedFrame({ + embed, + interactive, +}: { + embed: EmbedDescriptor + interactive: boolean +}): ReactElement { const iframeRef = useRef(null) useEffect(() => { if (embed.kind !== 'tweet') return @@ -176,7 +200,11 @@ function EmbedFrame({ embed }: { embed: EmbedDescriptor }): ReactElement { return listenForTweetHeight(iframe) }, [embed.kind, embed.key]) return ( - +