Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
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
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
9ecb00e89eadf0ae9e331959a5605df26664022c9e24641a30776152312b5ee59eedebdc48ea8ce582c0f0bd78b511fc6b462cd81c308124f844a6a89a269eb7d87c855860f6f86f400f8ac5911d0fee90eb6ce2643939663a4f6749868aa6e8bd21249be9595416f3e2c7f1e2eaf3aaf8fa284443b8e4d8966b6535798bfc2dd87691d228ebf49868c0b5bb77fc0f334fad14be81caf10dcd9426f9fc5dd3fe50af66d9fcc2d84cFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -371,6 +371,14 @@ interface BreadcrumbLocationPopoverProps { | ||
| veilBoundaryRef: React.RefObject<HTMLDivElement | null> | ||
| } | ||
| /** | ||
| * Grace period before a hover-out dismisses the path popover. Covers the gap | ||
| * the pointer crosses between the trigger and the popover content (and brief | ||
| * jitter at their edges); re-entering either within this window cancels the | ||
| * close. Standard hover-intent close delay — not tied to any navigation timing. | ||
| */ | ||
| const POPOVER_CLOSE_DELAY_MS = 120 | ||
| function BreadcrumbLocationPopover({ | ||
| icon: Icon, | ||
| breadcrumbs, | ||
| @@ -381,22 +389,44 @@ function BreadcrumbLocationPopover({ | ||
| const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) | ||
| const rootBreadcrumb = breadcrumbs[0] | ||
| const openPopover = () => { | ||
| const cancelScheduledClose = () => { | ||
| if (closeTimeoutRef.current) { | ||
| clearTimeout(closeTimeoutRef.current) | ||
| closeTimeoutRef.current = null | ||
| } | ||
| } | ||
| /** | ||
| * Hover-intent open. Driven only by pointer-/keyboard-enter — never by | ||
| * pointer movement. This is what makes the popover dismiss cleanly on a | ||
| * click-to-navigate: a stationary click fires no enter event, so once | ||
| * {@link navigateAndClose} sets `open` false nothing re-opens it before the | ||
| * route swaps. (A move-driven open would re-fire under the resting cursor and | ||
| * flash the popover/veil back in mid-navigation.) | ||
| */ | ||
| const openPopover = () => { | ||
| cancelScheduledClose() | ||
| setOpen(true) | ||
| } | ||
| const scheduleClose = () => { | ||
| if (closeTimeoutRef.current) { | ||
| clearTimeout(closeTimeoutRef.current) | ||
| } | ||
| cancelScheduledClose() | ||
| closeTimeoutRef.current = setTimeout(() => { | ||
| setOpen(false) | ||
| closeTimeoutRef.current = null | ||
| }, 120) | ||
| }, POPOVER_CLOSE_DELAY_MS) | ||
| } | ||
| /** | ||
| * Closes the popover up front, then runs the crumb's handler. Closing first | ||
| * lets the veil fade and the popover play its exit animation instead of | ||
| * snapping away when navigation unmounts the header. | ||
| */ | ||
| const navigateAndClose = (onClick?: () => void) => { | ||
| if (!onClick) return | ||
| cancelScheduledClose() | ||
| setOpen(false) | ||
| onClick() | ||
| } | ||
| useEffect(() => { | ||
| @@ -413,15 +443,11 @@ function BreadcrumbLocationPopover({ | ||
| <button | ||
| type='button' | ||
| aria-label={rootBreadcrumb?.label ?? 'Path'} | ||
| onClick={rootBreadcrumb?.onClick} | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| onClick={() => navigateAndClose(rootBreadcrumb?.onClick)} | ||
| onFocus={openPopover} | ||
| onBlur={scheduleClose} | ||
| onMouseEnter={openPopover} | ||
| onMouseLeave={scheduleClose} | ||
| onMouseMove={openPopover} | ||
| onPointerEnter={openPopover} | ||
| onPointerLeave={scheduleClose} | ||
| onPointerMove={openPopover} | ||
| className={cn( | ||
| chipVariants({ flush: true }), | ||
| 'max-w-none gap-1.5 px-2 transition-colors', | ||
| @@ -457,10 +483,6 @@ function BreadcrumbLocationPopover({ | ||
| )} | ||
| onMouseEnter={openPopover} | ||
| onMouseLeave={scheduleClose} | ||
| onMouseMove={openPopover} | ||
| onPointerEnter={openPopover} | ||
| onPointerLeave={scheduleClose} | ||
| onPointerMove={openPopover} | ||
| > | ||
| <PopoverSection className='px-1.5 py-0.5 text-[var(--text-muted)] text-xs'> | ||
| <span className='inline-flex items-center gap-1'> | ||
| @@ -474,7 +496,7 @@ function BreadcrumbLocationPopover({ | ||
| key={`${crumb.label}-${index}`} | ||
| icon={crumb.icon || (index === 0 ? Icon : undefined)} | ||
| label={crumb.label} | ||
| onClick={crumb.onClick} | ||
| onClick={crumb.onClick ? () => navigateAndClose(crumb.onClick) : undefined} | ||
| active={index === breadcrumbs.length - 1} | ||
| /> | ||
| ))} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,20 +1,14 @@ | ||
| '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' | ||
| import { getFileExtension } from '@/lib/uploads/utils/file-utils' | ||
| import { useWorkspaceFileBinary, useWorkspaceFileContent } from '@/hooks/queries/workspace-files' | ||
| import { resolveFileCategory } from './file-category' | ||
| import type { StreamingMode } from './text-editor-state' | ||
| import { useDocPreviewBinary } from './use-doc-preview-binary' | ||
| export type { StreamingMode } from './text-editor-state' | ||
| import { CsvTablePreview } from './csv-table-preview' | ||
| import { DocxPreview } from './docx-preview' | ||
| import { resolveFileCategory } from './file-category' | ||
| import { ImagePreview } from './image-preview' | ||
| import type { PdfDocumentSource } from './pdf-viewer' | ||
| import { PptxPreview } from './pptx-preview' | ||
| @@ -27,13 +21,17 @@ import { | ||
| resolvePreviewError, | ||
| } from './preview-shared' | ||
| import { TextEditor } from './text-editor' | ||
| import { useDocPreviewBinary } from './use-doc-preview-binary' | ||
| import { XlsxPreview } from './xlsx-preview' | ||
| const PdfViewerCore = dynamic(() => import('./pdf-viewer').then((m) => m.PdfViewerCore), { | ||
| 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 +48,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, | ||
| @@ -84,7 +91,6 @@ interface FileViewerProps { | ||
| onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void | ||
| saveRef?: React.MutableRefObject<(() => Promise<void>) | null> | ||
| streamingContent?: string | ||
| streamingMode?: StreamingMode | ||
| disableStreamingAutoScroll?: boolean | ||
| previewContextKey?: string | ||
| } | ||
| @@ -100,7 +106,6 @@ export function FileViewer({ | ||
| onSaveStatusChange, | ||
| saveRef, | ||
| streamingContent, | ||
| streamingMode, | ||
| disableStreamingAutoScroll = false, | ||
| previewContextKey, | ||
| }: FileViewerProps) { | ||
| @@ -114,6 +119,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 +135,24 @@ 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} | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| saveRef={saveRef} | ||
| streamingContent={streamingContent} | ||
| disableStreamingAutoScroll={disableStreamingAutoScroll} | ||
| previewContextKey={previewContextKey} | ||
| /> | ||
| ) | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| return ( | ||
| <TextEditor | ||
| file={file} | ||
| @@ -133,7 +164,6 @@ export function FileViewer({ | ||
| onSaveStatusChange={onSaveStatusChange} | ||
| saveRef={saveRef} | ||
| streamingContent={streamingContent} | ||
| streamingMode={streamingMode} | ||
| disableStreamingAutoScroll={disableStreamingAutoScroll} | ||
| previewContextKey={previewContextKey} | ||
| /> | ||
| 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' |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.