mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
perf(frontend): bound logs DOM, kill editor re-render storms, lazy-load heavy deps (#5212)
* perf(logs): virtualize the resource list to bound DOM + memory * perf(editor): narrow per-block store subscription to kill structural-edit re-render storm * perf(realtime): move presence state out of the socket context to stop cursor-frame re-renders * perf(editor): lazy-load NoteBlock so Streamdown is off the editor's critical path * perf(emcn): defer prismjs in Code so it's off the shared barrel's static graph
This commit is contained in:
@@ -31,6 +31,7 @@ export type {
|
||||
ResourceCellEditing,
|
||||
ResourceColumn,
|
||||
ResourceRow,
|
||||
ResourceTableHandle,
|
||||
RowDragDropConfig,
|
||||
SelectableConfig,
|
||||
} from './resource/resource'
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
'use client'
|
||||
import {
|
||||
type CSSProperties,
|
||||
type DragEvent,
|
||||
memo,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
@@ -74,11 +79,11 @@ export interface RowDragDropConfig {
|
||||
isAnyDragActive?: boolean
|
||||
isRowDraggable?: (rowId: string) => boolean
|
||||
isRowDropTarget?: (rowId: string) => boolean
|
||||
onDragStart?: (e: DragEvent<HTMLTableRowElement>, rowId: string) => void
|
||||
onDragOver?: (e: DragEvent<HTMLTableRowElement>, rowId: string) => void
|
||||
onDragLeave?: (e: DragEvent<HTMLTableRowElement>, rowId: string) => void
|
||||
onDrop?: (e: DragEvent<HTMLTableRowElement>, rowId: string) => void
|
||||
onDragEnd?: (e: DragEvent<HTMLTableRowElement>, rowId: string) => void
|
||||
onDragStart?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
|
||||
onDragOver?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
|
||||
onDragLeave?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
|
||||
onDrop?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
|
||||
onDragEnd?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
|
||||
}
|
||||
|
||||
export interface PaginationConfig {
|
||||
@@ -89,6 +94,34 @@ export interface PaginationConfig {
|
||||
|
||||
export const EMPTY_CELL_PLACEHOLDER = '—'
|
||||
|
||||
/**
|
||||
* Seed height (px) for each virtualized row before it is measured. Every
|
||||
* consumer renders single-line `py-2.5` cells, so this matches the resting row
|
||||
* height closely; `measureElement` then corrects each row to its exact pixel
|
||||
* height after mount, so the estimate only affects pre-measure scroll math.
|
||||
*/
|
||||
const ROW_HEIGHT_ESTIMATE = 41 as const
|
||||
|
||||
/** Rows rendered above/below the viewport to avoid blank flashes on fast scroll. */
|
||||
const ROW_OVERSCAN = 8 as const
|
||||
|
||||
const CHECKBOX_COLUMN_WIDTH = '52px'
|
||||
|
||||
/**
|
||||
* Builds the shared CSS grid track list for the header and every body row from
|
||||
* the same first-column-weighted ratios the legacy `<colgroup>` used, so the
|
||||
* virtualized grid layout reproduces the exact column widths. The checkbox
|
||||
* column, when present, is a fixed leading track.
|
||||
*/
|
||||
function buildGridTemplateColumns(columns: ResourceColumn[], hasCheckbox: boolean): string {
|
||||
const weights = columns.map(
|
||||
(col, colIdx) => (colIdx === 0 ? 2.5 : 1.0) * (col.widthMultiplier ?? 1)
|
||||
)
|
||||
const total = weights.reduce((s, w) => s + w, 0)
|
||||
const tracks = columns.map((_, colIdx) => `minmax(0, ${(weights[colIdx] / total).toFixed(6)}fr)`)
|
||||
return hasCheckbox ? `${CHECKBOX_COLUMN_WIDTH} ${tracks.join(' ')}` : tracks.join(' ')
|
||||
}
|
||||
|
||||
interface ResourceProps {
|
||||
children: ReactNode
|
||||
onContextMenu?: (e: React.MouseEvent) => void
|
||||
@@ -123,10 +156,29 @@ function ResourceRoot({ children, onContextMenu }: ResourceProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Imperative handle for `Resource.Table`. Lets a consumer drive virtualizer-aware
|
||||
* scrolling — required for keyboard navigation, since a `querySelector` on the
|
||||
* selected row's DOM node silently no-ops once that row is windowed out.
|
||||
*/
|
||||
export interface ResourceTableHandle {
|
||||
/** Scroll the row with the given id into view via the virtualizer (works even when the row is not in the DOM). */
|
||||
scrollToRow: (rowId: string) => void
|
||||
}
|
||||
|
||||
interface ResourceTableProps {
|
||||
columns: ResourceColumn[]
|
||||
rows: ResourceRow[]
|
||||
selectedRowId?: string | null
|
||||
/** Optional imperative handle exposing {@link ResourceTableHandle} (e.g. for keyboard-nav scrolling). */
|
||||
apiRef?: RefObject<ResourceTableHandle | null>
|
||||
/**
|
||||
* Window the row list with `@tanstack/react-virtual`, keeping only the visible
|
||||
* slice in the DOM. Opt-in because it removes off-screen rows — consumers that
|
||||
* depend on every row being mounted (e.g. drag-and-drop drop targets) must stay
|
||||
* on the full-DOM path. Enable it only for unbounded, accumulating lists (logs).
|
||||
*/
|
||||
virtualized?: boolean
|
||||
selectable?: SelectableConfig
|
||||
rowDragDrop?: RowDragDropConfig
|
||||
onRowClick?: (rowId: string) => void
|
||||
@@ -148,16 +200,28 @@ interface ResourceTableProps {
|
||||
* Data table body, module-private and exposed only as `Resource.Table` — the
|
||||
* compound member is the sole way consumers render it.
|
||||
*
|
||||
* Chrome guarantee: the `<table>`, `<colgroup>`, and column headers render
|
||||
* unconditionally — no prop or row state (empty, loading, error) ever drops
|
||||
* them. Structural additions (checkbox column, load-more sentinel, pagination
|
||||
* bar) are driven purely by which configs the consumer supplies and always
|
||||
* render the canonical chrome.
|
||||
* Chrome guarantee: the table region and column headers render unconditionally —
|
||||
* no prop or row state (empty, loading, error) ever drops them. Structural
|
||||
* additions (checkbox column, load-more sentinel, pagination bar) are driven
|
||||
* purely by which configs the consumer supplies and always render the canonical
|
||||
* chrome.
|
||||
*
|
||||
* The table is built from `<div>`s carrying explicit ARIA roles (`table`,
|
||||
* `rowgroup`, `row`, `columnheader`, `cell`) rather than native table elements:
|
||||
* the rows use CSS grid for column alignment, and `display: grid` on a native
|
||||
* `<table>` strips its implicit table semantics, so the roles are declared
|
||||
* directly. Column widths come from a shared grid track list (see
|
||||
* {@link buildGridTemplateColumns}) reproducing the legacy `<colgroup>` ratios.
|
||||
* When `virtualized`, the body windows with `@tanstack/react-virtual` so only
|
||||
* the visible row slice is in the DOM, bounding DOM size and memory on lists
|
||||
* that accumulate many pages.
|
||||
*/
|
||||
const ResourceTable = memo(function ResourceTable({
|
||||
columns,
|
||||
rows,
|
||||
selectedRowId,
|
||||
apiRef,
|
||||
virtualized = false,
|
||||
selectable,
|
||||
rowDragDrop,
|
||||
onRowClick,
|
||||
@@ -169,6 +233,7 @@ const ResourceTable = memo(function ResourceTable({
|
||||
pagination,
|
||||
overlay,
|
||||
}: ResourceTableProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [contextMenuRowId, setContextMenuRowId] = useState<string | null>(null)
|
||||
@@ -224,15 +289,53 @@ const ResourceTable = memo(function ResourceTable({
|
||||
[selectable]
|
||||
)
|
||||
|
||||
const gridTemplateColumns = useMemo(
|
||||
() => buildGridTemplateColumns(columns, hasCheckbox),
|
||||
[columns, hasCheckbox]
|
||||
)
|
||||
|
||||
/**
|
||||
* Windows the row list so only the visible slice (plus overscan) is in the
|
||||
* DOM, bounding DOM size and memory regardless of how many pages a consumer
|
||||
* accumulates. Rows are measured via {@link rowVirtualizer.measureElement} so
|
||||
* any single-line height variance stays pixel-exact.
|
||||
*/
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ROW_HEIGHT_ESTIMATE,
|
||||
overscan: ROW_OVERSCAN,
|
||||
getItemKey: (index) => rows[index].id,
|
||||
})
|
||||
|
||||
useImperativeHandle(
|
||||
apiRef,
|
||||
() => ({
|
||||
scrollToRow: (rowId: string) => {
|
||||
const index = rows.findIndex((row) => row.id === rowId)
|
||||
if (index >= 0) rowVirtualizer.scrollToIndex(index, { align: 'auto' })
|
||||
},
|
||||
}),
|
||||
[rows, rowVirtualizer]
|
||||
)
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems()
|
||||
const totalSize = rowVirtualizer.getTotalSize()
|
||||
|
||||
return (
|
||||
<div className='relative flex min-h-0 flex-1 flex-col overflow-hidden'>
|
||||
<div className='min-h-0 flex-1 overflow-auto overscroll-none'>
|
||||
<table className='w-full table-fixed text-small'>
|
||||
<ResourceColGroup columns={columns} hasCheckbox={hasCheckbox} />
|
||||
<thead className='sticky top-0 z-10 bg-[var(--bg)] shadow-[inset_0_-1px_0_var(--border)]'>
|
||||
<tr>
|
||||
<div ref={scrollRef} className='min-h-0 flex-1 overflow-auto overscroll-none'>
|
||||
<div role='table' className='grid w-full text-small'>
|
||||
<div
|
||||
role='rowgroup'
|
||||
className='sticky top-0 z-10 grid bg-[var(--bg)] shadow-[inset_0_-1px_0_var(--border)]'
|
||||
>
|
||||
<div role='row' className='grid' style={{ gridTemplateColumns }}>
|
||||
{hasCheckbox && (
|
||||
<th className='h-10 w-[52px] py-1.5 pr-0 pl-5 text-left align-middle'>
|
||||
<div
|
||||
role='columnheader'
|
||||
className='flex h-10 items-center py-1.5 pr-0 pl-5 text-left'
|
||||
>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
checked={selectable.isAllSelected}
|
||||
@@ -240,36 +343,65 @@ const ResourceTable = memo(function ResourceTable({
|
||||
disabled={selectable.disabled}
|
||||
aria-label='Select all'
|
||||
/>
|
||||
</th>
|
||||
</div>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
<div
|
||||
key={col.id}
|
||||
className='h-10 px-6 py-1.5 text-left align-middle font-normal text-[var(--text-muted)] text-small'
|
||||
role='columnheader'
|
||||
className='flex h-10 min-w-0 items-center px-6 py-1.5 text-left font-normal text-[var(--text-muted)] text-small'
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
<span className='min-w-0 truncate'>{col.header}</span>
|
||||
</div>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<DataRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
columns={columns}
|
||||
selectedRowId={selectedRowId}
|
||||
selectable={selectable}
|
||||
rowDragDrop={rowDragDrop}
|
||||
onRowClick={onRowClick}
|
||||
onRowHover={onRowHover}
|
||||
onRowContextMenu={onRowContextMenu ? wrappedOnRowContextMenu : undefined}
|
||||
isContextMenuTarget={contextMenuRowId === row.id}
|
||||
hasCheckbox={hasCheckbox}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
role='rowgroup'
|
||||
className={cn('grid', virtualized && 'relative')}
|
||||
style={virtualized ? { height: totalSize } : undefined}
|
||||
>
|
||||
{virtualized
|
||||
? virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index]
|
||||
return (
|
||||
<DataRow
|
||||
key={virtualRow.key}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
dataIndex={virtualRow.index}
|
||||
translateY={virtualRow.start}
|
||||
gridTemplateColumns={gridTemplateColumns}
|
||||
row={row}
|
||||
columns={columns}
|
||||
selectedRowId={selectedRowId}
|
||||
selectable={selectable}
|
||||
rowDragDrop={rowDragDrop}
|
||||
onRowClick={onRowClick}
|
||||
onRowHover={onRowHover}
|
||||
onRowContextMenu={onRowContextMenu ? wrappedOnRowContextMenu : undefined}
|
||||
isContextMenuTarget={contextMenuRowId === row.id}
|
||||
hasCheckbox={hasCheckbox}
|
||||
/>
|
||||
)
|
||||
})
|
||||
: rows.map((row) => (
|
||||
<DataRow
|
||||
key={row.id}
|
||||
gridTemplateColumns={gridTemplateColumns}
|
||||
row={row}
|
||||
columns={columns}
|
||||
selectedRowId={selectedRowId}
|
||||
selectable={selectable}
|
||||
rowDragDrop={rowDragDrop}
|
||||
onRowClick={onRowClick}
|
||||
onRowHover={onRowHover}
|
||||
onRowContextMenu={onRowContextMenu ? wrappedOnRowContextMenu : undefined}
|
||||
isContextMenuTarget={contextMenuRowId === row.id}
|
||||
hasCheckbox={hasCheckbox}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div ref={loadMoreRef} className='flex items-center justify-center py-3'>
|
||||
{isLoadingMore && (
|
||||
@@ -393,6 +525,18 @@ interface DataRowProps {
|
||||
onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void
|
||||
isContextMenuTarget?: boolean
|
||||
hasCheckbox: boolean
|
||||
/** CSS grid track list shared with the header so columns stay aligned. */
|
||||
gridTemplateColumns: string
|
||||
/**
|
||||
* Virtual row offset. When set, the row is absolutely positioned within the
|
||||
* sized tbody (windowed mode); when omitted, the row renders in normal grid
|
||||
* flow (full-DOM mode).
|
||||
*/
|
||||
translateY?: number
|
||||
/** Virtual index, consumed by the virtualizer's `measureElement` ref (windowed mode only). */
|
||||
dataIndex?: number
|
||||
/** Forwarded from the virtualizer so each mounted row is measured exactly (windowed mode only). */
|
||||
ref?: (node: HTMLDivElement | null) => void
|
||||
}
|
||||
|
||||
const DataRow = memo(function DataRow({
|
||||
@@ -406,6 +550,10 @@ const DataRow = memo(function DataRow({
|
||||
onRowContextMenu,
|
||||
isContextMenuTarget,
|
||||
hasCheckbox,
|
||||
gridTemplateColumns,
|
||||
translateY,
|
||||
dataIndex,
|
||||
ref,
|
||||
}: DataRowProps) {
|
||||
const isSelected = selectable?.selectedIds.has(row.id) ?? false
|
||||
const isDraggable = rowDragDrop?.isRowDraggable?.(row.id) ?? false
|
||||
@@ -416,7 +564,7 @@ const DataRow = memo(function DataRow({
|
||||
const hasActiveSelection = (selectable?.selectedIds.size ?? 0) > 0
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLTableRowElement>) => {
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
selectable &&
|
||||
!selectable.disabled &&
|
||||
@@ -457,32 +605,41 @@ const DataRow = memo(function DataRow({
|
||||
[selectable, row.id]
|
||||
)
|
||||
|
||||
const handleDragStart = (e: DragEvent<HTMLTableRowElement>) => {
|
||||
const handleDragStart = (e: DragEvent<HTMLDivElement>) => {
|
||||
rowDragDrop?.onDragStart?.(e, row.id)
|
||||
}
|
||||
|
||||
const handleDragOver = (e: DragEvent<HTMLTableRowElement>) => {
|
||||
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
rowDragDrop?.onDragOver?.(e, row.id)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: DragEvent<HTMLTableRowElement>) => {
|
||||
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||
rowDragDrop?.onDragLeave?.(e, row.id)
|
||||
}
|
||||
|
||||
const handleDrop = (e: DragEvent<HTMLTableRowElement>) => {
|
||||
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
rowDragDrop?.onDrop?.(e, row.id)
|
||||
}
|
||||
|
||||
const handleDragEnd = (e: DragEvent<HTMLTableRowElement>) => {
|
||||
const handleDragEnd = (e: DragEvent<HTMLDivElement>) => {
|
||||
rowDragDrop?.onDragEnd?.(e, row.id)
|
||||
}
|
||||
|
||||
const isWindowed = translateY !== undefined
|
||||
const rowStyle: CSSProperties = isWindowed
|
||||
? { gridTemplateColumns, transform: `translateY(${translateY}px)` }
|
||||
: { gridTemplateColumns }
|
||||
|
||||
return (
|
||||
<tr
|
||||
<div
|
||||
ref={ref}
|
||||
role='row'
|
||||
data-index={dataIndex}
|
||||
data-resource-row
|
||||
data-row-id={row.id}
|
||||
className={cn(
|
||||
'transition-colors',
|
||||
'grid w-full transition-colors',
|
||||
isWindowed && 'absolute top-0 left-0',
|
||||
!isAnyDragActive && 'hover-hover:bg-[var(--surface-3)]',
|
||||
onRowClick && 'cursor-pointer',
|
||||
isDraggable && 'cursor-grab active:cursor-grabbing',
|
||||
@@ -491,6 +648,7 @@ const DataRow = memo(function DataRow({
|
||||
isActiveDropTarget && 'bg-[var(--surface-4)] outline outline-1 outline-[var(--accent)]',
|
||||
(isDragging || (isAnyDragActive && isSelected && !isActiveDropTarget)) && 'opacity-50'
|
||||
)}
|
||||
style={rowStyle}
|
||||
data-drop-target={isDropTarget || undefined}
|
||||
draggable={isDraggable}
|
||||
onClick={onRowClick || selectable ? handleClick : undefined}
|
||||
@@ -503,7 +661,7 @@ const DataRow = memo(function DataRow({
|
||||
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||
>
|
||||
{hasCheckbox && selectable && (
|
||||
<td className='w-[52px] py-2.5 pr-0 pl-5 align-middle'>
|
||||
<div role='cell' className='flex items-center py-2.5 pr-0 pl-5'>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
checked={isSelected}
|
||||
@@ -512,47 +670,22 @@ const DataRow = memo(function DataRow({
|
||||
aria-label='Select row'
|
||||
onClick={handleSelectRowClick}
|
||||
/>
|
||||
</td>
|
||||
</div>
|
||||
)}
|
||||
{columns.map((col) => {
|
||||
const cell = row.cells[col.id]
|
||||
return (
|
||||
<td key={col.id} className='px-6 py-2.5 align-middle'>
|
||||
<div key={col.id} role='cell' className='flex min-w-0 items-center px-6 py-2.5'>
|
||||
<CellContent
|
||||
icon={cell?.icon}
|
||||
label={cell?.label || EMPTY_CELL_PLACEHOLDER}
|
||||
content={cell?.content}
|
||||
editing={cell?.editing}
|
||||
/>
|
||||
</td>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
|
||||
interface ResourceColGroupProps {
|
||||
columns: ResourceColumn[]
|
||||
hasCheckbox?: boolean
|
||||
}
|
||||
|
||||
const CHECKBOX_COLUMN_WIDTH = '52px'
|
||||
|
||||
const ResourceColGroup = memo(function ResourceColGroup({
|
||||
columns,
|
||||
hasCheckbox,
|
||||
}: ResourceColGroupProps) {
|
||||
const weights = columns.map(
|
||||
(col, colIdx) => (colIdx === 0 ? 2.5 : 1.0) * (col.widthMultiplier ?? 1)
|
||||
)
|
||||
const total = weights.reduce((s, w) => s + w, 0)
|
||||
return (
|
||||
<colgroup>
|
||||
{hasCheckbox && <col style={{ width: CHECKBOX_COLUMN_WIDTH }} />}
|
||||
{columns.map((col, colIdx) => (
|
||||
<col key={col.id} style={{ width: `${((weights[colIdx] / total) * 100).toFixed(3)}%` }} />
|
||||
))}
|
||||
</colgroup>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -726,7 +726,7 @@ export function Files() {
|
||||
isAnyDragActive: draggedRowIds.size > 0,
|
||||
isRowDraggable: (rowId) => canEdit && listRename.editingId !== rowId,
|
||||
isRowDropTarget: (rowId) => canEdit && parseRowId(rowId).kind === 'folder',
|
||||
onDragStart: (e: DragEvent<HTMLTableRowElement>, rowId) => {
|
||||
onDragStart: (e: DragEvent<HTMLDivElement>, rowId) => {
|
||||
if (!canEdit || listRename.editingId === rowId) {
|
||||
e.preventDefault()
|
||||
return
|
||||
@@ -769,7 +769,7 @@ export function Files() {
|
||||
e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2)
|
||||
dragGhostRef.current = ghost
|
||||
},
|
||||
onDragOver: (e: DragEvent<HTMLTableRowElement>, rowId) => {
|
||||
onDragOver: (e: DragEvent<HTMLDivElement>, rowId) => {
|
||||
const sourceRowIds = draggedRowIdsRef.current
|
||||
const isExternalFileDrag = hasExternalFiles(e.dataTransfer)
|
||||
if (!isExternalFileDrag && isInvalidDropTarget(rowId, sourceRowIds)) return
|
||||
@@ -779,12 +779,12 @@ export function Files() {
|
||||
e.dataTransfer.dropEffect = isExternalFileDrag ? 'copy' : 'move'
|
||||
setActiveDropTargetId(rowId)
|
||||
},
|
||||
onDragLeave: (e: DragEvent<HTMLTableRowElement>, rowId) => {
|
||||
onDragLeave: (e: DragEvent<HTMLDivElement>, rowId) => {
|
||||
const relatedTarget = e.relatedTarget
|
||||
if (relatedTarget instanceof Node && e.currentTarget.contains(relatedTarget)) return
|
||||
setActiveDropTargetId((current) => (current === rowId ? null : current))
|
||||
},
|
||||
onDrop: (e: DragEvent<HTMLTableRowElement>, rowId) => {
|
||||
onDrop: (e: DragEvent<HTMLDivElement>, rowId) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragCounterRef.current = 0
|
||||
|
||||
@@ -51,7 +51,7 @@ import type {
|
||||
SearchConfig,
|
||||
SortConfig,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import { Resource } from '@/app/workspace/[workspaceId]/components'
|
||||
import { Resource, type ResourceTableHandle } from '@/app/workspace/[workspaceId]/components'
|
||||
import { useLogFilters } from '@/app/workspace/[workspaceId]/logs/hooks/use-log-filters'
|
||||
import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-search-state'
|
||||
import {
|
||||
@@ -262,6 +262,7 @@ export default function Logs() {
|
||||
const selectedLogIndexRef = useRef(-1)
|
||||
const selectedLogIdRef = useRef<string | null>(null)
|
||||
const shouldScrollIntoViewRef = useRef(false)
|
||||
const resourceTableRef = useRef<ResourceTableHandle>(null)
|
||||
const logsRefetchRef = useRef<() => void>(() => {})
|
||||
const activeLogRefetchRef = useRef<() => void>(() => {})
|
||||
const activeLogTabRef = useRef<string>('overview')
|
||||
@@ -565,10 +566,8 @@ export default function Logs() {
|
||||
useEffect(() => {
|
||||
if (!selectedLogId || !shouldScrollIntoViewRef.current) return
|
||||
shouldScrollIntoViewRef.current = false
|
||||
const row = document.querySelector(`[data-row-id="${selectedLogId}"]`) as HTMLElement | null
|
||||
if (row) {
|
||||
row.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}
|
||||
// Route through the virtualizer; a querySelector would miss windowed-out rows.
|
||||
resourceTableRef.current?.scrollToRow(selectedLogId)
|
||||
}, [selectedLogId, selectedLogIndex])
|
||||
|
||||
const effectiveSidebarOpen =
|
||||
@@ -1122,6 +1121,8 @@ export default function Logs() {
|
||||
</div>
|
||||
) : (
|
||||
<Resource.Table
|
||||
apiRef={resourceTableRef}
|
||||
virtualized
|
||||
columns={LOG_COLUMNS}
|
||||
rows={rows}
|
||||
selectedRowId={selectedLogId}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useViewport } from 'reactflow'
|
||||
import { getUserColor } from '@/lib/workspaces/colors'
|
||||
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { useSocket } from '@/app/workspace/providers/socket-provider'
|
||||
import { usePresenceStore } from '@/stores/presence/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
interface CursorPoint {
|
||||
@@ -21,7 +22,8 @@ interface CursorRenderData {
|
||||
|
||||
const CursorsComponent = () => {
|
||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
const { currentWorkflowId, presenceUsers, currentSocketId } = useSocket()
|
||||
const { currentWorkflowId, currentSocketId } = useSocket()
|
||||
const presenceUsers = usePresenceStore((state) => state.presenceUsers)
|
||||
const viewport = useViewport()
|
||||
const preventZoomRef = usePreventZoom()
|
||||
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import type { CurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
/**
|
||||
* Per-block, narrowly-subscribed variant of {@link useCurrentWorkflow}.
|
||||
*
|
||||
* `useCurrentWorkflow` subscribes (via `useShallow`) to the entire
|
||||
* `{ blocks, edges, loops, parallels, lastSaved }` slice, so any structural
|
||||
* edit that replaces the `blocks` reference (rename, lock/enable toggle,
|
||||
* dimension settle, add/remove, paste) re-renders every mounted block at once.
|
||||
*
|
||||
* This hook returns the same {@link CurrentWorkflow} shape but subscribes only
|
||||
* to the fields a single block actually reads: its own block object and the
|
||||
* diff-mode flags. The `blocks` map it exposes contains only this block — every
|
||||
* consumer (`useBlockState`, `useBlockProperties`, `workflow-block.tsx`) only
|
||||
* ever indexes the map at `blockId`, so the narrowed map is behaviorally
|
||||
* identical while staying reference-stable across edits to other blocks.
|
||||
*
|
||||
* @param blockId - The block whose view of the workflow is needed
|
||||
* @returns A {@link CurrentWorkflow} scoped to the given block
|
||||
*/
|
||||
export function useBlockCurrentWorkflow(blockId: string): CurrentWorkflow {
|
||||
const normalBlock = useWorkflowStore((state) => state.blocks[blockId])
|
||||
|
||||
const { isShowingDiff, isDiffReady, hasActiveDiff } = useWorkflowDiffStore(
|
||||
useShallow((state) => ({
|
||||
isShowingDiff: state.isShowingDiff,
|
||||
isDiffReady: state.isDiffReady,
|
||||
hasActiveDiff: state.hasActiveDiff,
|
||||
}))
|
||||
)
|
||||
|
||||
const hasBaseline = useWorkflowDiffStore((state) => Boolean(state.baselineWorkflow))
|
||||
const baselineBlock = useWorkflowDiffStore((state) => state.baselineWorkflow?.blocks?.[blockId])
|
||||
|
||||
return useMemo((): CurrentWorkflow => {
|
||||
const isSnapshotView = hasBaseline && hasActiveDiff && isDiffReady && !isShowingDiff
|
||||
|
||||
const block = isSnapshotView ? baselineBlock : normalBlock
|
||||
const blocks: Record<string, BlockState> = block ? { [blockId]: block } : {}
|
||||
|
||||
return {
|
||||
blocks,
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
lastSaved: undefined,
|
||||
|
||||
isDiffMode: hasActiveDiff && isShowingDiff,
|
||||
isNormalMode: !hasActiveDiff || (!isShowingDiff && !isSnapshotView),
|
||||
isSnapshotView,
|
||||
|
||||
workflowState: { blocks, edges: [], loops: {}, parallels: {} },
|
||||
|
||||
getBlockById: (id: string) => (id === blockId ? block : undefined),
|
||||
getBlockCount: () => (block ? 1 : 0),
|
||||
getEdgeCount: () => 0,
|
||||
hasBlocks: () => Boolean(block),
|
||||
hasEdges: () => false,
|
||||
}
|
||||
}, [blockId, normalBlock, baselineBlock, hasBaseline, isShowingDiff, isDiffReady, hasActiveDiff])
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useBlockState } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks'
|
||||
import type { WorkflowBlockProps } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
|
||||
import { useBlockCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-current-workflow'
|
||||
import { getBlockRingStyles } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils'
|
||||
import { useLastRunPath } from '@/stores/execution'
|
||||
import { usePanelEditorStore, usePanelStore } from '@/stores/panel'
|
||||
@@ -40,7 +40,7 @@ export function useBlockVisual({
|
||||
const isEmbedded = data.isEmbedded ?? false
|
||||
const isPreviewSelected = data.isPreviewSelected ?? false
|
||||
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
const currentWorkflow = useBlockCurrentWorkflow(blockId)
|
||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
|
||||
const {
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { EdgeTypes, NodeTypes } from 'reactflow'
|
||||
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
|
||||
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
|
||||
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
||||
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
|
||||
|
||||
/**
|
||||
* Lazily loaded note node. Defined once at module scope so the {@link nodeTypes}
|
||||
* map stays referentially stable for ReactFlow. Loading it dynamically keeps its
|
||||
* heavy markdown dependencies (Streamdown, remark-breaks) off the editor's
|
||||
* critical path — they load only when a workflow actually contains a note block.
|
||||
* `ssr: false` is safe because the note node is canvas-only and never rendered
|
||||
* on the server.
|
||||
*/
|
||||
const NoteBlock = dynamic(
|
||||
() =>
|
||||
import('@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block').then(
|
||||
(mod) => mod.NoteBlock
|
||||
),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
/** Custom node types for ReactFlow. */
|
||||
export const nodeTypes: NodeTypes = {
|
||||
workflowBlock: WorkflowBlock,
|
||||
|
||||
+3
-1
@@ -5,6 +5,7 @@ import { Avatar, AvatarFallback, AvatarImage, Tooltip } from '@/components/emcn'
|
||||
import { getUserColor } from '@/lib/workspaces/colors'
|
||||
import { useSocket } from '@/app/workspace/providers/socket-provider'
|
||||
import { SIDEBAR_WIDTH } from '@/stores/constants'
|
||||
import { usePresenceStore } from '@/stores/presence/store'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
|
||||
/**
|
||||
@@ -80,7 +81,8 @@ function UserAvatar({ user, index }: UserAvatarProps) {
|
||||
* @returns Avatar stack for workflow presence
|
||||
*/
|
||||
export function Avatars({ workflowId }: AvatarsProps) {
|
||||
const { presenceUsers, currentWorkflowId, currentSocketId } = useSocket()
|
||||
const { currentWorkflowId, currentSocketId } = useSocket()
|
||||
const presenceUsers = usePresenceStore((state) => state.presenceUsers)
|
||||
const sidebarWidth = useSidebarStore((state) => state.sidebarWidth)
|
||||
|
||||
/**
|
||||
|
||||
@@ -43,6 +43,8 @@ import type {
|
||||
VariableUpdateEmit,
|
||||
WorkflowOperationEmit,
|
||||
} from '@/stores/operation-queue/types'
|
||||
import { usePresenceStore } from '@/stores/presence/store'
|
||||
import type { PresenceUser } from '@/stores/presence/types'
|
||||
import { useWorkflowRegistry as useWorkflowRegistryStore } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('SocketContext')
|
||||
@@ -71,15 +73,6 @@ interface User {
|
||||
email?: string
|
||||
}
|
||||
|
||||
interface PresenceUser {
|
||||
socketId: string
|
||||
userId: string
|
||||
userName: string
|
||||
avatarUrl?: string | null
|
||||
cursor?: { x: number; y: number } | null
|
||||
selection?: { type: 'block' | 'edge' | 'none'; id?: string }
|
||||
}
|
||||
|
||||
interface SocketContextType {
|
||||
socket: Socket | null
|
||||
isConnected: boolean
|
||||
@@ -95,7 +88,6 @@ interface SocketContextType {
|
||||
blockedJoinWorkflowId: string | null
|
||||
currentWorkflowId: string | null
|
||||
currentSocketId: string | null
|
||||
presenceUsers: PresenceUser[]
|
||||
joinWorkflow: (workflowId: string) => void
|
||||
leaveWorkflow: () => void
|
||||
retryConnection: () => void
|
||||
@@ -129,7 +121,6 @@ const SocketContext = createContext<SocketContextType>({
|
||||
blockedJoinWorkflowId: null,
|
||||
currentWorkflowId: null,
|
||||
currentSocketId: null,
|
||||
presenceUsers: [],
|
||||
joinWorkflow: () => {},
|
||||
leaveWorkflow: () => {},
|
||||
retryConnection: () => {},
|
||||
@@ -166,7 +157,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
const [isRetryingWorkflowJoin, setIsRetryingWorkflowJoin] = useState(false)
|
||||
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null)
|
||||
const [currentSocketId, setCurrentSocketId] = useState<string | null>(null)
|
||||
const [presenceUsers, setPresenceUsers] = useState<PresenceUser[]>([])
|
||||
const [authFailed, setAuthFailed] = useState(false)
|
||||
const [blockedJoinWorkflowId, setBlockedJoinWorkflowId] = useState<string | null>(null)
|
||||
const [explicitWorkflowId, setExplicitWorkflowId] = useState<string | null>(null)
|
||||
@@ -202,6 +192,21 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
const positionUpdateTimeouts = useRef<Map<string, number>>(new Map())
|
||||
const pendingPositionUpdates = useRef<Map<string, any>>(new Map())
|
||||
|
||||
/**
|
||||
* Presence is high-frequency (cursor frames many times per second) so it lives
|
||||
* in {@link usePresenceStore}, not the broad socket context — writing it here no
|
||||
* longer mints a new context value, so emitter-only `useSocket()` consumers stop
|
||||
* re-rendering on every cursor frame. These thin wrappers delegate to the store's
|
||||
* stable actions read via `getState()`.
|
||||
*/
|
||||
const setPresenceUsers = useCallback((users: PresenceUser[]) => {
|
||||
usePresenceStore.getState().setPresenceUsers(users)
|
||||
}, [])
|
||||
|
||||
const updatePresenceUsers = useCallback((updater: (prev: PresenceUser[]) => PresenceUser[]) => {
|
||||
usePresenceStore.getState().updatePresenceUsers(updater)
|
||||
}, [])
|
||||
|
||||
const setVisibleWorkflowId = useCallback((workflowId: string | null) => {
|
||||
currentWorkflowIdRef.current = workflowId
|
||||
setCurrentWorkflowId(workflowId)
|
||||
@@ -255,7 +260,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
setPresenceUsers([])
|
||||
setVisibleWorkflowId(null)
|
||||
},
|
||||
[resetVisibleWorkflowState, setVisibleWorkflowId]
|
||||
[resetVisibleWorkflowState, setPresenceUsers, setVisibleWorkflowId]
|
||||
)
|
||||
|
||||
const executeJoinCommands = useCallback(
|
||||
@@ -501,7 +506,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
return
|
||||
}
|
||||
|
||||
setPresenceUsers((prev) => {
|
||||
updatePresenceUsers((prev) => {
|
||||
const prevMap = new Map(prev.map((u) => [u.socketId, u]))
|
||||
|
||||
return users.map((user) => {
|
||||
@@ -675,7 +680,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
return
|
||||
}
|
||||
|
||||
setPresenceUsers((prev) => {
|
||||
updatePresenceUsers((prev) => {
|
||||
const existingIndex = prev.findIndex((user) => user.socketId === data.socketId)
|
||||
if (existingIndex === -1) {
|
||||
logger.debug('Received cursor-update for unknown user', { socketId: data.socketId })
|
||||
@@ -693,7 +698,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
return
|
||||
}
|
||||
|
||||
setPresenceUsers((prev) => {
|
||||
updatePresenceUsers((prev) => {
|
||||
const existingIndex = prev.findIndex((user) => user.socketId === data.socketId)
|
||||
if (existingIndex === -1) {
|
||||
logger.debug('Received selection-update for unknown user', {
|
||||
@@ -779,6 +784,9 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
socketRef.current.close()
|
||||
socketRef.current = null
|
||||
}
|
||||
|
||||
// Clear the module-global presence store on unmount to match the prior per-provider lifetime.
|
||||
usePresenceStore.getState().clearPresenceUsers()
|
||||
}
|
||||
}, [user?.id])
|
||||
|
||||
@@ -1116,7 +1124,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
blockedJoinWorkflowId,
|
||||
currentWorkflowId,
|
||||
currentSocketId,
|
||||
presenceUsers,
|
||||
joinWorkflow,
|
||||
leaveWorkflow,
|
||||
retryConnection,
|
||||
@@ -1147,7 +1154,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
blockedJoinWorkflowId,
|
||||
currentWorkflowId,
|
||||
currentSocketId,
|
||||
presenceUsers,
|
||||
joinWorkflow,
|
||||
leaveWorkflow,
|
||||
retryConnection,
|
||||
|
||||
@@ -12,18 +12,104 @@ import {
|
||||
} from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { highlight, languages } from 'prismjs'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/components/prism-python'
|
||||
import 'prismjs/components/prism-json'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import './code.css'
|
||||
|
||||
/**
|
||||
* Re-export Prism.js highlighting utilities for use across the app.
|
||||
* Components can import these instead of importing from prismjs directly.
|
||||
* Shape of the lazily-loaded Prism module (`./prism`), narrowed to the two
|
||||
* members this component uses for highlighting.
|
||||
*/
|
||||
export { highlight, languages }
|
||||
type PrismModule = typeof import('./prism')
|
||||
|
||||
/**
|
||||
* Module-level singleton promise for the lazily-loaded Prism module.
|
||||
*
|
||||
* Prism (core + the side-effectful JS/Python/JSON grammar registrations) is kept
|
||||
* out of this module's static import graph so it never lands in bundles that only
|
||||
* pull `Code` through the shared `@/components/emcn` barrel. It is loaded once per
|
||||
* session on the first highlight and cached here for all subsequent viewers.
|
||||
*/
|
||||
let prismModulePromise: Promise<PrismModule> | null = null
|
||||
|
||||
/**
|
||||
* The resolved Prism module, cached synchronously once the first load settles so
|
||||
* later viewers can initialize from it without a null→loaded render cycle.
|
||||
*/
|
||||
let resolvedPrism: PrismModule | null = null
|
||||
|
||||
/**
|
||||
* Loads the Prism module once and caches both the in-flight promise and the
|
||||
* resolved module for reuse.
|
||||
*
|
||||
* @returns A promise resolving to the Prism highlighting utilities.
|
||||
*/
|
||||
function loadPrism(): Promise<PrismModule> {
|
||||
if (!prismModulePromise) {
|
||||
prismModulePromise = import('./prism').then((mod) => {
|
||||
resolvedPrism = mod
|
||||
return mod
|
||||
})
|
||||
}
|
||||
return prismModulePromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes a client component to the lazily-loaded Prism module.
|
||||
*
|
||||
* Seeds from {@link resolvedPrism} so viewers mounted after the first load
|
||||
* highlight synchronously; otherwise returns `null` until Prism resolves (so
|
||||
* callers render the plaintext fallback), then the loaded module.
|
||||
*
|
||||
* @returns The loaded Prism module, or `null` while loading.
|
||||
*/
|
||||
function usePrism(): PrismModule | null {
|
||||
const [prism, setPrism] = useState<PrismModule | null>(resolvedPrism)
|
||||
|
||||
useEffect(() => {
|
||||
if (prism) return
|
||||
let active = true
|
||||
loadPrism().then((mod) => {
|
||||
if (active) setPrism(mod)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [prism])
|
||||
|
||||
return prism
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes HTML special characters so raw code can be safely injected via
|
||||
* `dangerouslySetInnerHTML` as the plaintext fallback before Prism loads (and
|
||||
* for unknown languages). Matches Prism's own escaping for visual parity.
|
||||
*
|
||||
* @param text - The raw code text to escape
|
||||
* @returns The HTML-escaped text
|
||||
*/
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlights a single line of code, falling back to escaped plaintext when Prism
|
||||
* has not loaded yet or the language grammar is unavailable.
|
||||
*
|
||||
* @param prism - The loaded Prism module, or `null` while loading
|
||||
* @param text - The line of code to highlight
|
||||
* @param language - The language key (e.g. `json`, `javascript`, `python`)
|
||||
* @returns Highlighted HTML, or escaped plaintext as a fallback
|
||||
*/
|
||||
function highlightOrEscape(prism: PrismModule | null, text: string, language: string): string {
|
||||
if (!prism) return escapeHtml(text)
|
||||
const grammar = prism.languages[language] || prism.languages.javascript
|
||||
return prism.highlight(text, grammar, language)
|
||||
}
|
||||
|
||||
/**
|
||||
* Code editor configuration and constants.
|
||||
@@ -844,6 +930,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [containerHeight, setContainerHeight] = useState(400)
|
||||
const prism = usePrism()
|
||||
|
||||
const lines = useMemo(() => code.split('\n'), [code])
|
||||
const gutterWidth = useMemo(() => calculateGutterWidth(lines.length), [lines.length])
|
||||
@@ -890,11 +977,10 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
|
||||
|
||||
// Only process visible lines for efficiency (not all lines)
|
||||
const visibleLines = useMemo(() => {
|
||||
const lang = languages[language] || languages.javascript
|
||||
const hasSearch = searchQuery?.trim()
|
||||
|
||||
return visibleLineIndices.map((idx) => {
|
||||
let html = highlight(displayLines[idx], lang, language)
|
||||
let html = highlightOrEscape(prism, displayLines[idx], language)
|
||||
|
||||
if (hasSearch && searchQuery) {
|
||||
const result = applySearchHighlightingToLine(
|
||||
@@ -908,7 +994,15 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
|
||||
|
||||
return { lineNumber: idx + 1, html }
|
||||
})
|
||||
}, [displayLines, language, visibleLineIndices, searchQuery, currentMatchIndex, matchOffsets])
|
||||
}, [
|
||||
prism,
|
||||
displayLines,
|
||||
language,
|
||||
visibleLineIndices,
|
||||
searchQuery,
|
||||
currentMatchIndex,
|
||||
matchOffsets,
|
||||
])
|
||||
|
||||
const hasCollapsibleContent = collapsibleLines.size > 0
|
||||
const effectiveShowCollapseColumn = showCollapseColumn && hasCollapsibleContent
|
||||
@@ -1037,6 +1131,7 @@ const ViewerInner = memo(function ViewerInner({
|
||||
contentRef,
|
||||
showCollapseColumn,
|
||||
}: ViewerInnerProps) {
|
||||
const prism = usePrism()
|
||||
const lines = useMemo(() => code.split('\n'), [code])
|
||||
const gutterWidth = useMemo(() => calculateGutterWidth(lines.length), [lines.length])
|
||||
|
||||
@@ -1083,22 +1178,21 @@ const ViewerInner = memo(function ViewerInner({
|
||||
|
||||
// Pre-compute highlighted lines with search for visible indices (for gutter mode)
|
||||
const highlightedVisibleLines = useMemo(() => {
|
||||
const lang = languages[language] || languages.javascript
|
||||
|
||||
if (!searchQuery?.trim()) {
|
||||
return visibleLineIndices.map((idx) => ({
|
||||
lineNumber: idx + 1,
|
||||
html: highlight(displayLines[idx], lang, language) || ' ',
|
||||
html: highlightOrEscape(prism, displayLines[idx], language) || ' ',
|
||||
}))
|
||||
}
|
||||
|
||||
return visibleLineIndices.map((idx) => {
|
||||
let html = highlight(displayLines[idx], lang, language)
|
||||
let html = highlightOrEscape(prism, displayLines[idx], language)
|
||||
const matchCounter = { count: cumulativeMatches[idx] }
|
||||
html = applySearchHighlighting(html, searchQuery, currentMatchIndex, matchCounter)
|
||||
return { lineNumber: idx + 1, html: html || ' ' }
|
||||
})
|
||||
}, [
|
||||
prism,
|
||||
displayLines,
|
||||
language,
|
||||
visibleLineIndices,
|
||||
@@ -1109,16 +1203,15 @@ const ViewerInner = memo(function ViewerInner({
|
||||
|
||||
// Pre-compute simple highlighted code (for no-gutter mode)
|
||||
const highlightedCode = useMemo(() => {
|
||||
const lang = languages[language] || languages.javascript
|
||||
const visibleCode = visibleLineIndices.map((idx) => displayLines[idx]).join('\n')
|
||||
let html = highlight(visibleCode, lang, language)
|
||||
let html = highlightOrEscape(prism, visibleCode, language)
|
||||
|
||||
if (searchQuery?.trim()) {
|
||||
const matchCounter = { count: 0 }
|
||||
html = applySearchHighlighting(html, searchQuery, currentMatchIndex, matchCounter)
|
||||
}
|
||||
return html
|
||||
}, [displayLines, language, visibleLineIndices, searchQuery, currentMatchIndex])
|
||||
}, [prism, displayLines, language, visibleLineIndices, searchQuery, currentMatchIndex])
|
||||
|
||||
const whitespaceClass = wrapText ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { highlight, languages } from 'prismjs'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/components/prism-python'
|
||||
import 'prismjs/components/prism-json'
|
||||
|
||||
/**
|
||||
* Prism.js highlighting utilities isolated in a dedicated module.
|
||||
*
|
||||
* The grammar imports above are side-effectful (they register languages on the
|
||||
* shared `Prism.languages` registry), which marks any module that statically
|
||||
* imports them as having side effects and therefore non-tree-shakeable. Keeping
|
||||
* them here — rather than in `code.tsx` — ensures Prism only enters bundles that
|
||||
* actually import `highlight`/`languages`, instead of every consumer of the
|
||||
* shared `@/components/emcn` barrel (which re-exports `Code`).
|
||||
*
|
||||
* `code.tsx` itself never imports this module statically; it loads it lazily via
|
||||
* dynamic `import()` on first highlight.
|
||||
*/
|
||||
export { highlight, languages }
|
||||
@@ -76,10 +76,9 @@ export {
|
||||
Code,
|
||||
calculateGutterWidth,
|
||||
getCodeEditorProps,
|
||||
highlight,
|
||||
languages,
|
||||
} from './code/code'
|
||||
export { CopyCodeButton } from './code/copy-code-button'
|
||||
export { highlight, languages } from './code/prism'
|
||||
export { CollapsibleCard, type CollapsibleCardProps } from './collapsible-card/collapsible-card'
|
||||
export {
|
||||
Combobox,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import type { PresenceState } from '@/stores/presence/types'
|
||||
|
||||
/**
|
||||
* Live collaborator presence for the active workflow room.
|
||||
*
|
||||
* Presence is high-frequency (cursor frames arrive many times per second), so it
|
||||
* lives in its own store rather than the broad socket context. Only presence
|
||||
* consumers (`<Cursors>`, `<Avatars>`) subscribe to it, so cursor frames no
|
||||
* longer re-render emitter-only `useSocket()` consumers such as `WorkflowContent`.
|
||||
*/
|
||||
export const usePresenceStore = create<PresenceState>()(
|
||||
devtools(
|
||||
(set) => ({
|
||||
presenceUsers: [],
|
||||
setPresenceUsers: (users) => set({ presenceUsers: users }),
|
||||
updatePresenceUsers: (updater) =>
|
||||
set((state) => ({ presenceUsers: updater(state.presenceUsers) })),
|
||||
clearPresenceUsers: () => set({ presenceUsers: [] }),
|
||||
}),
|
||||
{ name: 'presence-store' }
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* A collaborator present in the active workflow room. Mirrors the presence
|
||||
* payload broadcast by the realtime server (`presence-update`, cursor/selection
|
||||
* deltas, and `join-workflow-success`).
|
||||
*/
|
||||
export interface PresenceUser {
|
||||
socketId: string
|
||||
userId: string
|
||||
userName: string
|
||||
avatarUrl?: string | null
|
||||
cursor?: { x: number; y: number } | null
|
||||
selection?: { type: 'block' | 'edge' | 'none'; id?: string }
|
||||
}
|
||||
|
||||
export interface PresenceState {
|
||||
presenceUsers: PresenceUser[]
|
||||
/** Replace the full presence list (join success, presence-update). */
|
||||
setPresenceUsers: (users: PresenceUser[]) => void
|
||||
/** Apply a functional update to the presence list (cursor/selection deltas). */
|
||||
updatePresenceUsers: (updater: (prev: PresenceUser[]) => PresenceUser[]) => void
|
||||
/** Clear presence when leaving or losing the workflow room. */
|
||||
clearPresenceUsers: () => void
|
||||
}
|
||||
Reference in New Issue
Block a user