-
Notifications
You must be signed in to change notification settings - Fork 3.7k
feat(files): inline rich markdown editor #5133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
waleedlatif1
wants to merge
29
commits into
staging
Choose a base branch
from
feature/inline-rich-markdown-editor
base: staging
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 22 commits
Commits
Show all changes
29 commits
Select commit
Hold shift + click to select a range
9ecb00e
feat(files): inline rich markdown editor
waleedlatif1 89eadf0
fix(files): chain autosave unmount flush after in-flight save
waleedlatif1 ae9e331
fix(files): read pasted images from clipboard items, not just files
waleedlatif1 959a560
fix(files): destroy round-trip probe editor on serialization error
waleedlatif1 5df2666
fix(resource): hold breadcrumb nav latch across the route swap
waleedlatif1 4022c9e
chore(files): drop platform references and non-essential inline comments
waleedlatif1 24641a3
fix(files): scope inline markdown editor to the files view
waleedlatif1 0776152
fix(mothership): use the inline markdown editor in the chat resource …
waleedlatif1 312b5ee
refactor(files): collapse the duplicate raw-editor fallback branch in…
waleedlatif1 59eedeb
fix(mothership): swap to the inline editor once a file preview finish…
waleedlatif1 dc48ea8
Revert "fix(mothership): swap to the inline editor once a file previe…
waleedlatif1 ce582c0
Revert "fix(mothership): use the inline markdown editor in the chat r…
waleedlatif1 f0bd78b
feat(files): rich markdown editor across files + chat, read-only for …
waleedlatif1 511fc6b
chore(files): remove dead code (unused FileViewer logger + EmbeddedWo…
waleedlatif1 462cd81
fix(files): derive markdown round-trip verdict from live content, not…
waleedlatif1 c308124
test(files): guard the rich editor dirty signal — open is never dirty…
waleedlatif1 f844a6a
fix(files): lock the markdown round-trip verdict on opened content, n…
waleedlatif1 89a269e
improvement(file-viewer): reuse shared copy hook, lazy frontmatter split
waleedlatif1 b7d87c8
feat(file-viewer): linked images, typed-link input rule, drag-to-reor…
waleedlatif1 55860f6
improvement(file-viewer): Backspace at start of a heading reverts it …
waleedlatif1 f86f400
fix(file-viewer): don't upload pasted/dropped images into a read-only…
waleedlatif1 f8ac591
fix(file-viewer): sanitize linked-image href; drop global leading-new…
waleedlatif1 1d0fee9
feat(file-viewer): stream agent output directly into the rich editor;…
waleedlatif1 0eb6ce2
fix(sidebar): hydrate collapse state before paint to stop refresh flash
waleedlatif1 6439396
refactor(file-viewer): audit fixes — stale docs, DRY settle-lock, lan…
waleedlatif1 63a4f67
refactor(file-viewer): remove dead markdown-preview renderer now supe…
waleedlatif1 49868aa
refactor(file-viewer): drop dead streamingMode/append path, align nam…
waleedlatif1 6e8bd21
fix(file-viewer): re-lock round-trip verdict + frontmatter on each st…
waleedlatif1 249be95
test(file-viewer): lock link href sanitization for dangerous schemes …
waleedlatif1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
8 changes: 7 additions & 1 deletion
8
apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,10 @@ | ||
| export { resolveFileCategory } from './file-category' | ||
| export type { PreviewMode } from './file-viewer' | ||
| export { FileViewer, isCsvStreamOnly, isPreviewable, isTextEditable } from './file-viewer' | ||
| export { | ||
| FileViewer, | ||
| isCsvStreamOnly, | ||
| isMarkdownFile, | ||
| isPreviewable, | ||
| isTextEditable, | ||
| } from './file-viewer' | ||
| export { PreviewPanel, RICH_PREVIEWABLE_EXTENSIONS, resolvePreviewType } from './preview-panel' |
147 changes: 147 additions & 0 deletions
147
.../workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| import { useState } from 'react' | ||
| import type { JSONContent } from '@tiptap/core' | ||
| import { CodeBlock } from '@tiptap/extension-code-block' | ||
| import type { ReactNodeViewProps } from '@tiptap/react' | ||
| import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' | ||
| import { Check, ChevronDown, Copy, WrapText } from 'lucide-react' | ||
| import { | ||
| chipVariants, | ||
| DropdownMenu, | ||
| DropdownMenuContent, | ||
| DropdownMenuItem, | ||
| DropdownMenuTrigger, | ||
| } from '@/components/emcn' | ||
| import { cn } from '@/lib/core/utils/cn' | ||
| import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' | ||
| import { detectLanguage } from './detect-language' | ||
|
|
||
| const PLAIN = 'plain' | ||
|
|
||
| /** Languages the Prism highlighter has registered (see {@link CodeBlockHighlight}). */ | ||
| const LANGUAGE_OPTIONS = [ | ||
| { value: PLAIN, label: 'Plain text' }, | ||
| { value: 'bash', label: 'Bash' }, | ||
| { value: 'css', label: 'CSS' }, | ||
| { value: 'markup', label: 'HTML' }, | ||
| { value: 'javascript', label: 'JavaScript' }, | ||
| { value: 'json', label: 'JSON' }, | ||
| { value: 'python', label: 'Python' }, | ||
| { value: 'sql', label: 'SQL' }, | ||
| { value: 'typescript', label: 'TypeScript' }, | ||
| { value: 'yaml', label: 'YAML' }, | ||
| ] as const | ||
|
|
||
| const CONTROL_CLASS = | ||
| '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]' | ||
|
|
||
| function CodeBlockView({ node, updateAttributes }: ReactNodeViewProps) { | ||
| const [wrap, setWrap] = useState(false) | ||
| const [menuOpen, setMenuOpen] = useState(false) | ||
| const { copied, copy } = useCopyToClipboard({ resetMs: 1500 }) | ||
| const explicitLanguage = node.attrs.language as string | null | ||
| const language = explicitLanguage ?? detectLanguage(node.textContent) ?? PLAIN | ||
| const label = | ||
| LANGUAGE_OPTIONS.find((option) => option.value === language)?.label ?? | ||
| explicitLanguage ?? | ||
| 'Plain text' | ||
|
|
||
| return ( | ||
| <NodeViewWrapper className='group relative'> | ||
| <div | ||
| className={cn( | ||
| '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', | ||
| menuOpen && 'opacity-100' | ||
| )} | ||
| contentEditable={false} | ||
| > | ||
| <DropdownMenu onOpenChange={setMenuOpen}> | ||
| <DropdownMenuTrigger asChild> | ||
| <button | ||
| type='button' | ||
| aria-label='Code language' | ||
| className={cn( | ||
| chipVariants({ variant: 'default', flush: true }), | ||
| '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)]' | ||
| )} | ||
| > | ||
| {label} | ||
| <ChevronDown className='size-[14px] text-[var(--text-icon)]' /> | ||
| </button> | ||
| </DropdownMenuTrigger> | ||
| <DropdownMenuContent align='end'> | ||
| {LANGUAGE_OPTIONS.map((option) => ( | ||
| <DropdownMenuItem | ||
| key={option.value} | ||
| onSelect={() => | ||
| updateAttributes({ language: option.value === PLAIN ? null : option.value }) | ||
| } | ||
| > | ||
| {option.label} | ||
| </DropdownMenuItem> | ||
| ))} | ||
| </DropdownMenuContent> | ||
| </DropdownMenu> | ||
| <button | ||
| type='button' | ||
| aria-label='Toggle line wrap' | ||
| aria-pressed={wrap} | ||
| onMouseDown={(event) => event.preventDefault()} | ||
| onClick={() => setWrap((value) => !value)} | ||
| className={cn( | ||
| CONTROL_CLASS, | ||
| wrap && 'bg-[var(--surface-active)] text-[var(--text-body)]' | ||
| )} | ||
| > | ||
| <WrapText /> | ||
| </button> | ||
| <button | ||
| type='button' | ||
| aria-label='Copy code' | ||
| onMouseDown={(event) => event.preventDefault()} | ||
| onClick={() => copy(node.textContent)} | ||
| className={CONTROL_CLASS} | ||
| > | ||
| {copied ? <Check /> : <Copy />} | ||
| </button> | ||
| </div> | ||
| <pre className='code-editor-theme pr-20' data-wrap={wrap}> | ||
| <NodeViewContent<'code'> as='code' /> | ||
| </pre> | ||
| </NodeViewWrapper> | ||
| ) | ||
| } | ||
|
|
||
| function codeBlockText(node: JSONContent): string { | ||
| return (node.content ?? []).map((child) => child.text ?? '').join('') | ||
| } | ||
|
|
||
| /** Fence sized to one backtick longer than the longest run inside the code (CommonMark rule). */ | ||
| function fenceFor(text: string): string { | ||
| const longestRun = Math.max(0, ...[...text.matchAll(/`+/g)].map((match) => match[0].length)) | ||
| return '`'.repeat(Math.max(3, longestRun + 1)) | ||
| } | ||
|
|
||
| /** | ||
| * Code block whose markdown serializer sizes the fence to the interior backtick runs, so a code | ||
| * block that itself contains a ``` line round-trips instead of shattering. Shared by the test | ||
| * (plain) and live ({@link CodeBlockWithLanguage}) paths. | ||
| */ | ||
| export const MarkdownCodeBlock = CodeBlock.extend({ | ||
| renderMarkdown: (node: JSONContent) => { | ||
| const language = typeof node.attrs?.language === 'string' ? node.attrs.language : '' | ||
| const text = codeBlockText(node) | ||
| const fence = fenceFor(text) | ||
| return `${fence}${language}\n${text}\n${fence}` | ||
| }, | ||
| }) | ||
|
|
||
| /** | ||
| * Code block with hover-revealed controls (language picker, line-wrap toggle, copy). The | ||
| * `language` attribute drives {@link CodeBlockHighlight}'s Prism highlighting and serializes to | ||
| * the ```lang fence on save; wrap is a view-only preference. | ||
| */ | ||
| export const CodeBlockWithLanguage = MarkdownCodeBlock.extend({ | ||
| addNodeView() { | ||
| return ReactNodeViewRenderer(CodeBlockView) | ||
| }, | ||
| }) |
43 changes: 43 additions & 0 deletions
43
...ce/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| /** | ||
| * @vitest-environment jsdom | ||
| */ | ||
| import { Editor } from '@tiptap/core' | ||
| import { afterEach, describe, expect, it } from 'vitest' | ||
| import { buildDecorations } from './code-highlight' | ||
| import { createMarkdownContentExtensions } from './extensions' | ||
|
|
||
| let editor: Editor | null = null | ||
|
|
||
| function decorationClassesFor(markdown: string): string[] { | ||
| editor = new Editor({ extensions: createMarkdownContentExtensions() }) | ||
| editor.commands.setContent(markdown, { contentType: 'markdown' }) | ||
| const decorations = buildDecorations(editor.state.doc).find() | ||
| editor.destroy() | ||
| editor = null | ||
| return decorations.map( | ||
| (decoration) => | ||
| (decoration as unknown as { type: { attrs: { class: string } } }).type.attrs.class | ||
| ) | ||
| } | ||
|
|
||
| afterEach(() => { | ||
| editor?.destroy() | ||
| editor = null | ||
| }) | ||
|
|
||
| describe('code block syntax highlighting', () => { | ||
| it('emits Prism token decorations for a known language', () => { | ||
| const classes = decorationClassesFor('```js\nconst x = 1\n```') | ||
| expect(classes.length).toBeGreaterThan(0) | ||
| expect(classes.every((c) => c.startsWith('token'))).toBe(true) | ||
| expect(classes.some((c) => c.includes('keyword'))).toBe(true) | ||
| }) | ||
|
|
||
| it('does not decorate plain prose', () => { | ||
| expect(decorationClassesFor('just some text')).toHaveLength(0) | ||
| }) | ||
|
|
||
| it('does not decorate an unregistered language', () => { | ||
| expect(decorationClassesFor('```unregistered-lang\n+++ foo\n```')).toHaveLength(0) | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.