Skip to content

Commit a53bec9

Browse files
committed
feat(files): inline rich markdown editor
Replace the raw/preview split for markdown files with a Linear-style inline WYSIWYG editor (TipTap/ProseMirror): bubble + slash menus, code-block language picker with Prism highlighting and line-wrap, resizable images (HTML <img>), GFM tables, and frontmatter held byte-exact out of band. A round-trip preflight gate (decided once per open) falls back to the raw Monaco editor for any file that can't be edited losslessly, so the rich editor never silently corrupts a file.
1 parent 597d7ea commit a53bec9

32 files changed

Lines changed: 2911 additions & 144 deletions

apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -379,26 +379,50 @@ function BreadcrumbLocationPopover({
379379
}: BreadcrumbLocationPopoverProps) {
380380
const [open, setOpen] = useState(false)
381381
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
382+
/**
383+
* Suppresses reopen for the brief window between a click-to-navigate and the
384+
* route swap. Navigating away tears this popover down (the list and detail
385+
* views render different subtrees), so if `open` were still true the dimming
386+
* veil and popover content would snap away instead of fading — a visible
387+
* flash. {@link navigateAndClose} closes the popover before running the
388+
* crumb's handler and latches this so the pointer still resting on the
389+
* trigger can't re-fire `openPopover` mid-navigation. It is cleared on the
390+
* next pointer/focus exit so the popover keeps working when the handler does
391+
* not actually navigate (e.g. an unsaved-changes guard that opens a modal).
392+
*/
393+
const navigatingRef = useRef(false)
382394
const rootBreadcrumb = breadcrumbs[0]
383395

384-
const openPopover = () => {
396+
const clearCloseTimeout = () => {
385397
if (closeTimeoutRef.current) {
386398
clearTimeout(closeTimeoutRef.current)
387399
closeTimeoutRef.current = null
388400
}
401+
}
402+
403+
const openPopover = () => {
404+
if (navigatingRef.current) return
405+
clearCloseTimeout()
389406
setOpen(true)
390407
}
391408

392409
const scheduleClose = () => {
393-
if (closeTimeoutRef.current) {
394-
clearTimeout(closeTimeoutRef.current)
395-
}
410+
navigatingRef.current = false
411+
clearCloseTimeout()
396412
closeTimeoutRef.current = setTimeout(() => {
397413
setOpen(false)
398414
closeTimeoutRef.current = null
399415
}, 120)
400416
}
401417

418+
const navigateAndClose = (onClick?: () => void) => {
419+
if (!onClick) return
420+
navigatingRef.current = true
421+
clearCloseTimeout()
422+
setOpen(false)
423+
onClick()
424+
}
425+
402426
useEffect(() => {
403427
return () => {
404428
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current)
@@ -413,7 +437,7 @@ function BreadcrumbLocationPopover({
413437
<button
414438
type='button'
415439
aria-label={rootBreadcrumb?.label ?? 'Path'}
416-
onClick={rootBreadcrumb?.onClick}
440+
onClick={() => navigateAndClose(rootBreadcrumb?.onClick)}
417441
onFocus={openPopover}
418442
onBlur={scheduleClose}
419443
onMouseEnter={openPopover}
@@ -474,7 +498,7 @@ function BreadcrumbLocationPopover({
474498
key={`${crumb.label}-${index}`}
475499
icon={crumb.icon || (index === 0 ? Icon : undefined)}
476500
label={crumb.label}
477-
onClick={crumb.onClick}
501+
onClick={crumb.onClick ? () => navigateAndClose(crumb.onClick) : undefined}
478502
active={index === breadcrumbs.length - 1}
479503
/>
480504
))}

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ const PdfViewerCore = dynamic(() => import('./pdf-viewer').then((m) => m.PdfView
3333
ssr: false,
3434
})
3535

36+
const MarkdownFileEditor = dynamic(
37+
() => import('./rich-markdown-editor/markdown-file-editor').then((m) => m.MarkdownFileEditor),
38+
{ ssr: false, loading: () => <PreviewLoadingFrame className='flex flex-1 flex-col' /> }
39+
)
40+
3641
const logger = createLogger('FileViewer')
3742

3843
/**
@@ -50,6 +55,15 @@ export function isPreviewable(file: { type: string; name: string }): boolean {
5055
return resolvePreviewType(file.type, file.name) !== null
5156
}
5257

58+
/**
59+
* Markdown files render in the inline rich editor ({@link RichMarkdownEditor}) rather than
60+
* the raw Monaco editor. Toolbars use this to hide the raw/split/preview mode controls,
61+
* which don't apply to the single-surface editor.
62+
*/
63+
export function isMarkdownFile(file: { type: string; name: string }): boolean {
64+
return resolvePreviewType(file.type, file.name) === 'markdown'
65+
}
66+
5367
/**
5468
* A CSV larger than {@link CSV_INLINE_EDIT_MAX_BYTES} is shown as a streamed, read-only preview —
5569
* the editor would OOM loading the whole file. The viewer renders {@link CsvTablePreview} for it,
@@ -106,6 +120,23 @@ export function FileViewer({
106120
return <CsvTablePreview key={file.id} file={file} workspaceId={workspaceId} />
107121
}
108122

123+
// Markdown renders in the inline rich editor when idle. During agent streaming we keep
124+
// the raw/preview editor, which already handles incremental token reconciliation.
125+
if (isMarkdownFile(file) && streamingContent === undefined) {
126+
return (
127+
<MarkdownFileEditor
128+
key={file.id}
129+
file={file}
130+
workspaceId={workspaceId}
131+
canEdit={canEdit}
132+
autoFocus={autoFocus}
133+
onDirtyChange={onDirtyChange}
134+
onSaveStatusChange={onSaveStatusChange}
135+
saveRef={saveRef}
136+
/>
137+
)
138+
}
139+
109140
return (
110141
<TextEditor
111142
file={file}
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
export { resolveFileCategory } from './file-category'
22
export type { PreviewMode } from './file-viewer'
3-
export { FileViewer, isCsvStreamOnly, isPreviewable, isTextEditable } from './file-viewer'
3+
export {
4+
FileViewer,
5+
isCsvStreamOnly,
6+
isMarkdownFile,
7+
isPreviewable,
8+
isTextEditable,
9+
} from './file-viewer'
410
export { RICH_PREVIEWABLE_EXTENSIONS } from './preview-panel'
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { useEffect, useState } from 'react'
2+
import type { JSONContent } from '@tiptap/core'
3+
import { CodeBlock } from '@tiptap/extension-code-block'
4+
import type { ReactNodeViewProps } from '@tiptap/react'
5+
import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
6+
import { Check, ChevronDown, Copy, WrapText } from 'lucide-react'
7+
import {
8+
chipVariants,
9+
DropdownMenu,
10+
DropdownMenuContent,
11+
DropdownMenuItem,
12+
DropdownMenuTrigger,
13+
} from '@/components/emcn'
14+
import { cn } from '@/lib/core/utils/cn'
15+
import { detectLanguage } from './detect-language'
16+
17+
const PLAIN = 'plain'
18+
19+
/** Languages the Prism highlighter has registered (see {@link CodeBlockHighlight}). */
20+
const LANGUAGE_OPTIONS = [
21+
{ value: PLAIN, label: 'Plain text' },
22+
{ value: 'bash', label: 'Bash' },
23+
{ value: 'css', label: 'CSS' },
24+
{ value: 'markup', label: 'HTML' },
25+
{ value: 'javascript', label: 'JavaScript' },
26+
{ value: 'json', label: 'JSON' },
27+
{ value: 'python', label: 'Python' },
28+
{ value: 'sql', label: 'SQL' },
29+
{ value: 'typescript', label: 'TypeScript' },
30+
{ value: 'yaml', label: 'YAML' },
31+
] as const
32+
33+
const CONTROL_CLASS =
34+
'flex size-[24px] items-center justify-center rounded-lg text-[var(--text-icon)] outline-none transition-colors hover-hover:bg-[var(--surface-hover)] hover-hover:text-[var(--text-body)] focus-visible:bg-[var(--surface-hover)] [&_svg]:size-[14px]'
35+
36+
function CodeBlockView({ node, updateAttributes }: ReactNodeViewProps) {
37+
const [wrap, setWrap] = useState(false)
38+
const [copied, setCopied] = useState(false)
39+
const [menuOpen, setMenuOpen] = useState(false)
40+
const explicitLanguage = node.attrs.language as string | null
41+
const language = explicitLanguage ?? detectLanguage(node.textContent) ?? PLAIN
42+
const label =
43+
LANGUAGE_OPTIONS.find((option) => option.value === language)?.label ??
44+
explicitLanguage ??
45+
'Plain text'
46+
47+
useEffect(() => {
48+
if (!copied) return
49+
const timer = setTimeout(() => setCopied(false), 1500)
50+
return () => clearTimeout(timer)
51+
}, [copied])
52+
53+
const copy = async () => {
54+
const ok = await navigator.clipboard
55+
?.writeText(node.textContent)
56+
.then(() => true)
57+
.catch(() => false)
58+
if (ok) setCopied(true)
59+
}
60+
61+
return (
62+
<NodeViewWrapper className='group relative'>
63+
<div
64+
className={cn(
65+
'absolute top-1.5 right-2 z-10 flex items-center gap-0.5 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100',
66+
menuOpen && 'opacity-100'
67+
)}
68+
contentEditable={false}
69+
>
70+
<DropdownMenu onOpenChange={setMenuOpen}>
71+
<DropdownMenuTrigger asChild>
72+
<button
73+
type='button'
74+
aria-label='Code language'
75+
className={cn(
76+
chipVariants({ variant: 'default', flush: true }),
77+
'h-[24px] gap-1 px-1.5 text-[var(--text-muted)] data-[state=open]:bg-[var(--surface-active)] data-[state=open]:text-[var(--text-body)]'
78+
)}
79+
>
80+
{label}
81+
<ChevronDown className='size-[14px] text-[var(--text-icon)]' />
82+
</button>
83+
</DropdownMenuTrigger>
84+
<DropdownMenuContent align='end'>
85+
{LANGUAGE_OPTIONS.map((option) => (
86+
<DropdownMenuItem
87+
key={option.value}
88+
onSelect={() =>
89+
updateAttributes({ language: option.value === PLAIN ? null : option.value })
90+
}
91+
>
92+
{option.label}
93+
</DropdownMenuItem>
94+
))}
95+
</DropdownMenuContent>
96+
</DropdownMenu>
97+
<button
98+
type='button'
99+
aria-label='Toggle line wrap'
100+
aria-pressed={wrap}
101+
onMouseDown={(event) => event.preventDefault()}
102+
onClick={() => setWrap((value) => !value)}
103+
className={cn(
104+
CONTROL_CLASS,
105+
wrap && 'bg-[var(--surface-active)] text-[var(--text-body)]'
106+
)}
107+
>
108+
<WrapText />
109+
</button>
110+
<button
111+
type='button'
112+
aria-label='Copy code'
113+
onMouseDown={(event) => event.preventDefault()}
114+
onClick={copy}
115+
className={CONTROL_CLASS}
116+
>
117+
{copied ? <Check /> : <Copy />}
118+
</button>
119+
</div>
120+
<pre className='code-editor-theme pr-20' data-wrap={wrap}>
121+
<NodeViewContent<'code'> as='code' />
122+
</pre>
123+
</NodeViewWrapper>
124+
)
125+
}
126+
127+
function codeBlockText(node: JSONContent): string {
128+
return (node.content ?? []).map((child) => child.text ?? '').join('')
129+
}
130+
131+
/** Fence sized to one backtick longer than the longest run inside the code (CommonMark rule). */
132+
function fenceFor(text: string): string {
133+
const longestRun = Math.max(0, ...[...text.matchAll(/`+/g)].map((match) => match[0].length))
134+
return '`'.repeat(Math.max(3, longestRun + 1))
135+
}
136+
137+
/**
138+
* Code block whose markdown serializer sizes the fence to the interior backtick runs, so a code
139+
* block that itself contains a ``` line round-trips instead of shattering. Shared by the test
140+
* (plain) and live ({@link CodeBlockWithLanguage}) paths.
141+
*/
142+
export const MarkdownCodeBlock = CodeBlock.extend({
143+
renderMarkdown: (node: JSONContent) => {
144+
const language = typeof node.attrs?.language === 'string' ? node.attrs.language : ''
145+
const text = codeBlockText(node)
146+
const fence = fenceFor(text)
147+
return `${fence}${language}\n${text}\n${fence}`
148+
},
149+
})
150+
151+
/**
152+
* Code block with hover-revealed controls (language picker, line-wrap toggle, copy), in the
153+
* style of Linear's editor. The `language` attribute drives {@link CodeBlockHighlight}'s Prism
154+
* highlighting and serializes to the ```lang fence on save; wrap is a view-only preference.
155+
*/
156+
export const CodeBlockWithLanguage = MarkdownCodeBlock.extend({
157+
addNodeView() {
158+
return ReactNodeViewRenderer(CodeBlockView)
159+
},
160+
})
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { Editor } from '@tiptap/core'
5+
import { afterEach, describe, expect, it } from 'vitest'
6+
import { buildDecorations } from './code-highlight'
7+
import { createMarkdownContentExtensions } from './extensions'
8+
9+
let editor: Editor | null = null
10+
11+
function decorationClassesFor(markdown: string): string[] {
12+
editor = new Editor({ extensions: createMarkdownContentExtensions() })
13+
editor.commands.setContent(markdown, { contentType: 'markdown' })
14+
const decorations = buildDecorations(editor.state.doc).find()
15+
editor.destroy()
16+
editor = null
17+
return decorations.map(
18+
(decoration) =>
19+
(decoration as unknown as { type: { attrs: { class: string } } }).type.attrs.class
20+
)
21+
}
22+
23+
afterEach(() => {
24+
editor?.destroy()
25+
editor = null
26+
})
27+
28+
describe('code block syntax highlighting', () => {
29+
it('emits Prism token decorations for a known language', () => {
30+
const classes = decorationClassesFor('```js\nconst x = 1\n```')
31+
expect(classes.length).toBeGreaterThan(0)
32+
expect(classes.every((c) => c.startsWith('token'))).toBe(true)
33+
expect(classes.some((c) => c.includes('keyword'))).toBe(true)
34+
})
35+
36+
it('does not decorate plain prose', () => {
37+
expect(decorationClassesFor('just some text')).toHaveLength(0)
38+
})
39+
40+
it('does not decorate an unregistered language', () => {
41+
expect(decorationClassesFor('```unregistered-lang\n+++ foo\n```')).toHaveLength(0)
42+
})
43+
})

0 commit comments

Comments
 (0)