mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(md-render): fix markdown rendering in file viewer (#4458)
* fix(md-render): fix markdown rendering in file viewer * fix(md-render): use Children.map in pre handler for robustness * fix(md-render): add not-italic to fallback code element * fix(md-render): fix cloneElement type error and include tables improvements - Fix TypeScript build error: type isValidElement<Record<string, unknown>> so cloneElement accepts data-block prop - Column sidebar: use CSS min() for responsive width instead of fixed 400px - Table: boolean cell toggle only fires when clicking the checkbox element directly (via data-boolean-cell-toggle), not anywhere on the cell - Table: double-clicking a boolean cell no longer opens edit mode - Table: move row-select mousedown to the <td> to widen the hit target - Table: run/stop button prevents row-select on mousedown - Table: SelectAllCheckbox made keyboard-accessible; checkbox is pointer-events-none
This commit is contained in:
+51
-29
@@ -1,6 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, memo, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
createContext,
|
||||
isValidElement,
|
||||
memo,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import matter from 'gray-matter'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
@@ -10,7 +21,7 @@ import { Streamdown } from 'streamdown'
|
||||
import 'streamdown/styles.css'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { generateShortId } from '@sim/utils/id'
|
||||
import { Checkbox, CopyCodeButton, highlight, languages, Skeleton } from '@/components/emcn'
|
||||
import { Checkbox, highlight, languages, Skeleton } from '@/components/emcn'
|
||||
import '@/components/emcn/components/code/code.css'
|
||||
import 'prismjs/components/prism-bash'
|
||||
import 'prismjs/components/prism-css'
|
||||
@@ -473,7 +484,15 @@ function resolveSimFileUrl(src: string | undefined): string | undefined {
|
||||
}
|
||||
|
||||
const STATIC_MARKDOWN_COMPONENTS = {
|
||||
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
pre: ({ children }: { children?: React.ReactNode }) => (
|
||||
<>
|
||||
{Children.map(children, (child) =>
|
||||
isValidElement<Record<string, unknown>>(child)
|
||||
? cloneElement(child, { 'data-block': 'true' })
|
||||
: child
|
||||
) ?? children}
|
||||
</>
|
||||
),
|
||||
'mermaid-diagram': ({ definition }: { definition?: string }) => {
|
||||
const isStreaming = useContext(MermaidStreamingCtx)
|
||||
return <MermaidDiagram definition={definition ?? ''} isStreaming={isStreaming} />
|
||||
@@ -531,20 +550,11 @@ const STATIC_MARKDOWN_COMPONENTS = {
|
||||
{children}
|
||||
</h6>
|
||||
),
|
||||
inlineCode: ({ children }: { children?: React.ReactNode }) => {
|
||||
if (typeof children === 'string' && children.includes('\n')) {
|
||||
return (
|
||||
<code className='my-4 block overflow-x-auto whitespace-pre-wrap break-words rounded-lg bg-[var(--surface-5)] p-4 font-mono text-[var(--text-primary)] leading-[1.6]'>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<code className='whitespace-normal rounded bg-[var(--surface-5)] px-1.5 py-0.5 font-mono text-[var(--caution)]'>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
inlineCode: ({ children }: { children?: React.ReactNode }) => (
|
||||
<code className='whitespace-normal rounded bg-[var(--surface-5)] px-1.5 py-0.5 font-mono text-[var(--caution)] not-italic'>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
code: ({ children, className }: { children?: React.ReactNode; className?: string }) => {
|
||||
const langMatch = className?.match(/language-(\w+)/)
|
||||
const langRaw = langMatch?.[1] ?? ''
|
||||
@@ -564,22 +574,18 @@ const STATIC_MARKDOWN_COMPONENTS = {
|
||||
|
||||
return (
|
||||
<div className='my-4 overflow-hidden rounded-lg border border-[var(--border)]'>
|
||||
<div className='flex items-center justify-between border-[var(--border)] border-b bg-[var(--surface-3)] px-3 py-1.5'>
|
||||
<div className='border-[var(--border)] border-b bg-[var(--surface-3)] px-3 py-1.5'>
|
||||
<span className='text-[11px] text-[var(--text-tertiary)]'>{langRaw || 'code'}</span>
|
||||
<CopyCodeButton
|
||||
code={codeString}
|
||||
className='-mr-1 text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
|
||||
/>
|
||||
</div>
|
||||
<div className='code-editor-theme bg-[var(--surface-5)]'>
|
||||
{html ? (
|
||||
<pre
|
||||
className='m-0 overflow-x-auto whitespace-pre p-4 font-mono text-[13px] leading-[1.6]'
|
||||
className='m-0 overflow-x-auto whitespace-pre p-4 font-mono text-[13px] not-italic leading-[1.6]'
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
) : (
|
||||
<pre className='m-0 overflow-x-auto whitespace-pre p-4 font-mono text-[13px] text-[var(--text-primary)] leading-[1.6]'>
|
||||
<code>{codeString.trimEnd()}</code>
|
||||
<pre className='m-0 overflow-x-auto whitespace-pre p-4 font-mono text-[13px] text-[var(--text-primary)] not-italic leading-[1.6]'>
|
||||
<code className='not-italic'>{codeString.trimEnd()}</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
@@ -634,7 +640,7 @@ const STATIC_MARKDOWN_COMPONENTS = {
|
||||
<tr className='border-[var(--border)] border-b last:border-b-0'>{children}</tr>
|
||||
),
|
||||
th: ({ children }: { children?: React.ReactNode }) => (
|
||||
<th className='px-3 py-2 text-left font-semibold text-[12px] text-[var(--text-primary)]'>
|
||||
<th className='px-3 py-2 text-left font-semibold text-[13px] text-[var(--text-primary)]'>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
@@ -684,20 +690,36 @@ function LiRenderer({
|
||||
const isTaskItem = typeof className === 'string' && className.includes('task-list-item')
|
||||
|
||||
if (isTaskItem) {
|
||||
const [checkboxChild, ...contentChildren] = Children.toArray(children)
|
||||
const content = <span className='min-w-0 flex-1'>{contentChildren}</span>
|
||||
|
||||
if (ctx) {
|
||||
const offset = node?.position?.start?.offset
|
||||
if (offset === undefined) {
|
||||
return <li className='flex items-start gap-2 break-words leading-[1.6]'>{children}</li>
|
||||
return (
|
||||
<li className='flex items-start gap-2 break-words leading-[1.6]'>
|
||||
{checkboxChild}
|
||||
{content}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
const before = ctx.contentRef.current.slice(0, offset)
|
||||
const prior = before.match(/^(\s*(?:[-*+]|\d+[.)]) +)\[([ xX])\]/gm)
|
||||
return (
|
||||
<CheckboxIndexCtx.Provider value={prior ? prior.length : 0}>
|
||||
<li className='flex items-start gap-2 break-words leading-[1.6]'>{children}</li>
|
||||
<li className='flex items-start gap-2 break-words leading-[1.6]'>
|
||||
{checkboxChild}
|
||||
{content}
|
||||
</li>
|
||||
</CheckboxIndexCtx.Provider>
|
||||
)
|
||||
}
|
||||
return <li className='flex items-start gap-2 break-words leading-[1.6]'>{children}</li>
|
||||
return (
|
||||
<li className='flex items-start gap-2 break-words leading-[1.6]'>
|
||||
{checkboxChild}
|
||||
{content}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
return <li className='break-words leading-[1.6]'>{children}</li>
|
||||
|
||||
@@ -622,7 +622,7 @@ export function Files() {
|
||||
const mimeType = getMimeTypeFromExtension('md')
|
||||
const blob = new Blob([''], { type: mimeType })
|
||||
const file = new File([blob], name, { type: mimeType })
|
||||
const result = await uploadFile.mutateAsync({ workspaceId, file })
|
||||
const result = await uploadFile.mutateAsync({ workspaceId, file, skipToast: true })
|
||||
const fileId = result.file?.id
|
||||
if (fileId) {
|
||||
justCreatedFileIdRef.current = fileId
|
||||
|
||||
+3
-1
@@ -63,6 +63,7 @@ import {
|
||||
} from '@/hooks/queries/tables'
|
||||
import { useWorkflowState, workflowKeys } from '@/hooks/queries/workflows'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
import { COLUMN_SIDEBAR_WIDTH_CSS } from '../table/constants'
|
||||
import { COLUMN_TYPE_OPTIONS, type SidebarColumnType } from './column-types'
|
||||
|
||||
export type ColumnConfigState =
|
||||
@@ -1017,9 +1018,10 @@ export function ColumnSidebar({
|
||||
role='dialog'
|
||||
aria-label='Configure column'
|
||||
className={cn(
|
||||
'absolute top-0 right-0 bottom-0 z-[var(--z-modal)] flex w-[400px] flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--bg)] shadow-overlay transition-transform duration-200 ease-out',
|
||||
'absolute top-0 right-0 bottom-0 z-[var(--z-modal)] flex flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--bg)] shadow-md transition-transform duration-200 ease-out',
|
||||
open ? 'translate-x-0' : 'translate-x-full'
|
||||
)}
|
||||
style={{ width: COLUMN_SIDEBAR_WIDTH_CSS }}
|
||||
>
|
||||
<div className='flex h-full flex-col'>
|
||||
<div className='flex items-center justify-between border-[var(--border)] border-b px-3 py-2'>
|
||||
|
||||
+3
-1
@@ -119,7 +119,9 @@ export function CellContent({
|
||||
<div
|
||||
className={cn('flex min-h-[20px] items-center justify-center', isEditing && 'invisible')}
|
||||
>
|
||||
<Checkbox size='sm' checked={Boolean(value)} className='pointer-events-none' />
|
||||
<span data-boolean-cell-toggle className='inline-flex'>
|
||||
<Checkbox size='sm' checked={Boolean(value)} className='pointer-events-none' />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
} else if (!isNull && column.type === 'json') {
|
||||
|
||||
@@ -4,3 +4,6 @@ export const SELECTION_TINT_BG = 'bg-[rgba(37,99,235,0.06)]'
|
||||
/** Default column width in pixels. Used as a fallback when a column hasn't
|
||||
* been measured yet and as the initial width for newly-added columns. */
|
||||
export const COL_WIDTH = 160
|
||||
|
||||
/** Column config sidebar width: roomy by default, bounded on narrow screens. */
|
||||
export const COLUMN_SIDEBAR_WIDTH_CSS = 'min(480px, calc(100vw - 48px))'
|
||||
|
||||
@@ -66,7 +66,7 @@ import { RowModal } from '../row-modal'
|
||||
import { TableFilter } from '../table-filter'
|
||||
import { CellContent } from './cells/cell-content'
|
||||
import { ExpandedCellPopover } from './cells/expanded-cell-popover'
|
||||
import { COL_WIDTH, SELECTION_TINT_BG } from './constants'
|
||||
import { COL_WIDTH, COLUMN_SIDEBAR_WIDTH_CSS, SELECTION_TINT_BG } from './constants'
|
||||
import { ColumnHeaderMenu } from './headers/column-header-menu'
|
||||
import { COLUMN_TYPE_ICONS } from './headers/column-type-icon'
|
||||
import { WorkflowGroupMetaCell } from './headers/workflow-group-meta-cell'
|
||||
@@ -94,8 +94,6 @@ const COL_WIDTH_AUTO_FIT_MAX = 1000
|
||||
// row-by-row.
|
||||
const CHECKBOX_COL_WIDTH = 56
|
||||
const ADD_COL_WIDTH = 120
|
||||
/** Width of the column-config slideout (matches `column-sidebar.tsx`'s `w-[400px]`). */
|
||||
const COLUMN_SIDEBAR_WIDTH = 400
|
||||
const SKELETON_COL_COUNT = 4
|
||||
const SKELETON_ROW_COUNT = 10
|
||||
const ROW_HEIGHT_ESTIMATE = 35
|
||||
@@ -1218,22 +1216,25 @@ export function Table({
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}, [selectionAnchor, selectionFocus, isColumnSelection])
|
||||
|
||||
const handleCellClick = useCallback((rowId: string, columnName: string) => {
|
||||
const column = columnsRef.current.find((c) => c.name === columnName)
|
||||
if (column?.type === 'boolean') {
|
||||
if (!canEditRef.current) return
|
||||
const row = rowsRef.current.find((r) => r.id === rowId)
|
||||
if (row) {
|
||||
toggleBooleanCell(rowId, columnName, row.data[columnName])
|
||||
const handleCellClick = useCallback(
|
||||
(rowId: string, columnName: string, options?: { toggleBoolean?: boolean }) => {
|
||||
const column = columnsRef.current.find((c) => c.name === columnName)
|
||||
if (column?.type === 'boolean') {
|
||||
if (!options?.toggleBoolean || !canEditRef.current) return
|
||||
const row = rowsRef.current.find((r) => r.id === rowId)
|
||||
if (row) {
|
||||
toggleBooleanCell(rowId, columnName, row.data[columnName])
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const current = editingCellRef.current
|
||||
if (current && current.rowId === rowId && current.columnName === columnName) return
|
||||
setEditingCell(null)
|
||||
setInitialCharacter(null)
|
||||
}, [])
|
||||
const current = editingCellRef.current
|
||||
if (current && current.rowId === rowId && current.columnName === columnName) return
|
||||
setEditingCell(null)
|
||||
setInitialCharacter(null)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// The cell has `select-none` which suppresses programmatic selection, so we
|
||||
// override `user-select` on the inner element until the next click. The popover
|
||||
@@ -1241,6 +1242,9 @@ export function Table({
|
||||
// (workflow cells nest text inside a span with its own `overflow-clip`).
|
||||
const handleCellDoubleClick = useCallback(
|
||||
(rowId: string, columnName: string, columnKey: string) => {
|
||||
const column = columnsRef.current.find((c) => c.key === columnKey)
|
||||
if (column?.type === 'boolean') return
|
||||
|
||||
setSelectionFocus(null)
|
||||
setIsColumnSelection(false)
|
||||
|
||||
@@ -2080,11 +2084,11 @@ export function Table({
|
||||
* The two panels are mutually exclusive (each opener closes the other).
|
||||
*/
|
||||
const logPanelWidth = useLogDetailsUIStore((state) => state.panelWidth)
|
||||
const sidebarReservedPx = configState
|
||||
? COLUMN_SIDEBAR_WIDTH
|
||||
const sidebarReservedWidth = configState
|
||||
? COLUMN_SIDEBAR_WIDTH_CSS
|
||||
: executionDetailsId
|
||||
? logPanelWidth
|
||||
: 0
|
||||
? `${logPanelWidth}px`
|
||||
: '0px'
|
||||
|
||||
const handleConfigureColumn = useCallback((columnName: string) => {
|
||||
setExecutionDetailsId(null)
|
||||
@@ -2578,8 +2582,8 @@ export function Table({
|
||||
<div
|
||||
className='relative h-fit'
|
||||
style={{
|
||||
width: `${tableWidth + sidebarReservedPx}px`,
|
||||
paddingRight: sidebarReservedPx,
|
||||
width: `calc(${tableWidth}px + ${sidebarReservedWidth})`,
|
||||
paddingRight: sidebarReservedWidth,
|
||||
}}
|
||||
>
|
||||
<table
|
||||
@@ -2989,7 +2993,7 @@ interface DataRowProps {
|
||||
initialCharacter: string | null
|
||||
pendingCellValue: Record<string, unknown> | null
|
||||
normalizedSelection: NormalizedSelection | null
|
||||
onClick: (rowId: string, columnName: string) => void
|
||||
onClick: (rowId: string, columnName: string, options?: { toggleBoolean?: boolean }) => void
|
||||
onDoubleClick: (rowId: string, columnName: string, columnKey: string) => void
|
||||
onSave: (rowId: string, columnName: string, value: unknown, reason: SaveReason) => void
|
||||
onCancel: () => void
|
||||
@@ -3115,15 +3119,15 @@ const DataRow = React.memo(function DataRow({
|
||||
|
||||
return (
|
||||
<tr onContextMenu={(e) => onContextMenu(e, row)}>
|
||||
<td className={cn(CELL_CHECKBOX, 'cursor-pointer')}>
|
||||
<td
|
||||
className={cn(CELL_CHECKBOX, 'cursor-pointer')}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
onRowToggle(rowIndex, e.shiftKey)
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center justify-center gap-1'>
|
||||
<div
|
||||
className='group/checkbox flex h-[20px] w-[24px] shrink-0 items-center justify-center'
|
||||
onMouseDown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
onRowToggle(rowIndex, e.shiftKey)
|
||||
}}
|
||||
>
|
||||
<div className='group/checkbox flex h-[20px] w-[24px] shrink-0 items-center justify-center'>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[var(--text-tertiary)] text-xs tabular-nums',
|
||||
@@ -3147,6 +3151,9 @@ const DataRow = React.memo(function DataRow({
|
||||
aria-label={runningCount > 0 ? `Stop ${runningCount} running` : 'Run row'}
|
||||
title={runningCount > 0 ? `Stop ${runningCount} running` : 'Run row'}
|
||||
className='ml-auto flex h-[20px] w-[20px] shrink-0 items-center justify-center rounded text-[var(--text-primary)] transition-colors hover-hover:bg-[var(--surface-2)]'
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={() => {
|
||||
if (runningCount > 0) {
|
||||
onStopRow(row.id)
|
||||
@@ -3192,7 +3199,13 @@ const DataRow = React.memo(function DataRow({
|
||||
onCellMouseDown(rowIndex, colIndex, e.shiftKey)
|
||||
}}
|
||||
onMouseEnter={() => onCellMouseEnter(rowIndex, colIndex)}
|
||||
onClick={() => onClick(row.id, column.name)}
|
||||
onClick={(e) =>
|
||||
onClick(row.id, column.name, {
|
||||
toggleBoolean: Boolean(
|
||||
(e.target as HTMLElement).closest('[data-boolean-cell-toggle]')
|
||||
),
|
||||
})
|
||||
}
|
||||
onDoubleClick={() => onDoubleClick(row.id, column.name, column.key)}
|
||||
>
|
||||
{isHighlighted && (isMultiCell || isRowChecked) && (
|
||||
@@ -3307,9 +3320,23 @@ const SelectAllCheckbox = React.memo(function SelectAllCheckbox({
|
||||
onCheckedChange: () => void
|
||||
}) {
|
||||
return (
|
||||
<th className={CELL_HEADER_CHECKBOX}>
|
||||
<th
|
||||
className={cn(CELL_HEADER_CHECKBOX, 'cursor-pointer')}
|
||||
role='checkbox'
|
||||
aria-checked={checked}
|
||||
tabIndex={0}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
onCheckedChange()
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== ' ' && e.key !== 'Enter') return
|
||||
e.preventDefault()
|
||||
onCheckedChange()
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center justify-center'>
|
||||
<Checkbox size='sm' checked={checked} onCheckedChange={onCheckedChange} />
|
||||
<Checkbox size='sm' checked={checked} className='pointer-events-none' />
|
||||
</div>
|
||||
</th>
|
||||
)
|
||||
|
||||
+1
-1
@@ -345,7 +345,7 @@ export function ImportCsvDialog({
|
||||
<ModalBody>
|
||||
{!parsed ? (
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label>Upload CSV</Label>
|
||||
<Label>Import CSV</Label>
|
||||
<Button
|
||||
type='button'
|
||||
variant='default'
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ export function TablesListContextMenu({
|
||||
{onUploadCsv && (
|
||||
<DropdownMenuItem disabled={disableUpload} onSelect={onUploadCsv}>
|
||||
<Upload />
|
||||
Upload CSV
|
||||
Import CSV
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -450,7 +450,7 @@ export function Tables() {
|
||||
? `${uploadProgress.completed}/${uploadProgress.total}`
|
||||
: uploading
|
||||
? 'Uploading...'
|
||||
: 'Upload CSV'
|
||||
: 'Import CSV'
|
||||
|
||||
const handleCreateTable = useCallback(async () => {
|
||||
const existingNames = tables.map((t) => t.name)
|
||||
|
||||
@@ -205,6 +205,7 @@ interface UploadFileParams {
|
||||
file: File
|
||||
onProgress?: (event: UploadProgressEvent) => void
|
||||
signal?: AbortSignal
|
||||
skipToast?: boolean
|
||||
}
|
||||
|
||||
interface UploadFileResponse {
|
||||
@@ -324,7 +325,9 @@ export function useUploadWorkspaceFile() {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() })
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
toast.success(`Uploaded "${variables.file.name}"`)
|
||||
if (!variables.skipToast) {
|
||||
toast.success(`Uploaded "${variables.file.name}"`)
|
||||
}
|
||||
},
|
||||
onError: (error, variables) => {
|
||||
logger.error('Failed to upload file:', error)
|
||||
|
||||
Reference in New Issue
Block a user