diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 3b4506dd..aba0a7d4 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -3476,7 +3476,8 @@ function App() { const bodyWithoutTitle = transcriptBody.replace(/^#\s+.+\s*\n*/, '') // Also strip any title/heading the LLM may have generated const cleanedNotes = notes.replace(/^#{1,2}\s+.+\n+/, '') - const newBody = `# ${noteTitle}\n\n` + cleanedNotes + '\n\n---\n\n## Raw transcript\n\n' + bodyWithoutTitle + const transcriptData = JSON.stringify({ transcript: bodyWithoutTitle.trim() }) + const newBody = `# ${noteTitle}\n\n` + cleanedNotes + '\n\n```transcript\n' + transcriptData + '\n```' const newContent = fm ? `${fm}\n${newBody}` : newBody await window.ipc.invoke('workspace:writeFile', { path: notePath, diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 2592dec3..f3ccba2d 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -15,6 +15,7 @@ import { ChartBlockExtension } from '@/extensions/chart-block' import { TableBlockExtension } from '@/extensions/table-block' import { CalendarBlockExtension } from '@/extensions/calendar-block' import { EmailBlockExtension } from '@/extensions/email-block' +import { TranscriptBlockExtension } from '@/extensions/transcript-block' import { Markdown } from 'tiptap-markdown' import { useEffect, useCallback, useMemo, useRef, useState } from 'react' import { Calendar, ChevronDown, ExternalLink } from 'lucide-react' @@ -155,6 +156,8 @@ function getMarkdownWithBlankLines(editor: Editor): string { blocks.push('```calendar\n' + (node.attrs?.data as string || '{}') + '\n```') } else if (node.type === 'emailBlock') { blocks.push('```email\n' + (node.attrs?.data as string || '{}') + '\n```') + } else if (node.type === 'transcriptBlock') { + blocks.push('```transcript\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```') @@ -567,6 +570,7 @@ export function MarkdownEditor({ TableBlockExtension, CalendarBlockExtension, EmailBlockExtension, + TranscriptBlockExtension, WikiLink.configure({ onCreate: wikiLinks?.onCreate ? (path) => { diff --git a/apps/x/apps/renderer/src/extensions/transcript-block.tsx b/apps/x/apps/renderer/src/extensions/transcript-block.tsx new file mode 100644 index 00000000..8893ac13 --- /dev/null +++ b/apps/x/apps/renderer/src/extensions/transcript-block.tsx @@ -0,0 +1,102 @@ +import { mergeAttributes, Node } from '@tiptap/react' +import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' +import { ChevronDown, FileText } from 'lucide-react' +import { blocks } from '@x/shared' +import { useState } from 'react' + +function TranscriptBlockView({ node }: { + node: { attrs: Record } +}) { + const raw = node.attrs.data as string + let config: blocks.TranscriptBlock | null = null + + try { + config = blocks.TranscriptBlockSchema.parse(JSON.parse(raw)) + } catch { + // fallback below + } + + const [expanded, setExpanded] = useState(false) + + if (!config) { + return ( + +
+ + Invalid transcript block +
+
+ ) + } + + return ( + +
e.stopPropagation()}> + + {expanded && ( +
+ {config.transcript} +
+ )} +
+
+ ) +} + +export const TranscriptBlockExtension = Node.create({ + name: 'transcriptBlock', + 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-transcript')) { + return { data: code.textContent || '{}' } + } + return false + }, + }] + }, + + renderHTML({ HTMLAttributes }: { HTMLAttributes: Record }) { + return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'transcript-block' })] + }, + + addNodeView() { + return ReactNodeViewRenderer(TranscriptBlockView) + }, + + addStorage() { + return { + markdown: { + serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) { + state.write('```transcript\n' + node.attrs.data + '\n```') + state.closeBlock(node) + }, + parse: {}, + }, + } + }, +}) diff --git a/apps/x/apps/renderer/src/styles/editor.css b/apps/x/apps/renderer/src/styles/editor.css index 8701099b..1830cde2 100644 --- a/apps/x/apps/renderer/src/styles/editor.css +++ b/apps/x/apps/renderer/src/styles/editor.css @@ -618,7 +618,8 @@ .tiptap-editor .ProseMirror .chart-block-wrapper, .tiptap-editor .ProseMirror .table-block-wrapper, .tiptap-editor .ProseMirror .calendar-block-wrapper, -.tiptap-editor .ProseMirror .email-block-wrapper { +.tiptap-editor .ProseMirror .email-block-wrapper, +.tiptap-editor .ProseMirror .transcript-block-wrapper { margin: 8px 0; } @@ -628,7 +629,8 @@ .tiptap-editor .ProseMirror .table-block-card, .tiptap-editor .ProseMirror .calendar-block-card, .tiptap-editor .ProseMirror .email-block-card, -.tiptap-editor .ProseMirror .email-draft-block-card { +.tiptap-editor .ProseMirror .email-draft-block-card, +.tiptap-editor .ProseMirror .transcript-block-card { position: relative; padding: 12px 14px; border: 1px solid var(--border); @@ -644,7 +646,8 @@ .tiptap-editor .ProseMirror .table-block-card:hover, .tiptap-editor .ProseMirror .calendar-block-card:hover, .tiptap-editor .ProseMirror .email-block-card:hover, -.tiptap-editor .ProseMirror .email-draft-block-card:hover { +.tiptap-editor .ProseMirror .email-draft-block-card:hover, +.tiptap-editor .ProseMirror .transcript-block-card:hover { background-color: color-mix(in srgb, var(--muted) 70%, transparent); } @@ -1488,6 +1491,54 @@ margin-left: 4px; } +/* Transcript block */ +.tiptap-editor .ProseMirror .transcript-block-toggle { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 0; + font-size: 13px; + font-weight: 500; + color: color-mix(in srgb, var(--foreground) 60%, transparent); + background: none; + border: none; + cursor: pointer; + transition: color 0.12s ease; +} + +.tiptap-editor .ProseMirror .transcript-block-toggle:hover { + color: var(--foreground); +} + +.tiptap-editor .ProseMirror .transcript-block-chevron { + transition: transform 0.15s ease; + flex-shrink: 0; +} + +.tiptap-editor .ProseMirror .transcript-block-chevron-open { + transform: rotate(180deg); +} + +.tiptap-editor .ProseMirror .transcript-block-content { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); + font-size: 13px; + line-height: 1.6; + color: color-mix(in srgb, var(--foreground) 70%, transparent); + white-space: pre-wrap; + word-break: break-word; +} + +.tiptap-editor .ProseMirror .transcript-block-error { + display: flex; + align-items: center; + gap: 6px; + color: color-mix(in srgb, var(--foreground) 55%, transparent); + font-size: 13px; +} + /* Meeting event banner */ .meeting-event-banner { position: relative; diff --git a/apps/x/packages/shared/src/blocks.ts b/apps/x/packages/shared/src/blocks.ts index 55d1cd3e..68209051 100644 --- a/apps/x/packages/shared/src/blocks.ts +++ b/apps/x/packages/shared/src/blocks.ts @@ -74,3 +74,9 @@ export const EmailBlockSchema = z.object({ }); export type EmailBlock = z.infer; + +export const TranscriptBlockSchema = z.object({ + transcript: z.string(), +}); + +export type TranscriptBlock = z.infer;