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
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { lazy, Suspense, useContext, useEffect, useState } from 'react'
import {
chipVariants,
cn,
Expand All @@ -11,11 +11,18 @@ import {
import { Check, ChevronDown, Code, Duplicate, Eye, Wrap } from '@sim/emcn/icons'
import type { ReactNodeViewProps } from '@tiptap/react'
import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
import { DASHBOARD_EMBED_LANGUAGE } from '@/lib/dashboards/embed-language'
import { MarkdownStreamingContext } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-streaming-context'
import { looksLikeMermaid, MermaidDiagram } from '../mermaid-diagram'
import { MarkdownCodeBlock } from './code-block-schema'
import { detectLanguage } from './detect-language'
import { useEditorEditable } from './use-editor-editable'

/** Kept out of every rich-markdown surface's graph until a document actually holds a dashboard. */
const DashboardEmbed = lazy(() =>
import('@/components/dashboards/dashboard-embed').then((m) => ({ default: m.DashboardEmbed }))
)

const PLAIN = 'plain'
const MERMAID = 'mermaid'

Expand Down Expand Up @@ -51,7 +58,8 @@ const CONTROL_CLASS =
* whenever the cursor is outside it (and always in read-only), and as editable source while the
* cursor is inside, re-rendering on blur (the Linear/GitHub model). The source `<pre>` stays mounted
* (hidden behind the diagram) so ProseMirror keeps managing its contentDOM, and the node remains an
* ordinary code block, so markdown round-trips unchanged.
* ordinary code block, so markdown round-trips unchanged. A ```dashboard fence renders live
* dashboard panels the same way.
*/
function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeViewProps) {
const [wrap, setWrap] = useState(false)
Expand All @@ -60,16 +68,19 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
const [peekSource, setPeekSource] = useState(false)
const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
const editable = useEditorEditable(editor)
const isStreaming = useContext(MarkdownStreamingContext)

const explicitLanguage = node.attrs.language as string | null
const text = node.textContent
const isMermaid = explicitLanguage === MERMAID || (!explicitLanguage && looksLikeMermaid(text))
const isDashboard = explicitLanguage === DASHBOARD_EMBED_LANGUAGE
const isRendered = isMermaid || isDashboard

// Editable Mermaid shows source while the caret is focused inside the block and re-renders the
// diagram on blur (the Linear/GitHub model). The Show source / Show diagram control drives this by
// focusing into / blurring the block; read-only uses {@link peekSource} since there is no caret.
useEffect(() => {
if (!isMermaid || !editable) {
if (!isRendered || !editable) {
setEditingInline(false)
return
}
Expand All @@ -92,13 +103,13 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
editor.off('focus', sync)
editor.off('blur', sync)
}
}, [editor, getPos, isMermaid, editable])
}, [editor, getPos, isRendered, editable])

const showSource = editable ? editingInline : peekSource
const showDiagram = isMermaid && text.trim().length > 0 && !showSource
const showRendered = isRendered && text.trim().length > 0 && !showSource

// Skip language detection on the mermaid path — the picker/label never render there.
const language = explicitLanguage ?? (isMermaid ? null : detectLanguage(text)) ?? PLAIN
// Skip language detection on rendered blocks — the picker/label never render there.
const language = explicitLanguage ?? (isRendered ? null : detectLanguage(text)) ?? PLAIN
const label =
LANGUAGE_OPTIONS.find((option) => option.value === language)?.label ??
explicitLanguage ??
Expand Down Expand Up @@ -133,18 +144,20 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
)}
contentEditable={false}
>
{isMermaid && (
{isRendered && (
<button
type='button'
aria-label={showSource ? 'Show diagram' : 'Show source'}
aria-label={
showSource ? (isDashboard ? 'Show dashboard' : 'Show diagram') : 'Show source'
}
onMouseDown={(event) => event.preventDefault()}
onClick={toggleSource}
className={CONTROL_CLASS}
>
{showSource ? <Eye /> : <Code />}
</button>
)}
{!isMermaid &&
{!isRendered &&
(editable ? (
// Editable: a language picker. Read-only: a static label — selecting a language calls
// updateAttributes, which would mutate a doc that must not change.
Expand Down Expand Up @@ -180,7 +193,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
{label}
</span>
))}
{!isMermaid && editable && (
{!isRendered && editable && (
<button
type='button'
aria-label='Toggle line wrap'
Expand All @@ -205,22 +218,30 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
{copied ? <Check /> : <Duplicate />}
</button>
</div>
<pre className={cn('code-editor-theme pr-20', showDiagram && 'hidden')} data-wrap={wrap}>
<pre className={cn('code-editor-theme pr-20', showRendered && 'hidden')} data-wrap={wrap}>
<NodeViewContent<'code'> as='code' />
</pre>
{showDiagram && (
// Clicking the diagram selects the whole node (same selection ring as an image/code block)
// instead of dropping a caret inside — preventDefault stops ProseMirror placing the caret,
// which would otherwise flip to source. Editing is an explicit Show source / blur action.
{showRendered && (
// Select the whole node instead of placing a caret, which would flip the block to source.
<div
contentEditable={false}
className={cn(isDashboard && 'dashboard-embed')}
onMouseDown={(event) => {
const target = event.target
if (!(target instanceof Element) || !event.currentTarget.contains(target)) return
if (target.closest('button, input')) return
event.preventDefault()
const pos = typeof getPos === 'function' ? getPos() : null
if (typeof pos === 'number') editor.commands.setNodeSelection(pos)
}}
>
<MermaidDiagram definition={text} className='mermaid-diagram-frame' />
{isDashboard ? (
<Suspense fallback={null}>
<DashboardEmbed source={text} isStreaming={isStreaming} />
</Suspense>
) : (
<MermaidDiagram definition={text} className='mermaid-diagram-frame' />
)}
</div>
)}
</NodeViewWrapper>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { createContext } from 'react'

/** True while agent output is streaming into the editor, for node views that render its content. */
export const MarkdownStreamingContext = createContext(false)
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,12 @@
margin: 1rem 0;
}

/* A dashboard results table sizes its own columns and sits flush in its panel. */
.rich-markdown-nodes .dashboard-embed table {
table-layout: auto;
margin: 0;
}

.rich-markdown-nodes th > p,
.rich-markdown-nodes td > p {
margin: 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity'
import { parseMarkdownToDoc } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse'
import { isPlainTextPaste } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste'
import { MarkdownStreamingContext } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-streaming-context'
import { useEditorMentions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention'
import { EditorBubbleMenu } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu'
import { LinkHoverCard } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card'
Expand Down Expand Up @@ -1439,17 +1440,19 @@ export function LoadedRichMarkdownEditor({
void insertImagesRef.current(images, range)
}}
/>
{showPlaceholder && placeholder && (
<ReadOnlyPlaceholder
content={placeholder.content}
file={file}
workspaceId={workspaceId}
<MarkdownStreamingContext value={isStreaming}>
Comment thread
TheodoreSpeaks marked this conversation as resolved.
{showPlaceholder && placeholder && (
<ReadOnlyPlaceholder
content={placeholder.content}
file={file}
workspaceId={workspaceId}
/>
)}
<EditorContent
editor={editor}
className={cn(EDITOR_SURFACE_CLASS, showPlaceholder && 'hidden')}
/>
)}
<EditorContent
editor={editor}
className={cn(EDITOR_SURFACE_CLASS, showPlaceholder && 'hidden')}
/>
</MarkdownStreamingContext>
</div>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity'
import { parseMarkdownToDoc } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse'
import { isPlainTextPaste } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste'
import { MarkdownStreamingContext } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-streaming-context'
import { useEditorMentions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention'
import { EditorBubbleMenu } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu'
import { LinkHoverCard } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card'
Expand Down Expand Up @@ -444,15 +445,17 @@ function LoadedRichMarkdownField({
}}
/>
)}
<EditorContent
editor={editor}
className={cn(
'flex flex-1 flex-col',
isBare
? proseClassName
: 'selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white'
)}
/>
<MarkdownStreamingContext value={isStreaming}>
<EditorContent
editor={editor}
className={cn(
'flex flex-1 flex-col',
isBare
? proseClassName
: 'selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white'
)}
/>
</MarkdownStreamingContext>
</div>
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* @vitest-environment jsdom
*/
import { Editor } from '@tiptap/core'
import { describe, expect, it } from 'vitest'
import { createMarkdownEditorExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions'
import { SLASH_COMMANDS } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands'

const chart = SLASH_COMMANDS.find((command) => command.title === 'Chart')

describe('Chart slash command', () => {
it.each([
['an empty paragraph', '<p>/chart</p>'],
['the end of existing text', '<p>Intro /chart</p>'],
])('puts the caret at the start of the starter fence from %s', (_, content) => {
const editor = new Editor({
extensions: createMarkdownEditorExtensions({ placeholder: '' }),
content,
})
const end = editor.state.doc.content.size - 1
const from = editor.state.doc.textBetween(0, end).indexOf('/') + 1
chart?.run({ editor, range: { from, to: end } })
const { $from } = editor.state.selection
expect($from.parent.type.name).toBe('codeBlock')
expect($from.parent.attrs.language).toBe('dashboard')
expect($from.parentOffset).toBe(0)
editor.destroy()
})
})
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ComponentType, SVGProps } from 'react'
import {
ChartColumn,
Code,
Heading1,
Heading2,
Expand All @@ -14,6 +15,27 @@ import {
TextQuote,
} from '@sim/emcn/icons'
import type { Editor, Range } from '@tiptap/core'
import { TextSelection } from '@tiptap/pm/state'
import { DASHBOARD_EMBED_LANGUAGE } from '@/lib/dashboards/embed-language'

/** A time-series starter; the table id is left for the author to fill in. */
const DASHBOARD_EMBED_STARTER = `title: Rows over time
time: 7d
source:
tableId: # table id
Comment thread
TheodoreSpeaks marked this conversation as resolved.
blocks:
- chart: Rows per day
source:
groupBy: [createdAt]
bucket: day
aggregate:
rows: { op: count }
option:
xAxis: { type: time }
yAxis: { type: value }
series:
- { type: line, encode: { x: createdAt, y: rows } }
`

export interface SlashCommandContext {
editor: Editor
Expand Down Expand Up @@ -121,6 +143,33 @@ export const SLASH_COMMANDS: readonly SlashCommandItem[] = [
shortcut: '⌘⌥C',
run: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
title: 'Chart',
group: 'Blocks',
icon: ChartColumn,
aliases: ['dashboard', 'graph', 'metric', 'live data'],
run: ({ editor, range }) =>
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: 'codeBlock',
attrs: { language: DASHBOARD_EMBED_LANGUAGE },
content: [{ type: 'text', text: DASHBOARD_EMBED_STARTER }],
})
.command(({ tr }) => {
let fence = -1
tr.doc.nodesBetween(range.from - 1, tr.doc.content.size, (node, pos) => {
if (fence < 0 && node.type.name === 'codeBlock') fence = pos
return fence < 0
})
if (fence < 0) return false
tr.setSelection(TextSelection.create(tr.doc, fence + 1))
return true
})
.run(),
},
{
title: 'Table',
group: 'Blocks',
Expand Down
24 changes: 20 additions & 4 deletions apps/sim/components/charts/echarts-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,23 @@ import { cn } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import type { EChartsType } from 'echarts'
import { useTheme } from 'next-themes'
import { applyChartAnnotations, type ChartAnnotations } from '@/lib/charts/annotations'
import { installBarRowHighlight } from '@/lib/charts/bar-row-highlight'
import { chartSummaryExtension } from '@/lib/charts/summary'
import { applyChartTooltipDefaults, readEmcnChartTheme } from '@/lib/charts/theme'
import {
applyChartTooltipDefaults,
readChartTonePalette,
readEmcnChartTheme,
} from '@/lib/charts/theme'

interface EChartsViewProps {
option: Record<string, unknown>
label: string
className?: string
createController?: (chart: EChartsType) => EChartsController
revision?: string
/** Highlights and thresholds, coloured from the theme at render time. */
annotations?: ChartAnnotations
}

export interface EChartsController {
Expand All @@ -33,22 +40,31 @@ export function EChartsView({
className,
createController,
revision,
annotations,
}: EChartsViewProps) {
const containerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<EChartsType | null>(null)
const controllerRef = useRef<EChartsController | null>(null)
const rowHighlightRef = useRef<(() => void) | null>(null)
const { resolvedTheme } = useTheme()
const [status, setStatus] = useState<{ theme: string | undefined; error?: string } | null>(null)
const optionKey = JSON.stringify(option)
const optionKey = JSON.stringify({ option, annotations })
const applyOption = useEffectEvent((chart: EChartsType, nextOption: string) => {
try {
controllerRef.current?.dispose()
controllerRef.current = null
const controller = createController?.(chart)
controllerRef.current = controller ?? null
const parsed = applyChartTooltipDefaults(JSON.parse(nextOption))
chart.setOption(controller ? controller.prepareOption(parsed) : parsed, { notMerge: true })
const next: { option: Record<string, unknown>; annotations?: ChartAnnotations } =
JSON.parse(nextOption)
const parsed = applyChartTooltipDefaults(next.option)
// Annotate after the bar helpers read the option: the label series would read as a non-bar chart.
const rendered = next.annotations
? applyChartAnnotations(parsed, next.annotations, readChartTonePalette(chart.getDom()))
: parsed
chart.setOption(controller ? controller.prepareOption(rendered) : rendered, {
notMerge: true,
})
controller?.afterUpdate()
rowHighlightRef.current?.()
rowHighlightRef.current = installBarRowHighlight(chart, parsed)
Expand Down
Loading
Loading