mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-31 01:11:53 +08:00
improvement(workflow): add compact code hover previews (#7074)
* improvement(workflow): add compact code hover previews * fix(workflow): address code preview review feedback
This commit is contained in:
committed by
GitHub
parent
98a453d7e6
commit
b515fe0681
+8
@@ -48,6 +48,7 @@ import {
|
||||
resolveCanvasSentence,
|
||||
} from '@/lib/workflows/blocks/canvas-sentence'
|
||||
import { resolveSelectedTriggerId } from '@/lib/workflows/blocks/canvas-trigger-sentence'
|
||||
import { resolveCanvasCodePreview } from '@/lib/workflows/blocks/code-preview'
|
||||
import { calculateWorkflowBlockDimensions } from '@/lib/workflows/blocks/deterministic-dimensions'
|
||||
import { getConditionRows, getRouterRows } from '@/lib/workflows/dynamic-handle-topology'
|
||||
import { getDependsOnFields } from '@/lib/workflows/subblocks/dependencies'
|
||||
@@ -288,6 +289,9 @@ const areSubBlockRowPropsEqual = (
|
||||
const prevValue = subBlockId ? prevProps.allSubBlockValues?.[subBlockId]?.value : undefined
|
||||
const nextValue = subBlockId ? nextProps.allSubBlockValues?.[subBlockId]?.value : undefined
|
||||
const valueEqual = prevValue === nextValue || isEqual(prevValue, nextValue)
|
||||
const codeLanguageEqual =
|
||||
prevProps.subBlock?.type !== 'code' ||
|
||||
prevProps.allSubBlockValues?.language?.value === nextProps.allSubBlockValues?.language?.value
|
||||
|
||||
return (
|
||||
prevProps.title === nextProps.title &&
|
||||
@@ -298,6 +302,7 @@ const areSubBlockRowPropsEqual = (
|
||||
prevProps.workflowId === nextProps.workflowId &&
|
||||
prevProps.blockId === nextProps.blockId &&
|
||||
valueEqual &&
|
||||
codeLanguageEqual &&
|
||||
prevProps.displayAdvancedOptions === nextProps.displayAdvancedOptions &&
|
||||
prevProps.canonicalIndex === nextProps.canonicalIndex &&
|
||||
prevProps.canonicalModeOverrides === nextProps.canonicalModeOverrides &&
|
||||
@@ -596,12 +601,15 @@ const SubBlockRow = memo(function SubBlockRow({
|
||||
webhookUrlDisplayValue ||
|
||||
selectorDisplayName
|
||||
const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value)
|
||||
const codePreview =
|
||||
variant === 'inline-value' ? resolveCanvasCodePreview(subBlock, rawValue, rawValues) : undefined
|
||||
|
||||
return (
|
||||
<SubBlockRowView
|
||||
title={title}
|
||||
displayValue={displayValue}
|
||||
isMonospace={isMonospaceField}
|
||||
codePreview={codePreview}
|
||||
variant={variant}
|
||||
icon={icon}
|
||||
/>
|
||||
|
||||
+2
@@ -17,6 +17,7 @@ import {
|
||||
resolveCanvasSentence,
|
||||
} from '@/lib/workflows/blocks/canvas-sentence'
|
||||
import { resolveSelectedTriggerId } from '@/lib/workflows/blocks/canvas-trigger-sentence'
|
||||
import { resolveCanvasCodePreview } from '@/lib/workflows/blocks/code-preview'
|
||||
import {
|
||||
getDisplayValue,
|
||||
hasDisplayableRowValue,
|
||||
@@ -562,6 +563,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
<SubBlockRowView
|
||||
title={subBlock.title ?? subBlock.id}
|
||||
displayValue={displayValue}
|
||||
codePreview={resolveCanvasCodePreview(subBlock, rawValue, rawValues)}
|
||||
variant='inline-value'
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ApiBlock } from '@/blocks/blocks/api'
|
||||
|
||||
describe('API block redirect policy', () => {
|
||||
describe('API block', () => {
|
||||
it('uses a versioned safe default without changing legacy blocks', () => {
|
||||
const version = ApiBlock.subBlocks.find((subBlock) => subBlock.id === 'redirectPolicyVersion')
|
||||
const sendCredentials = ApiBlock.subBlocks.find(
|
||||
@@ -14,4 +14,11 @@ describe('API block redirect policy', () => {
|
||||
expect(sendCredentials?.mode).toBe('advanced')
|
||||
expect(sendCredentials?.defaultValue).toBe(true)
|
||||
})
|
||||
|
||||
it('marks the request body as JSON for code previews', () => {
|
||||
const body = ApiBlock.subBlocks.find((subBlock) => subBlock.id === 'body')
|
||||
|
||||
expect(body?.type).toBe('code')
|
||||
expect(body?.language).toBe('json')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -66,6 +66,7 @@ export const ApiBlock: BlockConfig<RequestResponse> = {
|
||||
id: 'body',
|
||||
title: 'Body',
|
||||
type: 'code',
|
||||
language: 'json',
|
||||
placeholder: 'Enter JSON...',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveCanvasCodePreview } from '@/lib/workflows/blocks/code-preview'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
|
||||
const CODE_SUBBLOCK: SubBlockConfig = {
|
||||
id: 'code',
|
||||
type: 'code',
|
||||
language: 'javascript',
|
||||
}
|
||||
|
||||
describe('resolveCanvasCodePreview', () => {
|
||||
it('uses the selected language when the block has a language field', () => {
|
||||
expect(
|
||||
resolveCanvasCodePreview(CODE_SUBBLOCK, 'print("hello")', { language: 'python' })
|
||||
).toEqual({
|
||||
code: 'print("hello")',
|
||||
language: 'python',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps the stored shell language to the Prism bash grammar', () => {
|
||||
expect(
|
||||
resolveCanvasCodePreview({ ...CODE_SUBBLOCK, language: 'shell' }, 'echo hello', {})
|
||||
).toEqual({
|
||||
code: 'echo hello',
|
||||
language: 'bash',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the subblock language when the selected language is empty', () => {
|
||||
expect(resolveCanvasCodePreview(CODE_SUBBLOCK, 'return true', { language: '' })).toEqual({
|
||||
code: 'return true',
|
||||
language: 'javascript',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not preview non-code, password, empty, or non-string values', () => {
|
||||
expect(
|
||||
resolveCanvasCodePreview({ ...CODE_SUBBLOCK, type: 'short-input' }, 'hello', {})
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
resolveCanvasCodePreview({ ...CODE_SUBBLOCK, password: true }, 'secret', {})
|
||||
).toBeUndefined()
|
||||
expect(resolveCanvasCodePreview(CODE_SUBBLOCK, ' ', {})).toBeUndefined()
|
||||
expect(resolveCanvasCodePreview(CODE_SUBBLOCK, { source: 'code' }, {})).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { CodePreview, CodePreviewLanguage } from '@sim/workflow-renderer'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
|
||||
/** Maps stored editor languages to the Prism grammar used by the shared viewer. */
|
||||
function resolveCodePreviewLanguage(language: unknown): CodePreviewLanguage {
|
||||
switch (language) {
|
||||
case 'json':
|
||||
case 'python':
|
||||
case 'javascript':
|
||||
return language
|
||||
case 'shell':
|
||||
return 'bash'
|
||||
default:
|
||||
return 'javascript'
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds a rich preview only for safe, non-empty code fields on the canvas. */
|
||||
export function resolveCanvasCodePreview(
|
||||
subBlock: SubBlockConfig | undefined,
|
||||
rawValue: unknown,
|
||||
values: Readonly<Record<string, unknown>>
|
||||
): CodePreview | undefined {
|
||||
if (
|
||||
subBlock?.type !== 'code' ||
|
||||
subBlock.password === true ||
|
||||
typeof rawValue !== 'string' ||
|
||||
rawValue.trim().length === 0
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const language =
|
||||
typeof values.language === 'string' && values.language.length > 0
|
||||
? values.language
|
||||
: subBlock.language
|
||||
return { code: rawValue, language: resolveCodePreviewLanguage(language) }
|
||||
}
|
||||
@@ -116,6 +116,7 @@ function highlightOrEscape(prism: PrismModule | null, text: string, language: st
|
||||
* All code editors in the app should use these values for consistency.
|
||||
*/
|
||||
export const CODE_LINE_HEIGHT_PX = 21
|
||||
const COMPACT_CODE_LINE_HEIGHT_PX = 20
|
||||
|
||||
/**
|
||||
* Gutter width values based on the number of digits in line numbers.
|
||||
@@ -679,6 +680,8 @@ interface CodeRowProps {
|
||||
showGutter: boolean
|
||||
/** Custom styles for the gutter */
|
||||
gutterStyle?: React.CSSProperties
|
||||
/** Visual density for read-only code. */
|
||||
density: CodeViewerDensity
|
||||
/** Left offset for alignment */
|
||||
leftOffset: number
|
||||
/** Whether to wrap long lines */
|
||||
@@ -703,6 +706,7 @@ function CodeRow({
|
||||
gutterWidth,
|
||||
showGutter,
|
||||
gutterStyle,
|
||||
density,
|
||||
leftOffset,
|
||||
wrapText,
|
||||
showCollapseColumn,
|
||||
@@ -718,7 +722,10 @@ function CodeRow({
|
||||
<div className={cn('flex', wrapText && 'overflow-hidden')} data-row-index={index}>
|
||||
{showGutter && (
|
||||
<div
|
||||
className='flex-shrink-0 select-none pr-0.5 text-right text-[var(--text-muted)] text-xs tabular-nums leading-[21px] dark:text-[var(--code-line-number)]'
|
||||
className={cn(
|
||||
'flex-shrink-0 select-none pr-0.5 text-right text-[var(--text-muted)] tabular-nums dark:text-[var(--code-line-number)]',
|
||||
density === 'compact' ? 'text-caption leading-5' : 'text-xs leading-[21px]'
|
||||
)}
|
||||
style={{ width: gutterWidth, marginLeft: leftOffset, ...gutterStyle }}
|
||||
>
|
||||
{line.lineNumber}
|
||||
@@ -740,7 +747,8 @@ function CodeRow({
|
||||
)}
|
||||
<pre
|
||||
className={cn(
|
||||
'm-0 flex-1 pr-2 pl-2 font-mono text-[var(--text-primary)] text-small leading-[21px] dark:text-[var(--code-foreground)]',
|
||||
'm-0 flex-1 pr-2 pl-2 font-mono text-[var(--text-primary)] dark:text-[var(--code-foreground)]',
|
||||
density === 'compact' ? 'text-caption leading-5' : 'text-small leading-[21px]',
|
||||
wrapText ? 'min-w-0 whitespace-pre-wrap break-words' : 'whitespace-pre'
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: line.html || ' ' }}
|
||||
@@ -796,6 +804,8 @@ function applySearchHighlightingToLine(
|
||||
/**
|
||||
* Props for the Code.Viewer component (readonly code display).
|
||||
*/
|
||||
type CodeViewerDensity = 'default' | 'compact'
|
||||
|
||||
interface CodeViewerProps {
|
||||
/** Code content to display */
|
||||
code: string
|
||||
@@ -805,6 +815,8 @@ interface CodeViewerProps {
|
||||
language?: 'javascript' | 'json' | 'python' | 'bash'
|
||||
/** Additional CSS classes for the container */
|
||||
className?: string
|
||||
/** Visual density for read-only code. */
|
||||
density?: CodeViewerDensity
|
||||
/** Left padding offset (useful for terminal alignment) */
|
||||
paddingLeft?: number
|
||||
/** Inline styles for the gutter (e.g., to override background) */
|
||||
@@ -891,6 +903,8 @@ type ViewerInnerProps = {
|
||||
language: 'javascript' | 'json' | 'python' | 'bash'
|
||||
/** Additional CSS classes for the container */
|
||||
className?: string
|
||||
/** Visual density for read-only code. */
|
||||
density: CodeViewerDensity
|
||||
/** Left padding offset in pixels */
|
||||
paddingLeft: number
|
||||
/** Custom styles for the gutter */
|
||||
@@ -918,6 +932,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
|
||||
showGutter,
|
||||
language,
|
||||
className,
|
||||
density,
|
||||
paddingLeft,
|
||||
gutterStyle,
|
||||
wrapText,
|
||||
@@ -1010,15 +1025,16 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
|
||||
const virtualizer = useVirtualizer({
|
||||
count: visibleLines.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => CODE_LINE_HEIGHT_PX,
|
||||
estimateSize: () => (density === 'compact' ? COMPACT_CODE_LINE_HEIGHT_PX : CODE_LINE_HEIGHT_PX),
|
||||
overscan: 5,
|
||||
})
|
||||
|
||||
/**
|
||||
* Drop cached row measurements when leaving wrap mode: the measureElement
|
||||
* refs detach with their wrapped heights still cached, and falling back to
|
||||
* the fixed estimate is exactly correct for nowrap rows. Entering wrap needs
|
||||
* no reset — refs re-attach and re-measure as rows render.
|
||||
* Drop cached row measurements when leaving wrap mode or changing density:
|
||||
* the measureElement refs detach with their wrapped heights still cached,
|
||||
* and falling back to the current fixed estimate is exactly correct for
|
||||
* nowrap rows. Entering wrap needs no reset — refs re-attach and re-measure
|
||||
* as rows render.
|
||||
*
|
||||
* Deliberately NOT keyed on content (`visibleLines`): `measure()` wipes the
|
||||
* cache without re-measuring mounted rows (ResizeObserver only fires on size
|
||||
@@ -1029,7 +1045,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!wrapText) virtualizer.measure()
|
||||
}, [wrapText, virtualizer])
|
||||
}, [density, wrapText, virtualizer])
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchQuery?.trim() || matchCount === 0 || !scrollRef.current) return
|
||||
@@ -1107,6 +1123,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
|
||||
gutterWidth={gutterWidth}
|
||||
showGutter={showGutter}
|
||||
gutterStyle={gutterStyle}
|
||||
density={density}
|
||||
leftOffset={paddingLeft}
|
||||
wrapText={wrapText}
|
||||
showCollapseColumn={effectiveShowCollapseColumn}
|
||||
@@ -1131,6 +1148,7 @@ const ViewerInner = memo(function ViewerInner({
|
||||
showGutter,
|
||||
language,
|
||||
className,
|
||||
density,
|
||||
paddingLeft,
|
||||
gutterStyle,
|
||||
wrapText,
|
||||
@@ -1236,8 +1254,8 @@ const ViewerInner = memo(function ViewerInner({
|
||||
<div
|
||||
style={{
|
||||
paddingLeft,
|
||||
paddingTop: '8px',
|
||||
paddingBottom: '8px',
|
||||
paddingTop: density === 'compact' ? '6px' : '8px',
|
||||
paddingBottom: density === 'compact' ? '6px' : '8px',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: effectiveShowCollapseColumn
|
||||
? `${gutterWidth}px ${collapseColumnWidth}px 1fr`
|
||||
@@ -1252,7 +1270,10 @@ const ViewerInner = memo(function ViewerInner({
|
||||
return (
|
||||
<Fragment key={idx}>
|
||||
<div
|
||||
className='select-none pr-0.5 text-right text-[var(--text-muted)] text-xs tabular-nums leading-[21px] dark:text-[var(--code-line-number)]'
|
||||
className={cn(
|
||||
'select-none pr-0.5 text-right text-[var(--text-muted)] tabular-nums dark:text-[var(--code-line-number)]',
|
||||
density === 'compact' ? 'text-caption leading-5' : 'text-xs leading-[21px]'
|
||||
)}
|
||||
style={gutterStyle}
|
||||
>
|
||||
{lineNumber}
|
||||
@@ -1270,7 +1291,10 @@ const ViewerInner = memo(function ViewerInner({
|
||||
)}
|
||||
<pre
|
||||
className={cn(
|
||||
'm-0 min-w-0 pr-2 pl-2 font-mono text-[var(--text-primary)] text-small leading-[21px] dark:text-[var(--code-foreground)]',
|
||||
'm-0 min-w-0 pr-2 pl-2 font-mono text-[var(--text-primary)] dark:text-[var(--code-foreground)]',
|
||||
density === 'compact'
|
||||
? 'text-caption leading-5'
|
||||
: 'text-small leading-[21px]',
|
||||
whitespaceClass
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
@@ -1291,7 +1315,10 @@ const ViewerInner = memo(function ViewerInner({
|
||||
<pre
|
||||
className={cn(
|
||||
whitespaceClass,
|
||||
'p-2 font-mono text-[var(--text-primary)] text-small leading-[21px] dark:text-[var(--code-foreground)]'
|
||||
'font-mono text-[var(--text-primary)] dark:text-[var(--code-foreground)]',
|
||||
density === 'compact'
|
||||
? 'px-2 py-1.5 text-caption leading-5'
|
||||
: 'p-2 text-small leading-[21px]'
|
||||
)}
|
||||
style={{ paddingLeft: paddingLeft > 0 ? paddingLeft : undefined }}
|
||||
dangerouslySetInnerHTML={{ __html: highlightedCode }}
|
||||
@@ -1330,6 +1357,7 @@ function Viewer({
|
||||
showGutter = false,
|
||||
language = 'json',
|
||||
className,
|
||||
density = 'default',
|
||||
paddingLeft = 0,
|
||||
gutterStyle,
|
||||
wrapText = false,
|
||||
@@ -1345,6 +1373,7 @@ function Viewer({
|
||||
showGutter,
|
||||
language,
|
||||
className,
|
||||
density,
|
||||
paddingLeft,
|
||||
gutterStyle,
|
||||
wrapText,
|
||||
|
||||
@@ -56,6 +56,7 @@ import { createPortal } from 'react-dom'
|
||||
import { Check, ChevronLeft, ChevronRight, Search } from '../../icons'
|
||||
import { cn } from '../../lib/cn'
|
||||
import { chipActiveSurfaceClass, chipHoverSurfaceClass } from '../chip/chip-chrome'
|
||||
import { TOOLTIP_MAX_WIDTH_PX, TOOLTIP_SURFACE_CLASS } from '../tooltip/tooltip-styles'
|
||||
|
||||
type PopoverSize = 'sm' | 'md'
|
||||
type PopoverColorScheme = 'default' | 'inverted'
|
||||
@@ -388,6 +389,11 @@ interface PopoverContentProps
|
||||
* @default false
|
||||
*/
|
||||
border?: boolean
|
||||
/**
|
||||
* Applies a semantic platform surface treatment.
|
||||
* @default 'default'
|
||||
*/
|
||||
appearance?: 'default' | 'tooltip'
|
||||
/**
|
||||
* Flip to avoid viewport collisions
|
||||
* @default true
|
||||
@@ -428,6 +434,7 @@ const PopoverContent = React.forwardRef<
|
||||
sideOffset,
|
||||
collisionPadding = 8,
|
||||
border = false,
|
||||
appearance = 'default',
|
||||
avoidCollisions = true,
|
||||
showArrow = false,
|
||||
arrowClassName,
|
||||
@@ -528,8 +535,14 @@ const PopoverContent = React.forwardRef<
|
||||
// management to avoid conflicts between the popover's internal selection index
|
||||
// and the component's custom navigation state.
|
||||
|
||||
const effectiveMaxWidth =
|
||||
maxWidth !== undefined
|
||||
? `${maxWidth}px`
|
||||
: appearance === 'tooltip'
|
||||
? `min(${TOOLTIP_MAX_WIDTH_PX}px, calc(100vw - 2rem))`
|
||||
: undefined
|
||||
const hasUserWidthConstraint =
|
||||
maxWidth !== undefined ||
|
||||
effectiveMaxWidth !== undefined ||
|
||||
minWidth !== undefined ||
|
||||
style?.minWidth !== undefined ||
|
||||
style?.maxWidth !== undefined ||
|
||||
@@ -590,6 +603,7 @@ const PopoverContent = React.forwardRef<
|
||||
showArrow ? 'overflow-visible' : 'overflow-auto',
|
||||
STYLES.colorScheme[colorScheme].content,
|
||||
STYLES.content,
|
||||
appearance === 'tooltip' && TOOLTIP_SURFACE_CLASS,
|
||||
hasUserWidthConstraint &&
|
||||
'[&_.flex-1:not([data-popover-scroll])]:truncate [&_[data-popover-section]]:truncate',
|
||||
border && 'border border-[var(--border-1)]',
|
||||
@@ -597,7 +611,7 @@ const PopoverContent = React.forwardRef<
|
||||
)}
|
||||
style={{
|
||||
maxHeight: `${maxHeight || 400}px`,
|
||||
maxWidth: maxWidth !== undefined ? `${maxWidth}px` : 'calc(100vw - 16px)',
|
||||
maxWidth: effectiveMaxWidth ?? 'calc(100vw - 16px)',
|
||||
minWidth:
|
||||
minWidth !== undefined
|
||||
? `${minWidth}px`
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Canonical maximum width shared by standard and interactive tooltip surfaces. */
|
||||
export const TOOLTIP_MAX_WIDTH_PX = 256
|
||||
|
||||
/** Canonical platform tooltip chrome, without positioning or content padding. */
|
||||
export const TOOLTIP_SURFACE_CLASS =
|
||||
'w-fit max-w-[min(16rem,calc(100vw-2rem))] rounded-lg border border-[var(--border)] bg-[var(--bg)] text-[var(--text-body)] text-caption shadow-sm'
|
||||
@@ -4,6 +4,7 @@ import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { cn } from '../../lib/cn'
|
||||
import { TOOLTIP_SURFACE_CLASS } from './tooltip-styles'
|
||||
|
||||
const TOOLTIP_OFFSET = 16
|
||||
const EDGE_GUTTER = 16
|
||||
@@ -365,7 +366,8 @@ export const FloatingTooltip = React.memo(function FloatingTooltip({
|
||||
aria-hidden={role ? undefined : 'true'}
|
||||
data-native-surface-overlay=''
|
||||
className={cn(
|
||||
'pointer-events-none fixed top-0 left-0 z-[var(--z-tooltip)] w-fit max-w-[min(16rem,calc(100vw-2rem))] rounded-lg border border-[var(--border)] bg-[var(--bg)] px-2 py-1.5 text-[var(--text-body)] text-caption opacity-100 shadow-sm transition-[opacity,translate] duration-150 ease-out',
|
||||
TOOLTIP_SURFACE_CLASS,
|
||||
'pointer-events-none fixed top-0 left-0 z-[var(--z-tooltip)] px-2 py-1.5 opacity-100 transition-[opacity,translate] duration-150 ease-out',
|
||||
'motion-reduce:transition-none',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -38,7 +38,14 @@ export {
|
||||
type SubflowNodeViewProps,
|
||||
SubflowStartView,
|
||||
} from './subflow/subflow-node-view'
|
||||
export type { BlockRunStatus, DiffStatus, EdgeDiffStatus, EdgeRunStatus } from './types'
|
||||
export type {
|
||||
BlockRunStatus,
|
||||
CodePreview,
|
||||
CodePreviewLanguage,
|
||||
DiffStatus,
|
||||
EdgeDiffStatus,
|
||||
EdgeRunStatus,
|
||||
} from './types'
|
||||
export {
|
||||
type CanvasSentenceSegment,
|
||||
CanvasSentenceView,
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type ReactNode,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { Code, isTextClipped, Popover, PopoverAnchor, PopoverContent } from '@sim/emcn'
|
||||
import type { CodePreview } from '../types'
|
||||
|
||||
const OPEN_DELAY_MS = 300
|
||||
const TRIGGER_EXIT_GRACE_MS = 600
|
||||
const CONTENT_EXIT_GRACE_MS = 120
|
||||
const CODE_TOOLTIP_MAX_HEIGHT_PX = 256
|
||||
|
||||
interface CodeHoverCardProps {
|
||||
preview: CodePreview
|
||||
className: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/** Interactive, two-axis-scrollable source preview anchored to a canvas code chip. */
|
||||
export function CodeHoverCard({ preview, className, children }: CodeHoverCardProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const contentId = useId()
|
||||
const triggerRef = useRef<HTMLSpanElement>(null)
|
||||
const openTimerRef = useRef<number | null>(null)
|
||||
const closeTimerRef = useRef<number | null>(null)
|
||||
|
||||
const clearOpenTimer = useCallback(() => {
|
||||
if (openTimerRef.current === null) return
|
||||
window.clearTimeout(openTimerRef.current)
|
||||
openTimerRef.current = null
|
||||
}, [])
|
||||
|
||||
const clearCloseTimer = useCallback(() => {
|
||||
if (closeTimerRef.current === null) return
|
||||
window.clearTimeout(closeTimerRef.current)
|
||||
closeTimerRef.current = null
|
||||
}, [])
|
||||
|
||||
const handleTriggerPointerEnter = (event: ReactPointerEvent<HTMLSpanElement>) => {
|
||||
if (!isTextClipped(event.currentTarget)) return
|
||||
clearCloseTimer()
|
||||
if (open || openTimerRef.current !== null) return
|
||||
openTimerRef.current = window.setTimeout(() => {
|
||||
openTimerRef.current = null
|
||||
setOpen(true)
|
||||
}, OPEN_DELAY_MS)
|
||||
}
|
||||
|
||||
const scheduleClose = (delay: number) => {
|
||||
clearOpenTimer()
|
||||
clearCloseTimer()
|
||||
closeTimerRef.current = window.setTimeout(() => {
|
||||
closeTimerRef.current = null
|
||||
setOpen(false)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const openImmediatelyIfClipped = (trigger: HTMLSpanElement) => {
|
||||
if (!isTextClipped(trigger)) return
|
||||
clearOpenTimer()
|
||||
clearCloseTimer()
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!nextOpen) {
|
||||
clearOpenTimer()
|
||||
clearCloseTimer()
|
||||
}
|
||||
setOpen(nextOpen)
|
||||
},
|
||||
[clearCloseTimer, clearOpenTimer]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearOpenTimer()
|
||||
clearCloseTimer()
|
||||
},
|
||||
[clearCloseTimer, clearOpenTimer]
|
||||
)
|
||||
|
||||
const handleTriggerPointerDown = (event: ReactPointerEvent<HTMLSpanElement>) => {
|
||||
event.preventDefault()
|
||||
if (event.pointerType === 'touch' || event.pointerType === 'pen') {
|
||||
event.stopPropagation()
|
||||
if (open) {
|
||||
handleOpenChange(false)
|
||||
} else {
|
||||
openImmediatelyIfClipped(event.currentTarget)
|
||||
}
|
||||
return
|
||||
}
|
||||
handleOpenChange(false)
|
||||
}
|
||||
|
||||
const handleTriggerKeyDown = (event: ReactKeyboardEvent<HTMLSpanElement>) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
handleOpenChange(false)
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (open) {
|
||||
handleOpenChange(false)
|
||||
} else {
|
||||
openImmediatelyIfClipped(event.currentTarget)
|
||||
}
|
||||
}
|
||||
|
||||
const handleContentKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'Escape') return
|
||||
event.stopPropagation()
|
||||
handleOpenChange(false)
|
||||
triggerRef.current?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverAnchor asChild>
|
||||
<span
|
||||
ref={triggerRef}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
aria-haspopup='dialog'
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? contentId : undefined}
|
||||
className={className}
|
||||
onPointerEnter={handleTriggerPointerEnter}
|
||||
onPointerLeave={() => {
|
||||
if (document.activeElement !== triggerRef.current) {
|
||||
scheduleClose(TRIGGER_EXIT_GRACE_MS)
|
||||
}
|
||||
}}
|
||||
onPointerDown={handleTriggerPointerDown}
|
||||
onFocus={(event) => openImmediatelyIfClipped(event.currentTarget)}
|
||||
onBlur={() => scheduleClose(TRIGGER_EXIT_GRACE_MS)}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
id={contentId}
|
||||
role='dialog'
|
||||
tabIndex={0}
|
||||
aria-label='Code preview'
|
||||
data-code-hover-card=''
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={-4}
|
||||
collisionPadding={16}
|
||||
appearance='tooltip'
|
||||
maxHeight={CODE_TOOLTIP_MAX_HEIGHT_PX}
|
||||
onPointerEnter={clearCloseTimer}
|
||||
onPointerLeave={() => scheduleClose(CONTENT_EXIT_GRACE_MS)}
|
||||
onFocus={clearCloseTimer}
|
||||
onBlur={() => scheduleClose(CONTENT_EXIT_GRACE_MS)}
|
||||
onKeyDown={handleContentKeyDown}
|
||||
className='nodrag nowheel overflow-hidden overscroll-contain p-0'
|
||||
>
|
||||
<Code.Viewer
|
||||
code={preview.code}
|
||||
language={preview.language}
|
||||
density='compact'
|
||||
className='max-h-[min(16rem,calc(100vh-2rem))] min-h-0 rounded-none border-0 bg-[var(--bg)] shadow-none dark:bg-[var(--bg)]'
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { OverflowSpan } from './overflow-span'
|
||||
|
||||
let root: Root | null = null
|
||||
let host: HTMLDivElement | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
)
|
||||
host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
root = createRoot(host)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (root) act(() => root?.unmount())
|
||||
host?.remove()
|
||||
root = null
|
||||
host = null
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('OverflowSpan code preview', () => {
|
||||
it('renders the complete source after the code-card dwell', () => {
|
||||
const code = Array.from({ length: 20 }, (_, index) => `line ${index + 1}`).join('\n')
|
||||
act(() => {
|
||||
root?.render(
|
||||
<OverflowSpan
|
||||
value='line 1'
|
||||
className='truncate'
|
||||
codePreview={{ code, language: 'javascript' }}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const trigger = host?.querySelector<HTMLElement>('span')
|
||||
if (!trigger) throw new Error('Overflow trigger did not render')
|
||||
Object.defineProperties(trigger, {
|
||||
clientWidth: { configurable: true, value: 50 },
|
||||
scrollWidth: { configurable: true, value: 200 },
|
||||
})
|
||||
|
||||
act(() => {
|
||||
trigger.dispatchEvent(
|
||||
new MouseEvent('pointerover', { bubbles: true, clientX: 200, clientY: 200 })
|
||||
)
|
||||
vi.advanceTimersByTime(299)
|
||||
})
|
||||
expect(document.querySelector('[data-native-surface-overlay]')).toBeNull()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1)
|
||||
})
|
||||
const preview = document.querySelector('[data-code-hover-card]')
|
||||
expect(preview).toHaveTextContent('line 20')
|
||||
expect(preview).toHaveClass('w-fit', 'max-w-[min(16rem,calc(100vw-2rem))]', 'shadow-sm')
|
||||
expect(preview).toHaveStyle({ maxWidth: 'min(256px, calc(100vw - 2rem))' })
|
||||
expect(preview?.querySelector('.overflow-x-auto')).toHaveClass('overflow-y-auto')
|
||||
expect(preview?.querySelector('.tabular-nums')).toBeNull()
|
||||
expect(preview?.querySelector('pre')).toHaveClass('px-2', 'py-1.5', 'text-caption', 'leading-5')
|
||||
})
|
||||
|
||||
it('stays open while the pointer crosses into the scrollable preview', () => {
|
||||
act(() => {
|
||||
root?.render(
|
||||
<OverflowSpan
|
||||
value='const value = 1'
|
||||
className='truncate'
|
||||
codePreview={{ code: 'const value = 1', language: 'javascript' }}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const trigger = host?.querySelector<HTMLElement>('span')
|
||||
if (!trigger) throw new Error('Overflow trigger did not render')
|
||||
Object.defineProperties(trigger, {
|
||||
clientWidth: { configurable: true, value: 50 },
|
||||
scrollWidth: { configurable: true, value: 200 },
|
||||
})
|
||||
|
||||
act(() => {
|
||||
trigger.dispatchEvent(
|
||||
new MouseEvent('pointerover', { bubbles: true, clientX: 200, clientY: 200 })
|
||||
)
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
const preview = document.querySelector<HTMLElement>('[data-code-hover-card]')
|
||||
if (!preview) throw new Error('Code preview did not render')
|
||||
|
||||
act(() => {
|
||||
trigger.dispatchEvent(new MouseEvent('pointerout', { bubbles: true }))
|
||||
vi.advanceTimersByTime(500)
|
||||
preview.dispatchEvent(new MouseEvent('pointerover', { bubbles: true }))
|
||||
vi.advanceTimersByTime(600)
|
||||
})
|
||||
expect(document.querySelector('[data-code-hover-card]')).not.toBeNull()
|
||||
|
||||
act(() => {
|
||||
preview.dispatchEvent(new MouseEvent('pointerout', { bubbles: true }))
|
||||
vi.advanceTimersByTime(119)
|
||||
})
|
||||
expect(document.querySelector('[data-code-hover-card]')).not.toBeNull()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1)
|
||||
})
|
||||
expect(document.querySelector('[data-code-hover-card]')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens from the keyboard and keeps the preview available while it is focused', () => {
|
||||
act(() => {
|
||||
root?.render(
|
||||
<OverflowSpan
|
||||
value='const value = 1'
|
||||
className='truncate'
|
||||
codePreview={{ code: 'const value = 1', language: 'javascript' }}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const trigger = host?.querySelector<HTMLElement>('span[role="button"]')
|
||||
if (!trigger) throw new Error('Overflow trigger did not render')
|
||||
Object.defineProperties(trigger, {
|
||||
clientWidth: { configurable: true, value: 50 },
|
||||
scrollWidth: { configurable: true, value: 200 },
|
||||
})
|
||||
|
||||
act(() => trigger.focus())
|
||||
const preview = document.querySelector<HTMLElement>('[data-code-hover-card]')
|
||||
expect(preview).not.toBeNull()
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
act(() => preview?.focus())
|
||||
act(() => vi.advanceTimersByTime(600))
|
||||
expect(document.querySelector('[data-code-hover-card]')).not.toBeNull()
|
||||
|
||||
act(() =>
|
||||
preview?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }))
|
||||
)
|
||||
expect(document.querySelector('[data-code-hover-card]')).toBeNull()
|
||||
expect(trigger).toHaveFocus()
|
||||
})
|
||||
|
||||
it('toggles the clipped preview on touch', () => {
|
||||
act(() => {
|
||||
root?.render(
|
||||
<OverflowSpan
|
||||
value='const value = 1'
|
||||
className='truncate'
|
||||
codePreview={{ code: 'const value = 1', language: 'javascript' }}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const trigger = host?.querySelector<HTMLElement>('span[role="button"]')
|
||||
if (!trigger) throw new Error('Overflow trigger did not render')
|
||||
Object.defineProperties(trigger, {
|
||||
clientWidth: { configurable: true, value: 50 },
|
||||
scrollWidth: { configurable: true, value: 200 },
|
||||
})
|
||||
const pointerDown = () => {
|
||||
const event = new MouseEvent('pointerdown', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'pointerType', { value: 'touch' })
|
||||
trigger.dispatchEvent(event)
|
||||
}
|
||||
|
||||
act(pointerDown)
|
||||
expect(document.querySelector('[data-code-hover-card]')).not.toBeNull()
|
||||
act(pointerDown)
|
||||
expect(document.querySelector('[data-code-hover-card]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { FloatingTooltip, isTextClipped, useFloatingTooltip } from '@sim/emcn'
|
||||
import type { CodePreview } from '../types'
|
||||
import { CodeHoverCard } from './code-hover-card'
|
||||
|
||||
interface OverflowSpanProps {
|
||||
value: string
|
||||
className: string
|
||||
/** Rich content shown instead of the plain value when this is clipped code. */
|
||||
codePreview?: CodePreview
|
||||
/**
|
||||
* Decorated rendering of `value` — the same characters, wrapped. Used to mark
|
||||
* a search hit inside a name without letting the decoration reach the
|
||||
@@ -19,7 +23,24 @@ interface OverflowSpanProps {
|
||||
* attribute here: on the canvas it pops the browser's raw, unstyled tooltip
|
||||
* with the full untruncated value (including raw code/JSON) over the graph.
|
||||
*/
|
||||
export function OverflowSpan({ value, className, children }: OverflowSpanProps) {
|
||||
export function OverflowSpan({ value, className, codePreview, children }: OverflowSpanProps) {
|
||||
if (codePreview) {
|
||||
return (
|
||||
<CodeHoverCard preview={codePreview} className={className}>
|
||||
{children ?? value}
|
||||
</CodeHoverCard>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TextOverflowSpan value={value} className={className}>
|
||||
{children}
|
||||
</TextOverflowSpan>
|
||||
)
|
||||
}
|
||||
|
||||
/** Plain clipped text keeps the platform tooltip behavior unchanged. */
|
||||
function TextOverflowSpan({ value, className, children }: Omit<OverflowSpanProps, 'codePreview'>) {
|
||||
const { state, handlers } = useFloatingTooltip(isTextClipped)
|
||||
|
||||
return (
|
||||
|
||||
@@ -17,3 +17,12 @@ export type DiffStatus = 'new' | 'edited' | undefined
|
||||
|
||||
/** Execution outcome of a block on its run path. */
|
||||
export type BlockRunStatus = 'success' | 'error' | undefined
|
||||
|
||||
/** Syntax languages supported by canvas code previews. */
|
||||
export type CodePreviewLanguage = 'javascript' | 'json' | 'python' | 'bash'
|
||||
|
||||
/** Rich preview payload for a code value in the pure workflow renderer. */
|
||||
export interface CodePreview {
|
||||
code: string
|
||||
language: CodePreviewLanguage
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { OverflowSpan } from '../lib/overflow-span'
|
||||
import type { CodePreview } from '../types'
|
||||
import { InlineChip } from './inline-chip'
|
||||
|
||||
/**
|
||||
@@ -16,6 +17,8 @@ export interface SubBlockRowViewProps {
|
||||
displayValue?: string
|
||||
/** Render the value in a monospace font (e.g. filter expressions). */
|
||||
isMonospace?: boolean
|
||||
/** Rich preview for an inline code value; ordinary values keep the text tooltip. */
|
||||
codePreview?: CodePreview
|
||||
/**
|
||||
* Leading icon for the `meta` variant; without one the variant falls back
|
||||
* to the labeled `row` presentation.
|
||||
@@ -46,6 +49,7 @@ export function SubBlockRowView({
|
||||
title,
|
||||
displayValue,
|
||||
isMonospace,
|
||||
codePreview,
|
||||
icon: Icon,
|
||||
variant = 'row',
|
||||
}: SubBlockRowViewProps) {
|
||||
@@ -55,6 +59,7 @@ export function SubBlockRowView({
|
||||
<OverflowSpan
|
||||
value={displayValue ?? title}
|
||||
className={cn('min-w-0 truncate', isMonospace && 'font-mono')}
|
||||
codePreview={codePreview}
|
||||
/>
|
||||
</InlineChip>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user