import { Fragment, useEffect, useMemo, useState, type ReactElement, type ReactNode } from 'react' import { useBridge, useData, usePublish, usePublishText, type PanelRef } from '@jaspers-ai/sdk' import type { Definition, List, ListItem, RootContent, Table } from 'mdast' import { definitionsOf, docOutput, docText, isLineBreak, linkHref, parseDoc, readMarkdown } from './doc' import './styles.css' // The view: the Markdown in the panel's state, read into a tree and drawn as React elements. Nothing // here becomes HTML from a string, so a document cannot inject markup: HTML reads as the text it is, // except
. Links open in the browser through the bridge, https only, since the frame can open // nothing itself. The title, length, and outline go out as the output, and the Markdown as the text. /** How long the Copy button says Copied. */ const COPIED_MS = 1500 /** * The panel's state arrives a round trip after the frame mounts. Until it does there is no document, * and publishing an empty one would say the view shows nothing when it is about to show something. */ export function DocView({ panel }: { panel: PanelRef }): ReactElement { const state = useData(`workspaces/${panel.workspaceId}/panels/${panel.id}/state`) if (state === undefined) { return (

Loading…

) } return } /** What drawing a node needs beyond the node: the way to open a link, and what reference links point at. */ interface Render { open: (href: string) => void definitions: Map } function Doc({ panel, markdown }: { panel: PanelRef; markdown: string }): ReactElement { const bridge = useBridge() const tree = useMemo(() => parseDoc(markdown), [markdown]) const output = useMemo(() => docOutput(tree, markdown), [tree, markdown]) usePublish(panel, output) usePublishText(panel, docText(markdown, output.title)) const render = useMemo( () => ({ open: (href) => void bridge.openLink(href).catch(() => undefined), definitions: definitionsOf(tree), }), [bridge, tree], ) if (markdown.trim() === '') { return (

Empty document.

) } return (
{/* The document scrolls under the button, which stays in the corner. */}
{blocks(tree.children, render)}
) } /** Puts the Markdown on the clipboard through main, since the frame has no clipboard of its own. */ function CopyButton({ markdown }: { markdown: string }): ReactElement { const bridge = useBridge() const [copied, setCopied] = useState(false) useEffect(() => { if (!copied) return const timer = setTimeout(() => setCopied(false), COPIED_MS) return () => clearTimeout(timer) }, [copied]) return ( ) } function blocks(nodes: RootContent[], render: Render): ReactNode[] { return nodes.map((node, i) => block(node, i, render)) } function block(node: RootContent, key: number, render: Render): ReactNode { switch (node.type) { case 'heading': { const Tag = `h${node.depth}` as const return {inlines(node.children, render)} } case 'paragraph': return

{inlines(node.children, render)}

case 'list': return list(node, key, render) case 'code': return (
          {node.value}
        
) case 'blockquote': return
{blocks(node.children, render)}
case 'table': return table(node, key, render) case 'thematicBreak': return
case 'html': return isLineBreak(node.value) ? (
) : (

{node.value}

) case 'footnoteDefinition': return (
[{node.label ?? node.identifier}]
{blocks(node.children, render)}
) case 'definition': return null default: return inline(node, key, render) } } /** A tight list's items hold their words directly, as GitHub draws them; a loose list's hold paragraphs. */ function list(node: List, key: number, render: Render): ReactNode { const loose = node.spread === true || node.children.some((item) => item.spread === true) const items = node.children.map((item, i) => listItem(item, i, loose, render)) return node.ordered ? (
    {items}
) : (
    {items}
) } function listItem(item: ListItem, key: number, loose: boolean, render: Render): ReactNode { const task = typeof item.checked === 'boolean' return (
  • {task && ( {item.checked ? '☑' : '☐'} )} {item.children.map((child, i) => !loose && child.type === 'paragraph' ? {inlines(child.children, render)} : block(child, i, render), )}
  • ) } function table(node: Table, key: number, render: Render): ReactNode { const [head, ...rows] = node.children const align = (column: number) => node.align?.[column] ?? undefined return (
    {head && ( {head.children.map((cell, i) => ( ))} )} {rows.map((row, r) => ( {row.children.map((cell, i) => ( ))} ))}
    {inlines(cell.children, render)}
    {inlines(cell.children, render)}
    ) } function inlines(nodes: RootContent[], render: Render): ReactNode[] { return nodes.map((node, i) => inline(node, i, render)) } function inline(node: RootContent, key: number, render: Render): ReactNode { switch (node.type) { case 'text': return node.value case 'emphasis': return {inlines(node.children, render)} case 'strong': return {inlines(node.children, render)} case 'delete': return {inlines(node.children, render)} case 'inlineCode': return {node.value} case 'break': return
    case 'html': return isLineBreak(node.value) ?
    : node.value case 'link': return link(linkHref(node.url), inlines(node.children, render), key, render) case 'linkReference': return link(reference(node.identifier, render), inlines(node.children, render), key, render) case 'image': return image(linkHref(node.url), node.alt, key, render) case 'imageReference': return image(reference(node.identifier, render), node.alt, key, render) case 'footnoteReference': return ( [{node.label ?? node.identifier}] ) default: return 'children' in node ? {inlines(node.children, render)} : null } } /** An https link opens in the browser through main; any other reads as its words. */ function link(href: string | null, children: ReactNode, key: number, render: Render): ReactNode { if (!href) return {children} return ( { event.preventDefault() render.open(href) }} > {children} ) } /** The frame loads no images, so an https image is a link to itself, named by its description. */ function image(href: string | null, alt: string | null | undefined, key: number, render: Render): ReactNode { const name = alt?.trim() || null return href ? link(href, `Image: ${name ?? href}`, key, render) : name } function reference(identifier: string, render: Render): string | null { const definition = render.definitions.get(identifier) return definition ? linkHref(definition.url) : null }