diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index 1ff56246..ebf8a650 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -46,6 +46,7 @@ "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", + "recharts": "^3.8.0", "sonner": "^2.0.7", "streamdown": "^1.6.10", "tailwind-merge": "^3.4.0", diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 9f4a2d2a..ba86c638 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -9,6 +9,10 @@ import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload' import { TaskBlockExtension } from '@/extensions/task-block' +import { ImageBlockExtension } from '@/extensions/image-block' +import { EmbedBlockExtension } from '@/extensions/embed-block' +import { ChartBlockExtension } from '@/extensions/chart-block' +import { TableBlockExtension } from '@/extensions/table-block' import { Markdown } from 'tiptap-markdown' import { useEffect, useCallback, useMemo, useRef, useState } from 'react' @@ -136,6 +140,14 @@ function getMarkdownWithBlankLines(editor: Editor): string { blocks.push(listLines.join('\n')) } else if (node.type === 'taskBlock') { blocks.push('```task\n' + (node.attrs?.data as string || '{}') + '\n```') + } else if (node.type === 'imageBlock') { + blocks.push('```image\n' + (node.attrs?.data as string || '{}') + '\n```') + } else if (node.type === 'embedBlock') { + blocks.push('```embed\n' + (node.attrs?.data as string || '{}') + '\n```') + } else if (node.type === 'chartBlock') { + blocks.push('```chart\n' + (node.attrs?.data as string || '{}') + '\n```') + } else if (node.type === 'tableBlock') { + blocks.push('```table\n' + (node.attrs?.data as string || '{}') + '\n```') } else if (node.type === 'codeBlock') { const lang = (node.attrs?.language as string) || '' blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```') @@ -429,6 +441,10 @@ export function MarkdownEditor({ }), ImageUploadPlaceholderExtension, TaskBlockExtension, + ImageBlockExtension, + EmbedBlockExtension, + ChartBlockExtension, + TableBlockExtension, WikiLink.configure({ onCreate: wikiLinks?.onCreate ? (path) => { diff --git a/apps/x/apps/renderer/src/extensions/chart-block.tsx b/apps/x/apps/renderer/src/extensions/chart-block.tsx new file mode 100644 index 00000000..3377b157 --- /dev/null +++ b/apps/x/apps/renderer/src/extensions/chart-block.tsx @@ -0,0 +1,173 @@ +import { mergeAttributes, Node } from '@tiptap/react' +import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' +import { X, BarChart3 } from 'lucide-react' +import { blocks } from '@x/shared' +import { useState, useEffect } from 'react' +import { + LineChart, Line, + BarChart, Bar, + PieChart, Pie, Cell, + XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, +} from 'recharts' + +const CHART_COLORS = ['#8884d8', '#82ca9d', '#ffc658', '#ff7300', '#0088fe', '#00c49f'] + +function ChartBlockView({ node, deleteNode }: { node: { attrs: Record }; deleteNode: () => void }) { + const raw = node.attrs.data as string + let config: blocks.ChartBlock | null = null + + try { + config = blocks.ChartBlockSchema.parse(JSON.parse(raw)) + } catch { + // fallback below + } + + const [fileData, setFileData] = useState[] | null>(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!config?.source) return + setLoading(true) + setError(null) + ;(window as unknown as { ipc: { invoke: (channel: string, args: Record) => Promise } }) + .ipc.invoke('workspace:readFile', { path: config.source, encoding: 'utf-8' }) + .then((content: string) => { + const parsed = JSON.parse(content) + if (Array.isArray(parsed)) { + setFileData(parsed) + } else { + setError('Source file must contain a JSON array') + } + }) + .catch((err: Error) => { + setError(err.message || 'Failed to load data file') + }) + .finally(() => setLoading(false)) + }, [config?.source]) + + if (!config) { + return ( + +
+ + Invalid chart block +
+
+ ) + } + + const data = config.data || fileData + + const renderChart = () => { + if (loading) return
Loading data...
+ if (error) return
{error}
+ if (!data || data.length === 0) return
No data
+ + return ( + + {config!.chart === 'line' ? ( + + + + + + + + + ) : config!.chart === 'bar' ? ( + + + + + + + + + ) : ( + + + + + {data.map((_, index) => ( + + ))} + + + )} + + ) + } + + return ( + +
+ + {config.title &&
{config.title}
} + {renderChart()} +
+
+ ) +} + +export const ChartBlockExtension = Node.create({ + name: 'chartBlock', + group: 'block', + atom: true, + selectable: true, + draggable: false, + + addAttributes() { + return { + data: { + default: '{}', + }, + } + }, + + parseHTML() { + return [ + { + tag: 'pre', + priority: 60, + getAttrs(element) { + const code = element.querySelector('code') + if (!code) return false + const cls = code.className || '' + if (cls.includes('language-chart')) { + return { data: code.textContent || '{}' } + } + return false + }, + }, + ] + }, + + renderHTML({ HTMLAttributes }: { HTMLAttributes: Record }) { + return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'chart-block' })] + }, + + addNodeView() { + return ReactNodeViewRenderer(ChartBlockView) + }, + + addStorage() { + return { + markdown: { + serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) { + state.write('```chart\n' + node.attrs.data + '\n```') + state.closeBlock(node) + }, + parse: { + // handled by parseHTML + }, + }, + } + }, +}) diff --git a/apps/x/apps/renderer/src/extensions/embed-block.tsx b/apps/x/apps/renderer/src/extensions/embed-block.tsx new file mode 100644 index 00000000..b3bc6969 --- /dev/null +++ b/apps/x/apps/renderer/src/extensions/embed-block.tsx @@ -0,0 +1,143 @@ +import { mergeAttributes, Node } from '@tiptap/react' +import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' +import { X, ExternalLink } from 'lucide-react' +import { blocks } from '@x/shared' + +function getEmbedUrl(provider: string, url: string): string | null { + if (provider === 'youtube') { + // Handle youtube.com/watch?v=X and youtu.be/X + const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/) + if (match) return `https://www.youtube.com/embed/${match[1]}` + } + if (provider === 'figma') { + // Convert www.figma.com/design/:key/... → embed.figma.com/design/:key?embed-host=rowboat + const figmaMatch = url.match(/figma\.com\/(design|board|proto)\/([\w-]+)/) + if (figmaMatch) { + return `https://embed.figma.com/${figmaMatch[1]}/${figmaMatch[2]}?embed-host=rowboat` + } + // Legacy /file/ URLs + const legacyMatch = url.match(/figma\.com\/file\/([\w-]+)/) + if (legacyMatch) { + return `https://embed.figma.com/design/${legacyMatch[1]}?embed-host=rowboat` + } + } + return null +} + +function EmbedBlockView({ node, deleteNode }: { node: { attrs: Record }; deleteNode: () => void }) { + const raw = node.attrs.data as string + let config: blocks.EmbedBlock | null = null + + try { + config = blocks.EmbedBlockSchema.parse(JSON.parse(raw)) + } catch { + // fallback below + } + + if (!config) { + return ( + +
+ + Invalid embed block +
+
+ ) + } + + const embedUrl = getEmbedUrl(config.provider, config.url) + + return ( + +
+ + {embedUrl ? ( +
+