-
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
base: staging
Are you sure you want to change the base?
Changes from all commits
9ecb00e
89eadf0
ae9e331
959a560
5df2666
4022c9e
24641a3
0776152
312b5ee
59eedeb
dc48ea8
ce582c0
f0bd78b
511fc6b
462cd81
c308124
f844a6a
89a269e
b7d87c8
55860f6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,6 @@ | ||
| 'use client' | ||
|
|
||
| import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' | ||
| import { createLogger } from '@sim/logger' | ||
| import { Music } from 'lucide-react' | ||
| import dynamic from 'next/dynamic' | ||
| import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' | ||
|
|
@@ -33,7 +32,10 @@ const PdfViewerCore = dynamic(() => import('./pdf-viewer').then((m) => m.PdfView | |
| ssr: false, | ||
| }) | ||
|
|
||
| const logger = createLogger('FileViewer') | ||
| const RichMarkdownEditor = dynamic( | ||
| () => import('./rich-markdown-editor/rich-markdown-editor').then((m) => m.RichMarkdownEditor), | ||
| { ssr: false, loading: () => <PreviewLoadingFrame className='flex flex-1 flex-col' /> } | ||
| ) | ||
|
|
||
| /** | ||
| * CSVs at or below this size load fully into the editor (editable, with an inline preview). | ||
|
|
@@ -50,6 +52,15 @@ export function isPreviewable(file: { type: string; name: string }): boolean { | |
| return resolvePreviewType(file.type, file.name) !== null | ||
| } | ||
|
|
||
| /** | ||
| * Markdown files render in the inline rich editor ({@link RichMarkdownEditor}) rather than | ||
| * the raw Monaco editor. Toolbars use this to hide the raw/split/preview mode controls, | ||
| * which don't apply to the single-surface editor. | ||
| */ | ||
| export function isMarkdownFile(file: { type: string; name: string }): boolean { | ||
| return resolvePreviewType(file.type, file.name) === 'markdown' | ||
| } | ||
|
|
||
| /** | ||
| * A CSV larger than {@link CSV_INLINE_EDIT_MAX_BYTES} is shown as a streamed, read-only preview — | ||
| * the editor would OOM loading the whole file. The viewer renders {@link CsvTablePreview} for it, | ||
|
|
@@ -114,6 +125,14 @@ export function FileViewer({ | |
| if (isCsvStreamOnly(file)) { | ||
| return <UnsupportedPreview file={file} /> | ||
| } | ||
| // Markdown renders through the inline rich editor (non-editable) so the public share | ||
| // surface matches the in-app reading experience; canEdit={false} disables autosave, | ||
| // the bubble menu, and every other editing affordance. | ||
| if (isMarkdownFile(file)) { | ||
| return ( | ||
| <RichMarkdownEditor key={file.id} file={file} workspaceId={workspaceId} canEdit={false} /> | ||
| ) | ||
| } | ||
| return <ReadOnlyTextPreview file={file} workspaceId={workspaceId} /> | ||
| } | ||
| // A large CSV can't be loaded whole into the editor (the browser OOMs on the full text). | ||
|
|
@@ -122,6 +141,25 @@ export function FileViewer({ | |
| return <CsvTablePreview key={file.id} file={file} workspaceId={workspaceId} /> | ||
| } | ||
|
|
||
| if (isMarkdownFile(file)) { | ||
| return ( | ||
| <RichMarkdownEditor | ||
| key={file.id} | ||
| file={file} | ||
| workspaceId={workspaceId} | ||
| canEdit={canEdit} | ||
| autoFocus={autoFocus} | ||
| onDirtyChange={onDirtyChange} | ||
| onSaveStatusChange={onSaveStatusChange} | ||
|
Comment on lines
141
to
+153
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The PR description states "falls back to the raw Monaco editor for any file that can't be edited losslessly," but the code routes all markdown files through This regresses editability for any user whose markdown contains constructs matched by |
||
| saveRef={saveRef} | ||
| streamingContent={streamingContent} | ||
| streamingMode={streamingMode} | ||
| disableStreamingAutoScroll={disableStreamingAutoScroll} | ||
| previewContextKey={previewContextKey} | ||
| /> | ||
| ) | ||
|
waleedlatif1 marked this conversation as resolved.
|
||
| } | ||
|
waleedlatif1 marked this conversation as resolved.
|
||
|
|
||
| return ( | ||
| <TextEditor | ||
| file={file} | ||
|
|
||
| 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' |
| 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) | ||
| }, | ||
| }) |
| 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) | ||
| }) | ||
| }) |
Uh oh!
There was an error while loading. Please reload this page.