Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { normalizeMimeType } from '@/lib/uploads/utils/mime'
import { SUPPORTED_CODE_EXTENSIONS } from '@/lib/uploads/utils/validation'

const TEXT_EDITABLE_MIME_TYPES = new Set([
Expand Down Expand Up @@ -132,6 +133,7 @@ export type FileCategory =
| 'unsupported'

export function resolveFileCategory(mimeType: string | null, filename: string): FileCategory {
mimeType = normalizeMimeType(mimeType)
if (mimeType && TEXT_EDITABLE_MIME_TYPES.has(mimeType)) return 'text-editable'
if (mimeType && IFRAME_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'iframe-previewable'
if (mimeType && IMAGE_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'image-previewable'
Expand All @@ -140,9 +142,11 @@ export function resolveFileCategory(mimeType: string | null, filename: string):
if (mimeType && DOCX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'docx-previewable'
if (mimeType && PPTX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'pptx-previewable'
if (mimeType && XLSX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'xlsx-previewable'
if (mimeType?.startsWith('text/')) return 'text-editable'

const ext = getFileExtension(filename)
const nameKey = ext || filename.toLowerCase()
const sourceName = filename.replace(/\.(example|template|sample|dist|default)$/i, '')
Comment thread
waleedlatif1 marked this conversation as resolved.
const nameKey = getFileExtension(sourceName) || sourceName.toLowerCase()
if (TEXT_EDITABLE_EXTENSIONS.has(nameKey)) return 'text-editable'
if (IFRAME_PREVIEWABLE_EXTENSIONS.has(ext)) return 'iframe-previewable'
if (IMAGE_PREVIEWABLE_EXTENSIONS.has(ext)) return 'image-previewable'
Expand All @@ -151,6 +155,8 @@ export function resolveFileCategory(mimeType: string | null, filename: string):
if (DOCX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'docx-previewable'
if (PPTX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'pptx-previewable'
if (XLSX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'xlsx-previewable'
if (/^\.?env(?: \(\d+\))*(?:\.[\w.-]+)?$/i.test(filename)) return 'text-editable'
if (/^dockerfile\.[\w.-]+$/i.test(filename)) return 'text-editable'

return 'unsupported'
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Music } from '@sim/emcn/icons'
import dynamic from 'next/dynamic'
import type { FileDownloadSource } from '@/lib/uploads/client/download'
import {
MAX_TEXT_PREVIEW_BYTES,
TEXT_PREVIEW_SIZE_MESSAGE,
} from '@/lib/uploads/client/text-content'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { resolveMediaMimeType } from '@/lib/uploads/utils/file-utils'
import {
Expand Down Expand Up @@ -51,12 +55,19 @@ const RichMarkdownEditor = dynamic(
*/
const CSV_INLINE_EDIT_MAX_BYTES = 5 * 1024 * 1024

export function isTextEditable(file: { type: string; name: string }): boolean {
return resolveFileCategory(file.type, file.name) === 'text-editable'
export function isTextEditable(file: {
type: string
name: string
size?: number | null
}): boolean {
return (
resolveFileCategory(file.type, file.name) === 'text-editable' &&
(file.size ?? 0) <= MAX_TEXT_PREVIEW_BYTES
)
}

export function isPreviewable(file: { type: string; name: string }): boolean {
return resolvePreviewType(file.type, file.name) !== null
export function isPreviewable(file: { type: string; name: string; size?: number | null }): boolean {
return resolvePreviewType(file.type, file.name) !== null && isTextEditable(file)
}

/**
Expand Down Expand Up @@ -180,13 +191,17 @@ function FileViewerContent({
const category = resolveFileCategory(file.type, file.name)

if (category === 'text-editable') {
if (isCsvStreamOnly(file)) {
return readOnly ? (
<UnsupportedPreview name={file.name} />
) : (
<CsvTablePreview key={file.id} file={file} workspaceId={workspaceId} />
)
}
if (!isTextEditable(file)) {
return <UnsupportedPreview name={file.name} reason={TEXT_PREVIEW_SIZE_MESSAGE} />
}
if (readOnly) {
// ReadOnlyTextPreview loads the whole file as text; a large CSV would OOM the
// browser. CsvTablePreview's streamed fallback is workspace-only, so on the
// read-only public path a large CSV is download-only.
if (isCsvStreamOnly(file)) {
return <UnsupportedPreview name={file.name} />
}
// 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.
Expand All @@ -203,12 +218,6 @@ function FileViewerContent({
}
return <ReadOnlyTextPreview file={file} workspaceId={workspaceId} />
}
// A large CSV can't be loaded whole into the editor (the browser OOMs on the full text).
// Render a streamed, read-only preview of the first rows + an "Import as a table" path instead.
if (isCsvStreamOnly(file)) {
return <CsvTablePreview key={file.id} file={file} workspaceId={workspaceId} />
}

if (isMarkdownFile(file)) {
return (
<RichMarkdownEditor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useTheme } from 'next-themes'
import '@sim/emcn/components/code/code.css'
import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { normalizeMimeType } from '@/lib/uploads/utils/mime'
import {
SIM_ARTIFACT_SHELL,
SIM_ARTIFACT_STYLESHEET,
Expand Down Expand Up @@ -50,6 +51,7 @@ const PREVIEWABLE_EXTENSIONS: Record<string, PreviewType> = {
export const RICH_PREVIEWABLE_EXTENSIONS = new Set(Object.keys(PREVIEWABLE_EXTENSIONS))

export function resolvePreviewType(mimeType: string | null, filename: string): PreviewType {
mimeType = normalizeMimeType(mimeType)
if (mimeType && PREVIEWABLE_MIME_TYPES[mimeType]) return PREVIEWABLE_MIME_TYPES[mimeType]
const ext = getFileExtension(filename)
return PREVIEWABLE_EXTENSIONS[ext] ?? null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,29 @@ import { getFileExtension } from '@/lib/uploads/utils/file-utils'

const logger = createLogger('FilePreview')

interface UnsupportedPreviewProps {
name: string
reason?: string
}

/**
* Terminal fallback for a file this app cannot render — either the format has no
* viewer at all, or a viewer that was expected to work failed (e.g. a HEIC whose
* server-side derivative could not be produced).
*/
export const UnsupportedPreview = memo(function UnsupportedPreview({ name }: { name: string }) {
export const UnsupportedPreview = memo(function UnsupportedPreview({
name,
reason,
}: UnsupportedPreviewProps) {
const ext = getFileExtension(name)

return (
<div className='flex flex-1 flex-col items-center justify-center gap-[8px]'>
<p className='text-[14px] text-[var(--text-primary)]'>
Preview not available{ext ? ` for .${ext} files` : ' for this file'}
Preview not available{reason ? '' : ext ? ` for .${ext} files` : ' for this file'}
</p>
<p className='text-[var(--text-muted)] text-small'>
Use the download button to view this file
{reason ?? 'Use the download button to view this file'}
</p>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,51 @@
*/
import { Editor } from '@tiptap/core'
import { afterEach, describe, expect, it } from 'vitest'
import { createMarkdownContentExtensions } from './extensions'
import { findHeadingPos } from './heading-anchors'
import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions'
import { scrollToHeading } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors'

let editor: Editor | null = null
afterEach(() => {
editor?.destroy()
editor = null
})

/** A ProseMirror doc parsed from markdown, for the position-resolution tests. */
function docOf(markdown: string) {
function editorOf(markdown: string) {
editor = new Editor({ extensions: createMarkdownContentExtensions() })
editor.commands.setContent(markdown, { contentType: 'markdown' })
return editor.state.doc
return editor
}

describe('findHeadingPos', () => {
it('disambiguates duplicate slugs GitHub-style (foo, foo-1, foo-2)', () => {
const doc = docOf('# Notes\n\na\n\n# Notes\n\nb\n\n# Notes\n\nc')
const first = findHeadingPos(doc, 'notes')
const second = findHeadingPos(doc, 'notes-1')
const third = findHeadingPos(doc, 'notes-2')
expect(first).toBeGreaterThanOrEqual(0)
expect(second).toBeGreaterThan(first)
expect(third).toBeGreaterThan(second)
describe('Markdown heading navigation', () => {
it.each([
['Unicode headings', '# Café 你好', 'café-你好', 0],
['punctuation spacing', '# Network & data', 'network--data', 0],
['existing suffixes', '# Notes\n\n# Notes-1\n\n# Notes', 'notes-2', 2],
] as const)('scrolls to GitHub fragments for %s', (_name, markdown, slug, index) => {
const editor = editorOf(markdown)
const headings = editor.view.dom.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6')
let scrolledTo: HTMLElement | null = null
for (const heading of headings) {
heading.scrollIntoView = () => {
scrolledTo = heading
}
}
expect(scrollToHeading(editor.view, `#${encodeURIComponent(slug)}`)).toBe(true)
expect(scrolledTo).toBe(headings[index])
})

it('scrolls to duplicate headings in document order', () => {
const editor = editorOf('# Notes\n\na\n\n# Notes\n\nb\n\n# Notes\n\nc')
const headings = editor.view.dom.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6')
let scrolledTo: HTMLElement | null = null
for (const heading of headings) {
heading.scrollIntoView = () => {
scrolledTo = heading
}
}
for (const [index, fragment] of ['#notes', '#notes-1', '#notes-2'].entries()) {
expect(scrollToHeading(editor.view, fragment)).toBe(true)
expect(scrolledTo).toBe(headings[index])
}
})
})
Original file line number Diff line number Diff line change
@@ -1,36 +1,32 @@
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import type { EditorView } from '@tiptap/pm/view'
import GithubSlugger from 'github-slugger'

/**
* Slugify heading text GitHub-style (lowercase, drop punctuation, collapse whitespace to hyphens) so
* that `[label](#slug)` fragment links — written against how GitHub renders the same markdown —
* resolve to the matching heading. Mirrors what `rehype-slug` produced in the old preview.
*/
export function slugifyHeading(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
}

/**
* The document position of the heading a `#slug` fragment link targets, or -1 if none matches.
* Computed on demand (at click time) rather than maintained as per-keystroke decorations. Duplicate
* slugs are disambiguated GitHub-style: `intro`, `intro-1`, `intro-2`, …
*/
export function findHeadingPos(doc: ProseMirrorNode, slug: string): number {
const seen = new Map<string, number>()
/** Resolves GitHub heading fragments, including Unicode and collisions with existing suffixes. */
function findHeadingPos(doc: ProseMirrorNode, slug: string): number {
const slugger = new GithubSlugger()
let found = -1
doc.descendants((node, pos) => {
if (found >= 0) return false
if (node.type.name !== 'heading') return true
const base = slugifyHeading(node.textContent)
if (!base) return true
const n = seen.get(base) ?? 0
seen.set(base, n + 1)
if ((n === 0 ? base : `${base}-${n}`) === slug) found = pos
return found < 0
if (slugger.slug(node.textContent) === slug) found = pos
return false
})
return found
}

/** Scrolls within this editor without navigating the page or changing the document selection. */
export function scrollToHeading(view: EditorView, fragment: string): boolean {
let slug: string
try {
slug = decodeURIComponent(fragment.slice(1))
} catch {
return false
}
const pos = slug ? findHeadingPos(view.state.doc, slug) : 0
if (pos < 0) return false
const target = slug ? view.nodeDOM(pos) : view.dom
if (!(target instanceof HTMLElement)) return false
target.scrollIntoView({ behavior: 'smooth', block: 'start' })
return true
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Check, Duplicate, Pencil, Unlink } from '@sim/emcn/icons'
import { getMarkRange } from '@tiptap/core'
import { type Editor, useEditorState } from '@tiptap/react'
import { createPortal } from 'react-dom'
import { scrollToHeading } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors'
import { normalizeLinkHref } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity'
import {
applyLink,
Expand Down Expand Up @@ -38,8 +39,8 @@ function resolveLinkRange(editor: Editor, el: HTMLElement): LinkRange | null {

/**
* Floating card shown when hovering a link, so the destination is visible even when the link text
* differs from the URL. The URL opens in a new tab; Copy is always available, while Edit (inline) and
* Remove require an editable document. Positioned with Floating UI against the hovered anchor; a short
* differs from the URL. Heading links scroll within the editor; other URLs open in a new tab.
* Copy is always available, while Edit (inline) and Remove require an editable document. Positioned with Floating UI against the hovered anchor; a short
* close delay plus the card's own hover bridge let the pointer travel from the link into the card.
*/
export function LinkHoverCard({ editor }: LinkHoverCardProps) {
Expand Down Expand Up @@ -208,7 +209,13 @@ export function LinkHoverCard({ editor }: LinkHoverCardProps) {
{safeHref ? (
<a
href={safeHref}
target='_blank'
target={safeHref.startsWith('#') ? undefined : '_blank'}
onClick={(event) => {
if (!safeHref.startsWith('#')) return
event.preventDefault()
if (!editor.isDestroyed) scrollToHeading(editor.view, safeHref)
Comment thread
waleedlatif1 marked this conversation as resolved.
dismiss()
}}
rel='noopener noreferrer'
title={rawHref}
className='max-w-[260px] truncate px-2 text-[var(--text-body)] text-small hover:underline'
Expand Down
Loading
Loading