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}
) : (