mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(workflow-renderer): extract edge, subflow, and note Views into @sim/workflow-renderer (#5263)
Adds a @sim/workflow-renderer package with pure, props-driven WorkflowEdgeView, SubflowNodeView, and NoteBlockView shared by the editor and (future) docs preview. Moves block-dimensions constants into the package. Each editor node becomes a thin Container that wires stores/permissions and injects the editor-only ActionBar via a slot. No optimizePackageImports for the workspace component packages (avoids the toast-style module duplication); Tailwind scans the package source.
This commit is contained in:
+19
-483
@@ -1,10 +1,6 @@
|
||||
import { memo, useCallback, useMemo } from 'react'
|
||||
import { BLOCK_DIMENSIONS, NoteBlockView } from '@sim/workflow-renderer'
|
||||
import type { NodeProps } from 'reactflow'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import 'streamdown/styles.css'
|
||||
import { cn, handleKeyboardActivation } from '@sim/emcn'
|
||||
import { BLOCK_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
|
||||
import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
@@ -14,9 +10,7 @@ import type { WorkflowBlockProps } from '../workflow-block/types'
|
||||
|
||||
interface NoteBlockNodeData extends WorkflowBlockProps {}
|
||||
|
||||
/**
|
||||
* Extract string value from subblock value object or primitive
|
||||
*/
|
||||
/** Extracts the string content from a raw subblock value (string or `{ value }`). */
|
||||
function extractFieldValue(rawValue: unknown): string | undefined {
|
||||
if (typeof rawValue === 'string') return rawValue
|
||||
if (rawValue && typeof rawValue === 'object' && 'value' in rawValue) {
|
||||
@@ -26,441 +20,14 @@ function extractFieldValue(rawValue: unknown): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
type EmbedInfo = {
|
||||
url: string
|
||||
type: 'iframe' | 'video' | 'audio'
|
||||
aspectRatio?: string
|
||||
}
|
||||
|
||||
const EMBED_SCALE = 0.78
|
||||
const EMBED_INVERSE_SCALE = `${(1 / EMBED_SCALE) * 100}%`
|
||||
|
||||
function getTwitchParent(): string {
|
||||
return typeof window !== 'undefined' ? window.location.hostname : 'localhost'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get embed info for supported media platforms
|
||||
* Editor container for {@link NoteBlockView}.
|
||||
*
|
||||
* Resolves the note's markdown content from its subblock value, the enabled/ring
|
||||
* visual state from {@link useBlockVisual}, and edit permission, then publishes
|
||||
* deterministic dimensions and renders the pure view shared with the docs
|
||||
* preview — injecting the editor-only {@link ActionBar} via the `actionBar` slot.
|
||||
*/
|
||||
function getEmbedInfo(url: string): EmbedInfo | null {
|
||||
const youtubeMatch = url.match(
|
||||
/(?:youtube\.com\/watch\?(?:.*&)?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/
|
||||
)
|
||||
if (youtubeMatch) {
|
||||
return { url: `https://www.youtube.com/embed/${youtubeMatch[1]}`, type: 'iframe' }
|
||||
}
|
||||
|
||||
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/)
|
||||
if (vimeoMatch) {
|
||||
return { url: `https://player.vimeo.com/video/${vimeoMatch[1]}`, type: 'iframe' }
|
||||
}
|
||||
|
||||
const dailymotionMatch = url.match(/dailymotion\.com\/video\/([a-zA-Z0-9]+)/)
|
||||
if (dailymotionMatch) {
|
||||
return { url: `https://www.dailymotion.com/embed/video/${dailymotionMatch[1]}`, type: 'iframe' }
|
||||
}
|
||||
|
||||
const twitchVideoMatch = url.match(/twitch\.tv\/videos\/(\d+)/)
|
||||
if (twitchVideoMatch) {
|
||||
return {
|
||||
url: `https://player.twitch.tv/?video=${twitchVideoMatch[1]}&parent=${getTwitchParent()}`,
|
||||
type: 'iframe',
|
||||
}
|
||||
}
|
||||
|
||||
const twitchChannelMatch = url.match(/twitch\.tv\/([a-zA-Z0-9_]+)(?:\/|$)/)
|
||||
if (twitchChannelMatch && !url.includes('/videos/') && !url.includes('/clip/')) {
|
||||
return {
|
||||
url: `https://player.twitch.tv/?channel=${twitchChannelMatch[1]}&parent=${getTwitchParent()}`,
|
||||
type: 'iframe',
|
||||
}
|
||||
}
|
||||
|
||||
const streamableMatch = url.match(/streamable\.com\/([a-zA-Z0-9]+)/)
|
||||
if (streamableMatch) {
|
||||
return { url: `https://streamable.com/e/${streamableMatch[1]}`, type: 'iframe' }
|
||||
}
|
||||
|
||||
const wistiaMatch = url.match(/(?:wistia\.com|wistia\.net)\/(?:medias|embed)\/([a-zA-Z0-9]+)/)
|
||||
if (wistiaMatch) {
|
||||
return { url: `https://fast.wistia.net/embed/iframe/${wistiaMatch[1]}`, type: 'iframe' }
|
||||
}
|
||||
|
||||
const tiktokMatch = url.match(/tiktok\.com\/@[^/]+\/video\/(\d+)/)
|
||||
if (tiktokMatch) {
|
||||
return {
|
||||
url: `https://www.tiktok.com/embed/v2/${tiktokMatch[1]}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '9/16',
|
||||
}
|
||||
}
|
||||
|
||||
const soundcloudMatch = url.match(/soundcloud\.com\/([a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+)/)
|
||||
if (soundcloudMatch) {
|
||||
return {
|
||||
url: `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=true&show_reposts=false&show_teaser=false`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '3/2',
|
||||
}
|
||||
}
|
||||
|
||||
const spotifyTrackMatch = url.match(/open\.spotify\.com\/track\/([a-zA-Z0-9]+)/)
|
||||
if (spotifyTrackMatch) {
|
||||
return {
|
||||
url: `https://open.spotify.com/embed/track/${spotifyTrackMatch[1]}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '3.7/1',
|
||||
}
|
||||
}
|
||||
|
||||
const spotifyAlbumMatch = url.match(/open\.spotify\.com\/album\/([a-zA-Z0-9]+)/)
|
||||
if (spotifyAlbumMatch) {
|
||||
return {
|
||||
url: `https://open.spotify.com/embed/album/${spotifyAlbumMatch[1]}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '2/3',
|
||||
}
|
||||
}
|
||||
|
||||
const spotifyPlaylistMatch = url.match(/open\.spotify\.com\/playlist\/([a-zA-Z0-9]+)/)
|
||||
if (spotifyPlaylistMatch) {
|
||||
return {
|
||||
url: `https://open.spotify.com/embed/playlist/${spotifyPlaylistMatch[1]}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '2/3',
|
||||
}
|
||||
}
|
||||
|
||||
const spotifyEpisodeMatch = url.match(/open\.spotify\.com\/episode\/([a-zA-Z0-9]+)/)
|
||||
if (spotifyEpisodeMatch) {
|
||||
return {
|
||||
url: `https://open.spotify.com/embed/episode/${spotifyEpisodeMatch[1]}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '2.5/1',
|
||||
}
|
||||
}
|
||||
|
||||
const spotifyShowMatch = url.match(/open\.spotify\.com\/show\/([a-zA-Z0-9]+)/)
|
||||
if (spotifyShowMatch) {
|
||||
return {
|
||||
url: `https://open.spotify.com/embed/show/${spotifyShowMatch[1]}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '3.7/1',
|
||||
}
|
||||
}
|
||||
|
||||
const appleMusicSongMatch = url.match(/music\.apple\.com\/([a-z]{2})\/song\/[^/]+\/(\d+)/)
|
||||
if (appleMusicSongMatch) {
|
||||
const [, country, songId] = appleMusicSongMatch
|
||||
return {
|
||||
url: `https://embed.music.apple.com/${country}/song/${songId}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '3/2',
|
||||
}
|
||||
}
|
||||
|
||||
const appleMusicAlbumMatch = url.match(/music\.apple\.com\/([a-z]{2})\/album\/(?:[^/]+\/)?(\d+)/)
|
||||
if (appleMusicAlbumMatch) {
|
||||
const [, country, albumId] = appleMusicAlbumMatch
|
||||
return {
|
||||
url: `https://embed.music.apple.com/${country}/album/${albumId}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '2/3',
|
||||
}
|
||||
}
|
||||
|
||||
const appleMusicPlaylistMatch = url.match(
|
||||
/music\.apple\.com\/([a-z]{2})\/playlist\/[^/]+\/(pl\.[a-zA-Z0-9]+)/
|
||||
)
|
||||
if (appleMusicPlaylistMatch) {
|
||||
const [, country, playlistId] = appleMusicPlaylistMatch
|
||||
return {
|
||||
url: `https://embed.music.apple.com/${country}/playlist/${playlistId}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '2/3',
|
||||
}
|
||||
}
|
||||
|
||||
const loomMatch = url.match(/loom\.com\/share\/([a-zA-Z0-9]+)/)
|
||||
if (loomMatch) {
|
||||
return { url: `https://www.loom.com/embed/${loomMatch[1]}`, type: 'iframe' }
|
||||
}
|
||||
|
||||
const facebookVideoMatch =
|
||||
url.match(/facebook\.com\/.*\/videos\/(\d+)/) || url.match(/fb\.watch\/([a-zA-Z0-9_-]+)/)
|
||||
if (facebookVideoMatch) {
|
||||
return {
|
||||
url: `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(url)}&show_text=false`,
|
||||
type: 'iframe',
|
||||
}
|
||||
}
|
||||
|
||||
const instagramReelMatch = url.match(/instagram\.com\/reel\/([a-zA-Z0-9_-]+)/)
|
||||
if (instagramReelMatch) {
|
||||
return {
|
||||
url: `https://www.instagram.com/reel/${instagramReelMatch[1]}/embed`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '9/16',
|
||||
}
|
||||
}
|
||||
|
||||
const instagramPostMatch = url.match(/instagram\.com\/p\/([a-zA-Z0-9_-]+)/)
|
||||
if (instagramPostMatch) {
|
||||
return {
|
||||
url: `https://www.instagram.com/p/${instagramPostMatch[1]}/embed`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '4/5',
|
||||
}
|
||||
}
|
||||
|
||||
const twitterMatch = url.match(/(?:twitter\.com|x\.com)\/[^/]+\/status\/(\d+)/)
|
||||
if (twitterMatch) {
|
||||
return {
|
||||
url: `https://platform.twitter.com/embed/Tweet.html?id=${twitterMatch[1]}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '3/4',
|
||||
}
|
||||
}
|
||||
|
||||
const rumbleMatch =
|
||||
url.match(/rumble\.com\/embed\/([a-zA-Z0-9]+)/) || url.match(/rumble\.com\/([a-zA-Z0-9]+)-/)
|
||||
if (rumbleMatch) {
|
||||
return { url: `https://rumble.com/embed/${rumbleMatch[1]}/`, type: 'iframe' }
|
||||
}
|
||||
|
||||
const bilibiliMatch = url.match(/bilibili\.com\/video\/(BV[a-zA-Z0-9]+)/)
|
||||
if (bilibiliMatch) {
|
||||
return {
|
||||
url: `https://player.bilibili.com/player.html?bvid=${bilibiliMatch[1]}&high_quality=1`,
|
||||
type: 'iframe',
|
||||
}
|
||||
}
|
||||
|
||||
const vidyardMatch = url.match(/(?:vidyard\.com|share\.vidyard\.com)\/watch\/([a-zA-Z0-9]+)/)
|
||||
if (vidyardMatch) {
|
||||
return { url: `https://play.vidyard.com/${vidyardMatch[1]}`, type: 'iframe' }
|
||||
}
|
||||
|
||||
const cfStreamMatch =
|
||||
url.match(/cloudflarestream\.com\/([a-zA-Z0-9]+)/) ||
|
||||
url.match(/videodelivery\.net\/([a-zA-Z0-9]+)/)
|
||||
if (cfStreamMatch) {
|
||||
return { url: `https://iframe.cloudflarestream.com/${cfStreamMatch[1]}`, type: 'iframe' }
|
||||
}
|
||||
|
||||
const twitchClipMatch =
|
||||
url.match(/clips\.twitch\.tv\/([a-zA-Z0-9_-]+)/) ||
|
||||
url.match(/twitch\.tv\/[^/]+\/clip\/([a-zA-Z0-9_-]+)/)
|
||||
if (twitchClipMatch) {
|
||||
return {
|
||||
url: `https://clips.twitch.tv/embed?clip=${twitchClipMatch[1]}&parent=${getTwitchParent()}`,
|
||||
type: 'iframe',
|
||||
}
|
||||
}
|
||||
|
||||
const mixcloudMatch = url.match(/mixcloud\.com\/([^/]+\/[^/]+)/)
|
||||
if (mixcloudMatch) {
|
||||
return {
|
||||
url: `https://www.mixcloud.com/widget/iframe/?feed=%2F${encodeURIComponent(mixcloudMatch[1])}%2F&hide_cover=1`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '2/1',
|
||||
}
|
||||
}
|
||||
|
||||
const googleDriveMatch = url.match(/drive\.google\.com\/file\/d\/([a-zA-Z0-9_-]+)/)
|
||||
if (googleDriveMatch) {
|
||||
return { url: `https://drive.google.com/file/d/${googleDriveMatch[1]}/preview`, type: 'iframe' }
|
||||
}
|
||||
|
||||
if (url.includes('dropbox.com') && /\.(mp4|mov|webm)/.test(url)) {
|
||||
const directUrl = url
|
||||
.replace('www.dropbox.com', 'dl.dropboxusercontent.com')
|
||||
.replace('?dl=0', '')
|
||||
return { url: directUrl, type: 'video' }
|
||||
}
|
||||
|
||||
const tenorMatch = url.match(/tenor\.com\/view\/[^/]+-(\d+)/)
|
||||
if (tenorMatch) {
|
||||
return { url: `https://tenor.com/embed/${tenorMatch[1]}`, type: 'iframe', aspectRatio: '1/1' }
|
||||
}
|
||||
|
||||
const giphyMatch = url.match(/giphy\.com\/(?:gifs|embed)\/(?:.*-)?([a-zA-Z0-9]+)/)
|
||||
if (giphyMatch) {
|
||||
return { url: `https://giphy.com/embed/${giphyMatch[1]}`, type: 'iframe', aspectRatio: '1/1' }
|
||||
}
|
||||
|
||||
if (/\.(mp4|webm|ogg|mov)(\?|$)/i.test(url)) {
|
||||
return { url, type: 'video' }
|
||||
}
|
||||
|
||||
if (/\.(mp3|wav|m4a|aac)(\?|$)/i.test(url)) {
|
||||
return { url, type: 'audio' }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact markdown renderer for note blocks with tight spacing
|
||||
*/
|
||||
const NOTE_REMARK_PLUGINS = [remarkBreaks]
|
||||
|
||||
const NOTE_COMPONENTS = {
|
||||
p: ({ children }: { children?: React.ReactNode }) => (
|
||||
<p className='mb-1 break-words text-[var(--text-primary)] text-sm leading-[1.25rem] last:mb-0'>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
h1: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h1 className='mt-3 mb-3 break-words font-semibold text-[var(--text-primary)] text-lg first:mt-0'>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h2 className='mt-2.5 mb-2.5 break-words font-semibold text-[var(--text-primary)] text-base first:mt-0'>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h3 className='mt-2 mb-2 break-words font-semibold text-[var(--text-primary)] text-sm first:mt-0'>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h4 className='mt-2 mb-2 break-words font-semibold text-[var(--text-primary)] text-xs first:mt-0'>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
ul: ({ children }: { children?: React.ReactNode }) => (
|
||||
<ul className='mt-1 mb-1 list-disc space-y-1 break-words pl-6 text-[var(--text-primary)] text-sm'>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }: { children?: React.ReactNode }) => (
|
||||
<ol className='mt-1 mb-1 list-decimal space-y-1 break-words pl-6 text-[var(--text-primary)] text-sm'>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }: { children?: React.ReactNode }) => <li className='break-words'>{children}</li>,
|
||||
inlineCode: ({ children }: { children?: React.ReactNode }) => (
|
||||
<code className='whitespace-normal rounded bg-[var(--surface-5)] px-1 py-0.5 font-mono text-[var(--caution)] text-xs'>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
code: ({ children, className, ...props }: { children?: React.ReactNode; className?: string }) => (
|
||||
<code
|
||||
{...props}
|
||||
className='block whitespace-pre-wrap break-words rounded bg-[var(--surface-5)] p-2 text-[var(--text-primary)] text-xs'
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => {
|
||||
const embedInfo = href ? getEmbedInfo(href) : null
|
||||
if (embedInfo) {
|
||||
return (
|
||||
<span className='my-2 block w-full'>
|
||||
<a
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='mb-1 block break-all text-[var(--brand-secondary)] underline-offset-2 hover-hover:underline'
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
<span className='block w-full overflow-hidden rounded-md'>
|
||||
{embedInfo.type === 'iframe' && (
|
||||
<span
|
||||
className='block overflow-hidden'
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: embedInfo.aspectRatio || '16/9',
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src={embedInfo.url}
|
||||
title='Media'
|
||||
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
|
||||
allowFullScreen
|
||||
loading='lazy'
|
||||
className='origin-top-left'
|
||||
style={{
|
||||
width: EMBED_INVERSE_SCALE,
|
||||
height: EMBED_INVERSE_SCALE,
|
||||
transform: `scale(${EMBED_SCALE})`,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{embedInfo.type === 'video' && (
|
||||
<video
|
||||
src={embedInfo.url}
|
||||
controls
|
||||
preload='metadata'
|
||||
className='aspect-video w-full'
|
||||
>
|
||||
<track kind='captions' src='' default />
|
||||
</video>
|
||||
)}
|
||||
{embedInfo.type === 'audio' && (
|
||||
<audio src={embedInfo.url} controls preload='metadata' className='w-full'>
|
||||
<track kind='captions' src='' default />
|
||||
</audio>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='break-all text-[var(--brand-secondary)] underline-offset-2 hover-hover:underline'
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
strong: ({ children }: { children?: React.ReactNode }) => (
|
||||
<strong className='break-words font-semibold text-[var(--text-primary)]'>{children}</strong>
|
||||
),
|
||||
em: ({ children }: { children?: React.ReactNode }) => (
|
||||
<em className='break-words text-[var(--text-tertiary)]'>{children}</em>
|
||||
),
|
||||
blockquote: ({ children }: { children?: React.ReactNode }) => (
|
||||
<blockquote className='my-4 break-words border-[var(--divider)] border-l-2 pl-4 text-[var(--text-primary)] italic [&>p:first-child]:mt-0 [&>p:last-child]:mb-0 [&>p]:my-2'>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
table: ({ children }: { children?: React.ReactNode }) => (
|
||||
<div className='my-2 max-w-full overflow-x-auto'>
|
||||
<table className='w-full border-collapse text-xs'>{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }: { children?: React.ReactNode }) => (
|
||||
<thead className='border-[var(--border)] border-b'>{children}</thead>
|
||||
),
|
||||
tbody: ({ children }: { children?: React.ReactNode }) => <tbody>{children}</tbody>,
|
||||
tr: ({ children }: { children?: React.ReactNode }) => (
|
||||
<tr className='border-[var(--border)] border-b last:border-b-0'>{children}</tr>
|
||||
),
|
||||
th: ({ children }: { children?: React.ReactNode }) => (
|
||||
<th className='px-2 py-1 text-left font-semibold text-[var(--text-primary)]'>{children}</th>
|
||||
),
|
||||
td: ({ children }: { children?: React.ReactNode }) => (
|
||||
<td className='px-2 py-1 text-[var(--text-secondary)]'>{children}</td>
|
||||
),
|
||||
}
|
||||
|
||||
const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }) {
|
||||
return (
|
||||
<Streamdown mode='static' remarkPlugins={NOTE_REMARK_PLUGINS} components={NOTE_COMPONENTS}>
|
||||
{content}
|
||||
</Streamdown>
|
||||
)
|
||||
})
|
||||
|
||||
export const NoteBlock = memo(function NoteBlock({
|
||||
id,
|
||||
data,
|
||||
@@ -498,8 +65,8 @@ export const NoteBlock = memo(function NoteBlock({
|
||||
const canEditWorkflow = userPermissions.canEdit && !data.isWorkflowLocked
|
||||
|
||||
/**
|
||||
* Calculate deterministic dimensions based on content structure.
|
||||
* Uses fixed width and computed height to avoid ResizeObserver jitter.
|
||||
* Calculate deterministic dimensions based on content structure. Uses fixed
|
||||
* width and computed height to avoid ResizeObserver jitter.
|
||||
*/
|
||||
useBlockDimensions({
|
||||
blockId: id,
|
||||
@@ -516,45 +83,14 @@ export const NoteBlock = memo(function NoteBlock({
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='group relative'>
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'note-drag-handle relative z-[20] w-[250px] cursor-grab select-none rounded-lg border border-[var(--border)] bg-[var(--surface-2)] [&:active]:cursor-grabbing'
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(event) => handleKeyboardActivation(event, handleClick)}
|
||||
>
|
||||
<ActionBar blockId={id} blockType={type} disabled={!canEditWorkflow} />
|
||||
|
||||
<div className='flex items-center justify-between border-[var(--divider)] border-b p-2'>
|
||||
<div className='flex min-w-0 flex-1 items-center'>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate font-medium text-md',
|
||||
!isEnabled && 'text-[var(--text-muted)]'
|
||||
)}
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='relative overflow-hidden p-2'>
|
||||
<div className='relative max-w-full break-all'>
|
||||
{isEmpty ? (
|
||||
<p className='text-[var(--text-placeholder)] text-sm'>Add note…</p>
|
||||
) : (
|
||||
<NoteMarkdown content={content} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasRing && (
|
||||
<div className={cn('pointer-events-none absolute inset-0 z-40 rounded-lg', ringStyles)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<NoteBlockView
|
||||
name={name}
|
||||
content={content}
|
||||
isEnabled={isEnabled}
|
||||
hasRing={hasRing}
|
||||
ringStyles={ringStyles}
|
||||
onSelect={handleClick}
|
||||
actionBar={<ActionBar blockId={id} blockType={type} disabled={!canEditWorkflow} />}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
export { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop'
|
||||
export { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel'
|
||||
export {
|
||||
SubflowNodeComponent,
|
||||
type SubflowNodeData,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
|
||||
export { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
|
||||
|
||||
+26
-228
@@ -1,9 +1,7 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import { Badge, cn, handleKeyboardActivation } from '@sim/emcn'
|
||||
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
|
||||
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
|
||||
import { type SubflowNodeData, SubflowNodeView } from '@sim/workflow-renderer'
|
||||
import { type NodeProps, useReactFlow } from 'reactflow'
|
||||
import { hasDiffStatus } from '@/lib/workflows/diff/types'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
@@ -11,53 +9,12 @@ import { useLastRunPath } from '@/stores/execution'
|
||||
import { usePanelEditorStore } from '@/stores/panel'
|
||||
|
||||
/**
|
||||
* Data structure for subflow nodes (loop and parallel containers)
|
||||
*/
|
||||
export interface SubflowNodeData {
|
||||
width?: number
|
||||
height?: number
|
||||
parentId?: string
|
||||
extent?: 'parent'
|
||||
isPreview?: boolean
|
||||
/** Whether this subflow is selected in preview mode */
|
||||
isPreviewSelected?: boolean
|
||||
kind: 'loop' | 'parallel'
|
||||
name?: string
|
||||
/** Execution status passed by preview/snapshot views */
|
||||
executionStatus?: 'success' | 'error' | 'not-executed'
|
||||
/** Whether the parent workflow is locked and should render as read-only */
|
||||
isWorkflowLocked?: boolean
|
||||
}
|
||||
|
||||
const HANDLE_STYLE = {
|
||||
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
|
||||
transform: 'translateY(-50%)',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Reusable class names for Handle components.
|
||||
* Matches the styling pattern from workflow-block.tsx.
|
||||
*/
|
||||
const getHandleClasses = (position: 'left' | 'right') => {
|
||||
const baseClasses = '!z-[10] !cursor-crosshair !border-none !transition-[colors] !duration-150'
|
||||
const colorClasses = '!bg-[var(--workflow-edge)]'
|
||||
|
||||
const positionClasses = {
|
||||
left: '!left-[-8px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover-hover:!left-[-11px] hover-hover:!w-[10px] hover-hover:!rounded-l-full',
|
||||
right:
|
||||
'!right-[-8px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover-hover:!right-[-11px] hover-hover:!w-[10px] hover-hover:!rounded-r-full',
|
||||
}
|
||||
|
||||
return cn(baseClasses, colorClasses, positionClasses[position])
|
||||
}
|
||||
|
||||
/**
|
||||
* Subflow node component for loop and parallel execution containers.
|
||||
* Renders a resizable container with a header displaying the block name and icon,
|
||||
* handles for connections, and supports nested execution contexts.
|
||||
* Editor container for {@link SubflowNodeView}.
|
||||
*
|
||||
* @param props - Node properties containing data and id
|
||||
* @returns Rendered subflow node component
|
||||
* Resolves the subflow's enabled/locked/focus/diff/run state from the editor
|
||||
* stores, computes its nesting depth from the ReactFlow node tree, and renders
|
||||
* the pure view shared with the docs preview — injecting the editor-only
|
||||
* {@link ActionBar} through the view's `actionBar` slot.
|
||||
*/
|
||||
export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<SubflowNodeData>) => {
|
||||
const { getNodes } = useReactFlow()
|
||||
@@ -66,7 +23,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
const currentBlock = currentWorkflow.getBlockById(id)
|
||||
const diffStatus: DiffStatus =
|
||||
const diffStatus =
|
||||
currentWorkflow.isDiffMode && currentBlock && hasDiffStatus(currentBlock)
|
||||
? currentBlock.is_diff
|
||||
: undefined
|
||||
@@ -75,12 +32,10 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
const isLocked = currentBlock?.locked ?? false
|
||||
const isPreview = data?.isPreview || false
|
||||
|
||||
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
|
||||
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
|
||||
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
|
||||
const isFocused = currentBlockId === id
|
||||
|
||||
const isPreviewSelected = data?.isPreviewSelected || false
|
||||
|
||||
const lastRunPath = useLastRunPath()
|
||||
const executionStatus = data.executionStatus
|
||||
const runPathStatus: 'success' | 'error' | undefined =
|
||||
@@ -91,8 +46,8 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
: lastRunPath.get(id)
|
||||
|
||||
/**
|
||||
* Calculate the nesting level of this subflow node based on its parent hierarchy.
|
||||
* Used to apply appropriate styling for nested containers.
|
||||
* Nesting depth, walking the parent chain so the view can apply nested
|
||||
* container styling.
|
||||
*/
|
||||
const nestingLevel = useMemo(() => {
|
||||
let level = 0
|
||||
@@ -108,178 +63,21 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
return level
|
||||
}, [data?.parentId, getNodes])
|
||||
|
||||
const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
|
||||
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
|
||||
const BlockIcon = data.kind === 'loop' ? RepeatIcon : SplitIcon
|
||||
const blockIconBg = data.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
|
||||
const blockName = data.name || (data.kind === 'loop' ? 'Loop' : 'Parallel')
|
||||
|
||||
/**
|
||||
* Determine the ring styling based on subflow state priority:
|
||||
* 1. Focused (selected in editor), selected (shift-click/box), or preview selected - blue ring
|
||||
* 2. Diff status (version comparison) - green/orange ring
|
||||
* 3. Run path status (execution result) - green/red ring
|
||||
*/
|
||||
const isSelected = !isPreview && selected
|
||||
const hasRing =
|
||||
isFocused ||
|
||||
isSelected ||
|
||||
isPreviewSelected ||
|
||||
diffStatus === 'new' ||
|
||||
diffStatus === 'edited' ||
|
||||
!!runPathStatus
|
||||
|
||||
/**
|
||||
* Compute the ring color for the subflow selection indicator.
|
||||
* Uses boxShadow (not CSS outline) to match the ring styling of regular workflow blocks.
|
||||
* This works because ReactFlow renders child nodes as sibling divs at the viewport level
|
||||
* (not as DOM children), so children at zIndex 1000 don't clip the parent's boxShadow.
|
||||
*/
|
||||
const getRingColor = (): string | undefined => {
|
||||
if (!hasRing) return undefined
|
||||
if (isFocused || isSelected || isPreviewSelected) return 'var(--brand-secondary)'
|
||||
if (diffStatus === 'new') return 'var(--brand-accent)'
|
||||
if (diffStatus === 'edited') return 'var(--warning)'
|
||||
if (runPathStatus === 'success') {
|
||||
return executionStatus ? 'var(--brand-accent)' : 'var(--border-success)'
|
||||
}
|
||||
if (runPathStatus === 'error') return 'var(--text-error)'
|
||||
return undefined
|
||||
}
|
||||
const ringColor = getRingColor()
|
||||
|
||||
return (
|
||||
<div className='group pointer-events-none relative'>
|
||||
<div
|
||||
className='relative select-none rounded-lg border border-[var(--border-1)] transition-block-bg'
|
||||
style={{
|
||||
width: data.width || 500,
|
||||
height: data.height || 300,
|
||||
overflow: 'visible',
|
||||
pointerEvents: 'none',
|
||||
...(ringColor && {
|
||||
boxShadow: `0 0 0 1.75px ${ringColor}`,
|
||||
}),
|
||||
}}
|
||||
data-node-id={id}
|
||||
data-type='subflowNode'
|
||||
data-nesting-level={nestingLevel}
|
||||
data-subflow-selected={isFocused || isSelected || isPreviewSelected}
|
||||
>
|
||||
{!isPreview && <ActionBar blockId={id} blockType={data.kind} disabled={!canEditWorkflow} />}
|
||||
|
||||
{/* Header Section */}
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
aria-label={`Select ${blockName}`}
|
||||
onClick={() => setCurrentBlockId(id)}
|
||||
onKeyDown={(event) => handleKeyboardActivation(event, () => setCurrentBlockId(id))}
|
||||
className='workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-2 pr-3 pl-2 [&:active]:cursor-grabbing'
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2.5'>
|
||||
<div
|
||||
className='flex size-[24px] flex-shrink-0 items-center justify-center rounded-md'
|
||||
style={{ backgroundColor: isEnabled ? blockIconBg : 'gray' }}
|
||||
>
|
||||
<BlockIcon className='size-[16px] text-white' />
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate font-medium text-md',
|
||||
!isEnabled && 'text-[var(--text-muted)]'
|
||||
)}
|
||||
title={blockName}
|
||||
>
|
||||
{blockName}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
|
||||
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* Subflow body background. Captures clicks to select the subflow in the
|
||||
* panel editor, matching the header click behavior. Child nodes and edges
|
||||
* are rendered as sibling divs at the viewport level by ReactFlow (not as
|
||||
* DOM children), so enabling pointer events here doesn't block them.
|
||||
*/}
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={isPreview ? -1 : 0}
|
||||
aria-label={`Select ${blockName}`}
|
||||
className='workflow-drag-handle absolute inset-0 top-[44px] cursor-grab rounded-b-[8px] [&:active]:cursor-grabbing'
|
||||
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
|
||||
onClick={() => setCurrentBlockId(id)}
|
||||
onKeyDown={(event) => handleKeyboardActivation(event, () => setCurrentBlockId(id))}
|
||||
/>
|
||||
|
||||
{!isPreview && canEditWorkflow && (
|
||||
<div
|
||||
role='separator'
|
||||
aria-orientation='horizontal'
|
||||
className='absolute right-[8px] bottom-2 z-20 flex size-[32px] cursor-se-resize items-center justify-center text-muted-foreground'
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className='relative h-[calc(100%-50px)] pt-4 pr-[80px] pb-4 pl-4'
|
||||
data-dragarea='true'
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{/* Subflow Start */}
|
||||
<div
|
||||
className='absolute top-4 left-[16px] flex items-center justify-center rounded-lg border border-[var(--border-1)] bg-[var(--surface-2)] px-3 py-1.5'
|
||||
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
|
||||
data-parent-id={id}
|
||||
data-node-role={`${data.kind}-start`}
|
||||
data-extent='parent'
|
||||
>
|
||||
<span className='font-medium text-[var(--text-primary)] text-sm'>Start</span>
|
||||
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id={startHandleId}
|
||||
className={getHandleClasses('right')}
|
||||
style={{
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
data-parent-id={id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input handle on left middle */}
|
||||
<Handle
|
||||
type='target'
|
||||
position={Position.Left}
|
||||
className={getHandleClasses('left')}
|
||||
style={{
|
||||
...HANDLE_STYLE,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Output handle on right middle */}
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
className={getHandleClasses('right')}
|
||||
style={{
|
||||
...HANDLE_STYLE,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
id={endHandleId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SubflowNodeView
|
||||
id={id}
|
||||
data={data}
|
||||
selected={selected}
|
||||
isEnabled={isEnabled}
|
||||
isLocked={isLocked}
|
||||
isFocused={isFocused}
|
||||
runPathStatus={runPathStatus}
|
||||
diffStatus={diffStatus}
|
||||
nestingLevel={nestingLevel}
|
||||
canEditWorkflow={canEditWorkflow}
|
||||
onSelect={() => setCurrentBlockId(id)}
|
||||
actionBar={<ActionBar blockId={id} blockType={data.kind} disabled={!canEditWorkflow} />}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,7 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { Badge, cn, handleKeyboardActivation, Tooltip } from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { HANDLE_POSITIONS } from '@sim/workflow-renderer'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
|
||||
@@ -8,7 +9,6 @@ import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createMcpToolId } from '@/lib/mcp/shared'
|
||||
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
||||
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { calculateWorkflowBlockDimensions } from '@/lib/workflows/blocks/deterministic-dimensions'
|
||||
import { getConditionRows, getRouterRows } from '@/lib/workflows/dynamic-handle-topology'
|
||||
import {
|
||||
|
||||
+18
-112
@@ -1,8 +1,7 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from 'reactflow'
|
||||
import { type EdgeDiffStatus, WorkflowEdgeView } from '@sim/workflow-renderer'
|
||||
import type { EdgeProps } from 'reactflow'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import type { EdgeDiffStatus } from '@/lib/workflows/diff/types'
|
||||
import { useLastRunEdges } from '@/stores/execution'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
||||
|
||||
@@ -12,35 +11,14 @@ interface WorkflowEdgeProps extends EdgeProps {
|
||||
targetHandle?: string | null
|
||||
}
|
||||
|
||||
const WorkflowEdgeComponent = ({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
data,
|
||||
style,
|
||||
source,
|
||||
target,
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
}: WorkflowEdgeProps) => {
|
||||
const isHorizontal = sourcePosition === 'right' || sourcePosition === 'left'
|
||||
|
||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
borderRadius: 8,
|
||||
offset: isHorizontal ? 30 : 20,
|
||||
})
|
||||
|
||||
const isSelected = data?.isSelected ?? false
|
||||
/**
|
||||
* Editor container for {@link WorkflowEdgeView}.
|
||||
*
|
||||
* Reads the diff and execution stores, resolves the edge's diff/run state, and
|
||||
* passes it to the pure renderer shared with the docs preview.
|
||||
*/
|
||||
const WorkflowEdgeComponent = (props: WorkflowEdgeProps) => {
|
||||
const { id, data, source, target, sourceHandle, targetHandle } = props
|
||||
|
||||
const { diffAnalysis, isShowingDiff, isDiffReady } = useWorkflowDiffStore(
|
||||
useShallow((state) => ({
|
||||
@@ -51,14 +29,12 @@ const WorkflowEdgeComponent = ({
|
||||
)
|
||||
const lastRunEdges = useLastRunEdges()
|
||||
|
||||
const dataSourceHandle = (data as { sourceHandle?: string } | undefined)?.sourceHandle
|
||||
const isErrorEdge = (sourceHandle ?? dataSourceHandle) === 'error'
|
||||
const previewExecutionStatus = (
|
||||
data as { executionStatus?: 'success' | 'error' | 'not-executed' } | undefined
|
||||
)?.executionStatus
|
||||
const edgeRunStatus = previewExecutionStatus || lastRunEdges.get(id)
|
||||
const runStatus = previewExecutionStatus || lastRunEdges.get(id)
|
||||
|
||||
const edgeDiffStatus = useMemo((): EdgeDiffStatus => {
|
||||
const diffStatus = useMemo((): EdgeDiffStatus => {
|
||||
if (data?.isDeleted) return 'deleted'
|
||||
if (!diffAnalysis?.edge_diff || !isDiffReady) return null
|
||||
|
||||
@@ -84,84 +60,14 @@ const WorkflowEdgeComponent = ({
|
||||
targetHandle,
|
||||
])
|
||||
|
||||
const edgeStyle = useMemo(() => {
|
||||
let color = 'var(--workflow-edge)'
|
||||
let opacity = 1
|
||||
|
||||
if (edgeDiffStatus === 'deleted') {
|
||||
color = 'var(--text-error)'
|
||||
opacity = 0.7
|
||||
} else if (edgeDiffStatus === 'new') {
|
||||
color = 'var(--brand-accent)'
|
||||
} else if (edgeRunStatus === 'success') {
|
||||
// Use green for preview mode, default for canvas execution
|
||||
color = previewExecutionStatus ? 'var(--brand-accent)' : 'var(--border-success)'
|
||||
} else if (edgeRunStatus === 'error') {
|
||||
color = 'var(--text-error)'
|
||||
} else if (isErrorEdge) {
|
||||
// Error edges that weren't taken stay red
|
||||
color = 'var(--text-error)'
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
opacity = 0.5
|
||||
}
|
||||
|
||||
return {
|
||||
...(style ?? {}),
|
||||
strokeWidth: edgeDiffStatus
|
||||
? 3
|
||||
: edgeRunStatus === 'success' || edgeRunStatus === 'error'
|
||||
? 2.5
|
||||
: isSelected
|
||||
? 2.5
|
||||
: 2,
|
||||
stroke: color,
|
||||
strokeDasharray: edgeDiffStatus === 'deleted' ? '10,5' : undefined,
|
||||
opacity,
|
||||
}
|
||||
}, [style, edgeDiffStatus, isSelected, isErrorEdge, edgeRunStatus, previewExecutionStatus])
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge path={edgePath} style={edgeStyle} interactionWidth={30} />
|
||||
|
||||
{isSelected && (
|
||||
<EdgeLabelRenderer>
|
||||
<button
|
||||
type='button'
|
||||
className='nodrag nopan group flex size-[22px] cursor-pointer items-center justify-center transition-colors'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||
pointerEvents: 'all',
|
||||
zIndex: 1011,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (data?.onDelete) {
|
||||
data.onDelete(id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X className='size-4 text-[var(--text-error)] transition-colors group-hover:text-[color-mix(in_srgb,var(--text-error)_80%,transparent)]' />
|
||||
</button>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
</>
|
||||
<WorkflowEdgeView
|
||||
{...props}
|
||||
diffStatus={diffStatus}
|
||||
runStatus={runStatus}
|
||||
isPreviewRun={Boolean(previewExecutionStatus)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow edge component with execution status and diff visualization.
|
||||
*
|
||||
* @remarks
|
||||
* Edge coloring priority:
|
||||
* 1. Diff status (deleted/new) - for version comparison
|
||||
* 2. Execution status (success/error) - for run visualization
|
||||
* 3. Error edge default (red) - for untaken error paths
|
||||
* 4. Default edge color - normal workflow connections
|
||||
*/
|
||||
export const WorkflowEdge = memo(WorkflowEdgeComponent)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import {
|
||||
calculateContainerDimensions,
|
||||
clampPositionToContainer,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
|
||||
import type { Edge, Node } from 'reactflow'
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
||||
import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
@@ -16,12 +16,13 @@ import 'reactflow/dist/style.css'
|
||||
import { toast } from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import type { SubflowNodeData } from '@sim/workflow-renderer'
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/base-tool'
|
||||
import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state'
|
||||
import type { OAuthProvider } from '@/lib/oauth'
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
||||
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
|
||||
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
@@ -36,7 +37,6 @@ import { CanvasMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/compone
|
||||
import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors'
|
||||
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
|
||||
import { WorkflowSearchReplace } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace'
|
||||
import type { SubflowNodeData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
|
||||
import { WorkflowControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls'
|
||||
import {
|
||||
useAutoLayout,
|
||||
|
||||
+1
-1
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { type CSSProperties, memo, useMemo } from 'react'
|
||||
import { HANDLE_POSITIONS } from '@sim/workflow-renderer'
|
||||
import { Handle, type NodeProps, Position } from 'reactflow'
|
||||
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import {
|
||||
getDisplayValue,
|
||||
resolveDropdownLabel,
|
||||
|
||||
+1
-1
@@ -2,9 +2,9 @@
|
||||
|
||||
import { memo } from 'react'
|
||||
import { Badge, cn } from '@sim/emcn'
|
||||
import { HANDLE_POSITIONS } from '@sim/workflow-renderer'
|
||||
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import { Handle, type NodeProps, Position } from 'reactflow'
|
||||
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
|
||||
/** Execution status for subflows in preview mode */
|
||||
type ExecutionStatus = 'success' | 'error' | 'not-executed'
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import 'reactflow/dist/style.css'
|
||||
|
||||
import { cn } from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
|
||||
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
|
||||
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
|
||||
import { PreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { BLOCK_DIMENSIONS } from '@sim/workflow-renderer'
|
||||
import type { Node, ReactFlowInstance } from 'reactflow'
|
||||
import { BLOCK_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
|
||||
interface VisibleBounds {
|
||||
width: number
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Autolayout Constants
|
||||
*
|
||||
* Layout algorithm specific constants for spacing, padding, and overlap detection.
|
||||
* Block dimensions are in @/lib/workflows/blocks/block-dimensions
|
||||
* Block dimensions are in @sim/workflow-renderer
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
|
||||
import {
|
||||
CONTAINER_PADDING_X,
|
||||
CONTAINER_PADDING_Y,
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
import { layoutBlocksCore } from '@/lib/workflows/autolayout/core'
|
||||
import type { Edge, LayoutOptions } from '@/lib/workflows/autolayout/types'
|
||||
import { filterLayoutEligibleBlockIds, getBlocksByParent } from '@/lib/workflows/autolayout/utils'
|
||||
import { CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('AutoLayout:Containers')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@sim/workflow-renderer'
|
||||
import {
|
||||
CONTAINER_LAYOUT_OPTIONS,
|
||||
DEFAULT_LAYOUT_OPTIONS,
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
prepareBlockMetrics,
|
||||
snapNodesToGrid,
|
||||
} from '@/lib/workflows/autolayout/utils'
|
||||
import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { EDGE } from '@/executor/constants'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
|
||||
import {
|
||||
CONTAINER_PADDING,
|
||||
DEFAULT_HORIZONTAL_SPACING,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
shouldSkipAutoLayout,
|
||||
snapPositionToGrid,
|
||||
} from '@/lib/workflows/autolayout/utils'
|
||||
import { CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
type TargetedBlockInfo = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
|
||||
import {
|
||||
AUTO_LAYOUT_EXCLUDED_TYPES,
|
||||
CONTAINER_BLOCK_TYPES,
|
||||
@@ -9,7 +10,6 @@ import {
|
||||
ROOT_PADDING_Y,
|
||||
} from '@/lib/workflows/autolayout/constants'
|
||||
import type { BlockMetrics, BoundingBox, Edge, GraphNode } from '@/lib/workflows/autolayout/types'
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { calculateWorkflowBlockDimensions } from '@/lib/workflows/blocks/deterministic-dimensions'
|
||||
import { getConditionRows, getRouterRows } from '@/lib/workflows/dynamic-handle-topology'
|
||||
import {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BLOCK_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { BLOCK_DIMENSIONS } from '@sim/workflow-renderer'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
|
||||
interface WorkflowBlockDimensionsInput {
|
||||
|
||||
@@ -172,6 +172,7 @@ const nextConfig: NextConfig = {
|
||||
'@t3-oss/env-core',
|
||||
'@sim/db',
|
||||
'@sim/emcn',
|
||||
'@sim/workflow-renderer',
|
||||
],
|
||||
async headers() {
|
||||
return [
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"@sim/security": "workspace:*",
|
||||
"@sim/utils": "workspace:*",
|
||||
"@sim/workflow-persistence": "workspace:*",
|
||||
"@sim/workflow-renderer": "workspace:*",
|
||||
"@sim/workflow-types": "workspace:*",
|
||||
"@t3-oss/env-nextjs": "0.13.4",
|
||||
"@tanstack/react-query": "5.90.8",
|
||||
|
||||
@@ -7,6 +7,7 @@ export default {
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'../../packages/emcn/src/**/*.{js,ts,jsx,tsx}',
|
||||
'../../packages/workflow-renderer/src/**/*.{js,ts,jsx,tsx}',
|
||||
'!./app/node_modules/**',
|
||||
'!**/node_modules/**',
|
||||
],
|
||||
|
||||
@@ -171,6 +171,7 @@
|
||||
"@sim/security": "workspace:*",
|
||||
"@sim/utils": "workspace:*",
|
||||
"@sim/workflow-persistence": "workspace:*",
|
||||
"@sim/workflow-renderer": "workspace:*",
|
||||
"@sim/workflow-types": "workspace:*",
|
||||
"@t3-oss/env-nextjs": "0.13.4",
|
||||
"@tanstack/react-query": "5.90.8",
|
||||
@@ -527,6 +528,27 @@
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
},
|
||||
"packages/workflow-renderer": {
|
||||
"name": "@sim/workflow-renderer",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@sim/tsconfig": "workspace:*",
|
||||
"@types/react": "^19",
|
||||
"lucide-react": "^0.479.0",
|
||||
"react": "19.2.4",
|
||||
"reactflow": "^11.11.4",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"streamdown": "2.5.0",
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"lucide-react": ">=0.479.0",
|
||||
"react": "^19",
|
||||
"reactflow": "^11.11.4",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"streamdown": ">=2.5.0",
|
||||
},
|
||||
},
|
||||
"packages/workflow-types": {
|
||||
"name": "@sim/workflow-types",
|
||||
"version": "0.1.0",
|
||||
@@ -1544,6 +1566,8 @@
|
||||
|
||||
"@sim/workflow-persistence": ["@sim/workflow-persistence@workspace:packages/workflow-persistence"],
|
||||
|
||||
"@sim/workflow-renderer": ["@sim/workflow-renderer@workspace:packages/workflow-renderer"],
|
||||
|
||||
"@sim/workflow-types": ["@sim/workflow-types@workspace:packages/workflow-types"],
|
||||
|
||||
"@smithy/config-resolver": ["@smithy/config-resolver@4.6.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-NJF/Xc69G68BzZMKMEpWkCY9HjZJzTWztTW4VxBC2SodX+H60xw+NGckNhkgg4uMRHrpDkhWeBeigM3YJmv1FQ=="],
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@sim/workflow-renderer",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"sideEffects": [
|
||||
"**/*.css",
|
||||
"**/note-block-view.tsx"
|
||||
],
|
||||
"type": "module",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"bun": ">=1.2.13",
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "biome check --write --unsafe .",
|
||||
"lint:check": "biome check .",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome format ."
|
||||
},
|
||||
"peerDependencies": {
|
||||
"lucide-react": ">=0.479.0",
|
||||
"react": "^19",
|
||||
"reactflow": "^11.11.4",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"streamdown": ">=2.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sim/tsconfig": "workspace:*",
|
||||
"@types/react": "^19",
|
||||
"lucide-react": "^0.479.0",
|
||||
"react": "19.2.4",
|
||||
"reactflow": "^11.11.4",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"streamdown": "2.5.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Ambient declaration for CSS Modules. The renderer compiles `@sim/emcn` source
|
||||
* (which imports CSS modules) as part of its program, so it needs this in scope
|
||||
* for a standalone type-check. Consuming apps (Next.js) provide their own.
|
||||
*/
|
||||
declare module '*.module.css' {
|
||||
const classes: { readonly [key: string]: string }
|
||||
export default classes
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useMemo } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from 'reactflow'
|
||||
import type { EdgeDiffStatus, EdgeRunStatus } from '../types'
|
||||
|
||||
/**
|
||||
* Props for the pure workflow edge renderer.
|
||||
*
|
||||
* Geometry and `data` come straight from ReactFlow. The visual state that would
|
||||
* otherwise be read from stores — diff status, run status, and whether the run
|
||||
* status originated from a preview — is resolved by the container and passed in.
|
||||
*/
|
||||
export interface WorkflowEdgeViewProps extends EdgeProps {
|
||||
sourceHandle?: string | null
|
||||
/** Pre-resolved diff state (container reads the diff store). */
|
||||
diffStatus: EdgeDiffStatus
|
||||
/** Pre-resolved execution outcome (container reads the execution store). */
|
||||
runStatus: EdgeRunStatus
|
||||
/** Whether `runStatus` came from a preview run (drives success coloring). */
|
||||
isPreviewRun: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure workflow edge renderer with execution status and diff visualization.
|
||||
*
|
||||
* @remarks
|
||||
* Edge coloring priority:
|
||||
* 1. Diff status (deleted/new) - for version comparison
|
||||
* 2. Execution status (success/error) - for run visualization
|
||||
* 3. Error edge default (red) - for untaken error paths
|
||||
* 4. Default edge color - normal workflow connections
|
||||
*/
|
||||
export function WorkflowEdgeView({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
data,
|
||||
style,
|
||||
sourceHandle,
|
||||
diffStatus,
|
||||
runStatus,
|
||||
isPreviewRun,
|
||||
}: WorkflowEdgeViewProps) {
|
||||
const isHorizontal = sourcePosition === 'right' || sourcePosition === 'left'
|
||||
|
||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
borderRadius: 8,
|
||||
offset: isHorizontal ? 30 : 20,
|
||||
})
|
||||
|
||||
const isSelected = data?.isSelected ?? false
|
||||
|
||||
const dataSourceHandle = (data as { sourceHandle?: string } | undefined)?.sourceHandle
|
||||
const isErrorEdge = (sourceHandle ?? dataSourceHandle) === 'error'
|
||||
|
||||
const edgeStyle = useMemo(() => {
|
||||
let color = 'var(--workflow-edge)'
|
||||
let opacity = 1
|
||||
|
||||
if (diffStatus === 'deleted') {
|
||||
color = 'var(--text-error)'
|
||||
opacity = 0.7
|
||||
} else if (diffStatus === 'new') {
|
||||
color = 'var(--brand-accent)'
|
||||
} else if (runStatus === 'success') {
|
||||
// Use green for preview mode, default for canvas execution
|
||||
color = isPreviewRun ? 'var(--brand-accent)' : 'var(--border-success)'
|
||||
} else if (runStatus === 'error') {
|
||||
color = 'var(--text-error)'
|
||||
} else if (isErrorEdge) {
|
||||
// Error edges that weren't taken stay red
|
||||
color = 'var(--text-error)'
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
opacity = 0.5
|
||||
}
|
||||
|
||||
return {
|
||||
...(style ?? {}),
|
||||
strokeWidth: diffStatus
|
||||
? 3
|
||||
: runStatus === 'success' || runStatus === 'error'
|
||||
? 2.5
|
||||
: isSelected
|
||||
? 2.5
|
||||
: 2,
|
||||
stroke: color,
|
||||
strokeDasharray: diffStatus === 'deleted' ? '10,5' : undefined,
|
||||
opacity,
|
||||
}
|
||||
}, [style, diffStatus, isSelected, isErrorEdge, runStatus, isPreviewRun])
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge path={edgePath} style={edgeStyle} interactionWidth={30} />
|
||||
|
||||
{isSelected && (
|
||||
<EdgeLabelRenderer>
|
||||
<button
|
||||
type='button'
|
||||
className='nodrag nopan group flex size-[22px] cursor-pointer items-center justify-center transition-colors'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||
pointerEvents: 'all',
|
||||
zIndex: 1011,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (data?.onDelete) {
|
||||
data.onDelete(id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X className='size-4 text-[var(--text-error)] transition-colors group-hover:text-[color-mix(in_srgb,var(--text-error)_80%,transparent)]' />
|
||||
</button>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export * from './dimensions'
|
||||
export { WorkflowEdgeView, type WorkflowEdgeViewProps } from './edge/workflow-edge-view'
|
||||
export { NoteBlockView, type NoteBlockViewProps } from './note/note-block-view'
|
||||
export {
|
||||
type SubflowNodeData,
|
||||
SubflowNodeView,
|
||||
type SubflowNodeViewProps,
|
||||
} from './subflow/subflow-node-view'
|
||||
export type { BlockRunStatus, DiffStatus, EdgeDiffStatus, EdgeRunStatus } from './types'
|
||||
@@ -0,0 +1,517 @@
|
||||
import { memo, type ReactNode } from 'react'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import 'streamdown/styles.css'
|
||||
import { cn, handleKeyboardActivation } from '@sim/emcn'
|
||||
|
||||
type EmbedInfo = {
|
||||
url: string
|
||||
type: 'iframe' | 'video' | 'audio'
|
||||
aspectRatio?: string
|
||||
}
|
||||
|
||||
const EMBED_SCALE = 0.78
|
||||
const EMBED_INVERSE_SCALE = `${(1 / EMBED_SCALE) * 100}%`
|
||||
|
||||
function getTwitchParent(): string {
|
||||
return typeof window !== 'undefined' ? window.location.hostname : 'localhost'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get embed info for supported media platforms
|
||||
*/
|
||||
function getEmbedInfo(url: string): EmbedInfo | null {
|
||||
const youtubeMatch = url.match(
|
||||
/(?:youtube\.com\/watch\?(?:.*&)?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/
|
||||
)
|
||||
if (youtubeMatch) {
|
||||
return { url: `https://www.youtube.com/embed/${youtubeMatch[1]}`, type: 'iframe' }
|
||||
}
|
||||
|
||||
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/)
|
||||
if (vimeoMatch) {
|
||||
return { url: `https://player.vimeo.com/video/${vimeoMatch[1]}`, type: 'iframe' }
|
||||
}
|
||||
|
||||
const dailymotionMatch = url.match(/dailymotion\.com\/video\/([a-zA-Z0-9]+)/)
|
||||
if (dailymotionMatch) {
|
||||
return { url: `https://www.dailymotion.com/embed/video/${dailymotionMatch[1]}`, type: 'iframe' }
|
||||
}
|
||||
|
||||
const twitchVideoMatch = url.match(/twitch\.tv\/videos\/(\d+)/)
|
||||
if (twitchVideoMatch) {
|
||||
return {
|
||||
url: `https://player.twitch.tv/?video=${twitchVideoMatch[1]}&parent=${getTwitchParent()}`,
|
||||
type: 'iframe',
|
||||
}
|
||||
}
|
||||
|
||||
const twitchChannelMatch = url.match(/twitch\.tv\/([a-zA-Z0-9_]+)(?:\/|$)/)
|
||||
if (twitchChannelMatch && !url.includes('/videos/') && !url.includes('/clip/')) {
|
||||
return {
|
||||
url: `https://player.twitch.tv/?channel=${twitchChannelMatch[1]}&parent=${getTwitchParent()}`,
|
||||
type: 'iframe',
|
||||
}
|
||||
}
|
||||
|
||||
const streamableMatch = url.match(/streamable\.com\/([a-zA-Z0-9]+)/)
|
||||
if (streamableMatch) {
|
||||
return { url: `https://streamable.com/e/${streamableMatch[1]}`, type: 'iframe' }
|
||||
}
|
||||
|
||||
const wistiaMatch = url.match(/(?:wistia\.com|wistia\.net)\/(?:medias|embed)\/([a-zA-Z0-9]+)/)
|
||||
if (wistiaMatch) {
|
||||
return { url: `https://fast.wistia.net/embed/iframe/${wistiaMatch[1]}`, type: 'iframe' }
|
||||
}
|
||||
|
||||
const tiktokMatch = url.match(/tiktok\.com\/@[^/]+\/video\/(\d+)/)
|
||||
if (tiktokMatch) {
|
||||
return {
|
||||
url: `https://www.tiktok.com/embed/v2/${tiktokMatch[1]}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '9/16',
|
||||
}
|
||||
}
|
||||
|
||||
const soundcloudMatch = url.match(/soundcloud\.com\/([a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+)/)
|
||||
if (soundcloudMatch) {
|
||||
return {
|
||||
url: `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=true&show_reposts=false&show_teaser=false`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '3/2',
|
||||
}
|
||||
}
|
||||
|
||||
const spotifyTrackMatch = url.match(/open\.spotify\.com\/track\/([a-zA-Z0-9]+)/)
|
||||
if (spotifyTrackMatch) {
|
||||
return {
|
||||
url: `https://open.spotify.com/embed/track/${spotifyTrackMatch[1]}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '3.7/1',
|
||||
}
|
||||
}
|
||||
|
||||
const spotifyAlbumMatch = url.match(/open\.spotify\.com\/album\/([a-zA-Z0-9]+)/)
|
||||
if (spotifyAlbumMatch) {
|
||||
return {
|
||||
url: `https://open.spotify.com/embed/album/${spotifyAlbumMatch[1]}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '2/3',
|
||||
}
|
||||
}
|
||||
|
||||
const spotifyPlaylistMatch = url.match(/open\.spotify\.com\/playlist\/([a-zA-Z0-9]+)/)
|
||||
if (spotifyPlaylistMatch) {
|
||||
return {
|
||||
url: `https://open.spotify.com/embed/playlist/${spotifyPlaylistMatch[1]}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '2/3',
|
||||
}
|
||||
}
|
||||
|
||||
const spotifyEpisodeMatch = url.match(/open\.spotify\.com\/episode\/([a-zA-Z0-9]+)/)
|
||||
if (spotifyEpisodeMatch) {
|
||||
return {
|
||||
url: `https://open.spotify.com/embed/episode/${spotifyEpisodeMatch[1]}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '2.5/1',
|
||||
}
|
||||
}
|
||||
|
||||
const spotifyShowMatch = url.match(/open\.spotify\.com\/show\/([a-zA-Z0-9]+)/)
|
||||
if (spotifyShowMatch) {
|
||||
return {
|
||||
url: `https://open.spotify.com/embed/show/${spotifyShowMatch[1]}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '3.7/1',
|
||||
}
|
||||
}
|
||||
|
||||
const appleMusicSongMatch = url.match(/music\.apple\.com\/([a-z]{2})\/song\/[^/]+\/(\d+)/)
|
||||
if (appleMusicSongMatch) {
|
||||
const [, country, songId] = appleMusicSongMatch
|
||||
return {
|
||||
url: `https://embed.music.apple.com/${country}/song/${songId}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '3/2',
|
||||
}
|
||||
}
|
||||
|
||||
const appleMusicAlbumMatch = url.match(/music\.apple\.com\/([a-z]{2})\/album\/(?:[^/]+\/)?(\d+)/)
|
||||
if (appleMusicAlbumMatch) {
|
||||
const [, country, albumId] = appleMusicAlbumMatch
|
||||
return {
|
||||
url: `https://embed.music.apple.com/${country}/album/${albumId}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '2/3',
|
||||
}
|
||||
}
|
||||
|
||||
const appleMusicPlaylistMatch = url.match(
|
||||
/music\.apple\.com\/([a-z]{2})\/playlist\/[^/]+\/(pl\.[a-zA-Z0-9]+)/
|
||||
)
|
||||
if (appleMusicPlaylistMatch) {
|
||||
const [, country, playlistId] = appleMusicPlaylistMatch
|
||||
return {
|
||||
url: `https://embed.music.apple.com/${country}/playlist/${playlistId}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '2/3',
|
||||
}
|
||||
}
|
||||
|
||||
const loomMatch = url.match(/loom\.com\/share\/([a-zA-Z0-9]+)/)
|
||||
if (loomMatch) {
|
||||
return { url: `https://www.loom.com/embed/${loomMatch[1]}`, type: 'iframe' }
|
||||
}
|
||||
|
||||
const facebookVideoMatch =
|
||||
url.match(/facebook\.com\/.*\/videos\/(\d+)/) || url.match(/fb\.watch\/([a-zA-Z0-9_-]+)/)
|
||||
if (facebookVideoMatch) {
|
||||
return {
|
||||
url: `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(url)}&show_text=false`,
|
||||
type: 'iframe',
|
||||
}
|
||||
}
|
||||
|
||||
const instagramReelMatch = url.match(/instagram\.com\/reel\/([a-zA-Z0-9_-]+)/)
|
||||
if (instagramReelMatch) {
|
||||
return {
|
||||
url: `https://www.instagram.com/reel/${instagramReelMatch[1]}/embed`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '9/16',
|
||||
}
|
||||
}
|
||||
|
||||
const instagramPostMatch = url.match(/instagram\.com\/p\/([a-zA-Z0-9_-]+)/)
|
||||
if (instagramPostMatch) {
|
||||
return {
|
||||
url: `https://www.instagram.com/p/${instagramPostMatch[1]}/embed`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '4/5',
|
||||
}
|
||||
}
|
||||
|
||||
const twitterMatch = url.match(/(?:twitter\.com|x\.com)\/[^/]+\/status\/(\d+)/)
|
||||
if (twitterMatch) {
|
||||
return {
|
||||
url: `https://platform.twitter.com/embed/Tweet.html?id=${twitterMatch[1]}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '3/4',
|
||||
}
|
||||
}
|
||||
|
||||
const rumbleMatch =
|
||||
url.match(/rumble\.com\/embed\/([a-zA-Z0-9]+)/) || url.match(/rumble\.com\/([a-zA-Z0-9]+)-/)
|
||||
if (rumbleMatch) {
|
||||
return { url: `https://rumble.com/embed/${rumbleMatch[1]}/`, type: 'iframe' }
|
||||
}
|
||||
|
||||
const bilibiliMatch = url.match(/bilibili\.com\/video\/(BV[a-zA-Z0-9]+)/)
|
||||
if (bilibiliMatch) {
|
||||
return {
|
||||
url: `https://player.bilibili.com/player.html?bvid=${bilibiliMatch[1]}&high_quality=1`,
|
||||
type: 'iframe',
|
||||
}
|
||||
}
|
||||
|
||||
const vidyardMatch = url.match(/(?:vidyard\.com|share\.vidyard\.com)\/watch\/([a-zA-Z0-9]+)/)
|
||||
if (vidyardMatch) {
|
||||
return { url: `https://play.vidyard.com/${vidyardMatch[1]}`, type: 'iframe' }
|
||||
}
|
||||
|
||||
const cfStreamMatch =
|
||||
url.match(/cloudflarestream\.com\/([a-zA-Z0-9]+)/) ||
|
||||
url.match(/videodelivery\.net\/([a-zA-Z0-9]+)/)
|
||||
if (cfStreamMatch) {
|
||||
return { url: `https://iframe.cloudflarestream.com/${cfStreamMatch[1]}`, type: 'iframe' }
|
||||
}
|
||||
|
||||
const twitchClipMatch =
|
||||
url.match(/clips\.twitch\.tv\/([a-zA-Z0-9_-]+)/) ||
|
||||
url.match(/twitch\.tv\/[^/]+\/clip\/([a-zA-Z0-9_-]+)/)
|
||||
if (twitchClipMatch) {
|
||||
return {
|
||||
url: `https://clips.twitch.tv/embed?clip=${twitchClipMatch[1]}&parent=${getTwitchParent()}`,
|
||||
type: 'iframe',
|
||||
}
|
||||
}
|
||||
|
||||
const mixcloudMatch = url.match(/mixcloud\.com\/([^/]+\/[^/]+)/)
|
||||
if (mixcloudMatch) {
|
||||
return {
|
||||
url: `https://www.mixcloud.com/widget/iframe/?feed=%2F${encodeURIComponent(mixcloudMatch[1])}%2F&hide_cover=1`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '2/1',
|
||||
}
|
||||
}
|
||||
|
||||
const googleDriveMatch = url.match(/drive\.google\.com\/file\/d\/([a-zA-Z0-9_-]+)/)
|
||||
if (googleDriveMatch) {
|
||||
return { url: `https://drive.google.com/file/d/${googleDriveMatch[1]}/preview`, type: 'iframe' }
|
||||
}
|
||||
|
||||
if (url.includes('dropbox.com') && /\.(mp4|mov|webm)/.test(url)) {
|
||||
const directUrl = url
|
||||
.replace('www.dropbox.com', 'dl.dropboxusercontent.com')
|
||||
.replace('?dl=0', '')
|
||||
return { url: directUrl, type: 'video' }
|
||||
}
|
||||
|
||||
const tenorMatch = url.match(/tenor\.com\/view\/[^/]+-(\d+)/)
|
||||
if (tenorMatch) {
|
||||
return { url: `https://tenor.com/embed/${tenorMatch[1]}`, type: 'iframe', aspectRatio: '1/1' }
|
||||
}
|
||||
|
||||
const giphyMatch = url.match(/giphy\.com\/(?:gifs|embed)\/(?:.*-)?([a-zA-Z0-9]+)/)
|
||||
if (giphyMatch) {
|
||||
return { url: `https://giphy.com/embed/${giphyMatch[1]}`, type: 'iframe', aspectRatio: '1/1' }
|
||||
}
|
||||
|
||||
if (/\.(mp4|webm|ogg|mov)(\?|$)/i.test(url)) {
|
||||
return { url, type: 'video' }
|
||||
}
|
||||
|
||||
if (/\.(mp3|wav|m4a|aac)(\?|$)/i.test(url)) {
|
||||
return { url, type: 'audio' }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact markdown renderer for note blocks with tight spacing
|
||||
*/
|
||||
const NOTE_REMARK_PLUGINS = [remarkBreaks]
|
||||
|
||||
const NOTE_COMPONENTS = {
|
||||
p: ({ children }: { children?: ReactNode }) => (
|
||||
<p className='mb-1 break-words text-[var(--text-primary)] text-sm leading-[1.25rem] last:mb-0'>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
h1: ({ children }: { children?: ReactNode }) => (
|
||||
<h1 className='mt-3 mb-3 break-words font-semibold text-[var(--text-primary)] text-lg first:mt-0'>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }: { children?: ReactNode }) => (
|
||||
<h2 className='mt-2.5 mb-2.5 break-words font-semibold text-[var(--text-primary)] text-base first:mt-0'>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }: { children?: ReactNode }) => (
|
||||
<h3 className='mt-2 mb-2 break-words font-semibold text-[var(--text-primary)] text-sm first:mt-0'>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }: { children?: ReactNode }) => (
|
||||
<h4 className='mt-2 mb-2 break-words font-semibold text-[var(--text-primary)] text-xs first:mt-0'>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
ul: ({ children }: { children?: ReactNode }) => (
|
||||
<ul className='mt-1 mb-1 list-disc space-y-1 break-words pl-6 text-[var(--text-primary)] text-sm'>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }: { children?: ReactNode }) => (
|
||||
<ol className='mt-1 mb-1 list-decimal space-y-1 break-words pl-6 text-[var(--text-primary)] text-sm'>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }: { children?: ReactNode }) => <li className='break-words'>{children}</li>,
|
||||
inlineCode: ({ children }: { children?: ReactNode }) => (
|
||||
<code className='whitespace-normal rounded bg-[var(--surface-5)] px-1 py-0.5 font-mono text-[var(--caution)] text-xs'>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
code: ({ children, className, ...props }: { children?: ReactNode; className?: string }) => (
|
||||
<code
|
||||
{...props}
|
||||
className='block whitespace-pre-wrap break-words rounded bg-[var(--surface-5)] p-2 text-[var(--text-primary)] text-xs'
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
a: ({ href, children }: { href?: string; children?: ReactNode }) => {
|
||||
const embedInfo = href ? getEmbedInfo(href) : null
|
||||
if (embedInfo) {
|
||||
return (
|
||||
<span className='my-2 block w-full'>
|
||||
<a
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='mb-1 block break-all text-[var(--brand-secondary)] underline-offset-2 hover-hover:underline'
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
<span className='block w-full overflow-hidden rounded-md'>
|
||||
{embedInfo.type === 'iframe' && (
|
||||
<span
|
||||
className='block overflow-hidden'
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: embedInfo.aspectRatio || '16/9',
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src={embedInfo.url}
|
||||
title='Media'
|
||||
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
|
||||
allowFullScreen
|
||||
loading='lazy'
|
||||
className='origin-top-left'
|
||||
style={{
|
||||
width: EMBED_INVERSE_SCALE,
|
||||
height: EMBED_INVERSE_SCALE,
|
||||
transform: `scale(${EMBED_SCALE})`,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{embedInfo.type === 'video' && (
|
||||
<video
|
||||
src={embedInfo.url}
|
||||
controls
|
||||
preload='metadata'
|
||||
className='aspect-video w-full'
|
||||
>
|
||||
<track kind='captions' src='' default />
|
||||
</video>
|
||||
)}
|
||||
{embedInfo.type === 'audio' && (
|
||||
<audio src={embedInfo.url} controls preload='metadata' className='w-full'>
|
||||
<track kind='captions' src='' default />
|
||||
</audio>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='break-all text-[var(--brand-secondary)] underline-offset-2 hover-hover:underline'
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
strong: ({ children }: { children?: ReactNode }) => (
|
||||
<strong className='break-words font-semibold text-[var(--text-primary)]'>{children}</strong>
|
||||
),
|
||||
em: ({ children }: { children?: ReactNode }) => (
|
||||
<em className='break-words text-[var(--text-tertiary)]'>{children}</em>
|
||||
),
|
||||
blockquote: ({ children }: { children?: ReactNode }) => (
|
||||
<blockquote className='my-4 break-words border-[var(--divider)] border-l-2 pl-4 text-[var(--text-primary)] italic [&>p:first-child]:mt-0 [&>p:last-child]:mb-0 [&>p]:my-2'>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
table: ({ children }: { children?: ReactNode }) => (
|
||||
<div className='my-2 max-w-full overflow-x-auto'>
|
||||
<table className='w-full border-collapse text-xs'>{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }: { children?: ReactNode }) => (
|
||||
<thead className='border-[var(--border)] border-b'>{children}</thead>
|
||||
),
|
||||
tbody: ({ children }: { children?: ReactNode }) => <tbody>{children}</tbody>,
|
||||
tr: ({ children }: { children?: ReactNode }) => (
|
||||
<tr className='border-[var(--border)] border-b last:border-b-0'>{children}</tr>
|
||||
),
|
||||
th: ({ children }: { children?: ReactNode }) => (
|
||||
<th className='px-2 py-1 text-left font-semibold text-[var(--text-primary)]'>{children}</th>
|
||||
),
|
||||
td: ({ children }: { children?: ReactNode }) => (
|
||||
<td className='px-2 py-1 text-[var(--text-secondary)]'>{children}</td>
|
||||
),
|
||||
}
|
||||
|
||||
const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }) {
|
||||
return (
|
||||
<Streamdown mode='static' remarkPlugins={NOTE_REMARK_PLUGINS} components={NOTE_COMPONENTS}>
|
||||
{content}
|
||||
</Streamdown>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Props for the pure note renderer. The container resolves the markdown content
|
||||
* (from the block's subblock value), enabled/ring visual state, and the select
|
||||
* handler; the editor-only action bar is injected via the `actionBar` slot.
|
||||
*/
|
||||
export interface NoteBlockViewProps {
|
||||
name?: string
|
||||
/** Markdown content; an empty string renders the placeholder. */
|
||||
content: string
|
||||
isEnabled: boolean
|
||||
hasRing: boolean
|
||||
ringStyles: string
|
||||
/** Selects this note in the editor panel. */
|
||||
onSelect: () => void
|
||||
/** Editor-only action bar; omit in read-only / preview contexts. */
|
||||
actionBar?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure renderer for a note block: a draggable card with a title and a markdown
|
||||
* body (rich text + embeds). Carries no store, socket, or permission coupling.
|
||||
*/
|
||||
export function NoteBlockView({
|
||||
name,
|
||||
content,
|
||||
isEnabled,
|
||||
hasRing,
|
||||
ringStyles,
|
||||
onSelect,
|
||||
actionBar,
|
||||
}: NoteBlockViewProps) {
|
||||
const isEmpty = content.trim().length === 0
|
||||
|
||||
return (
|
||||
<div className='group relative'>
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'note-drag-handle relative z-[20] w-[250px] cursor-grab select-none rounded-lg border border-[var(--border)] bg-[var(--surface-2)] [&:active]:cursor-grabbing'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
onKeyDown={(event) => handleKeyboardActivation(event, onSelect)}
|
||||
>
|
||||
{actionBar}
|
||||
|
||||
<div className='flex items-center justify-between border-[var(--divider)] border-b p-2'>
|
||||
<div className='flex min-w-0 flex-1 items-center'>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate font-medium text-md',
|
||||
!isEnabled && 'text-[var(--text-muted)]'
|
||||
)}
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='relative overflow-hidden p-2'>
|
||||
<div className='relative max-w-full break-all'>
|
||||
{isEmpty ? (
|
||||
<p className='text-[var(--text-placeholder)] text-sm'>Add note…</p>
|
||||
) : (
|
||||
<NoteMarkdown content={content} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasRing && (
|
||||
<div className={cn('pointer-events-none absolute inset-0 z-40 rounded-lg', ringStyles)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Badge, cn, handleKeyboardActivation } from '@sim/emcn'
|
||||
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import { Handle, Position } from 'reactflow'
|
||||
import { HANDLE_POSITIONS } from '../dimensions'
|
||||
import type { BlockRunStatus, DiffStatus } from '../types'
|
||||
|
||||
/** Data attached to loop/parallel container nodes. */
|
||||
export interface SubflowNodeData {
|
||||
width?: number
|
||||
height?: number
|
||||
parentId?: string
|
||||
extent?: 'parent'
|
||||
isPreview?: boolean
|
||||
/** Whether this subflow is selected in preview mode. */
|
||||
isPreviewSelected?: boolean
|
||||
kind: 'loop' | 'parallel'
|
||||
name?: string
|
||||
/** Execution status passed by preview/snapshot views. */
|
||||
executionStatus?: 'success' | 'error' | 'not-executed'
|
||||
/** Whether the parent workflow is locked and should render read-only. */
|
||||
isWorkflowLocked?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the pure subflow (loop/parallel container) renderer.
|
||||
*
|
||||
* Geometry and presentation come from `data`; the state that the editor would
|
||||
* read from stores — enabled/locked flags, focus, execution and diff status,
|
||||
* nesting depth, and edit permission — is resolved by the container and passed
|
||||
* in. The editor-only action bar is injected via the `actionBar` slot so the
|
||||
* pure renderer carries no store, socket, or permission coupling.
|
||||
*/
|
||||
export interface SubflowNodeViewProps {
|
||||
id: string
|
||||
data: SubflowNodeData
|
||||
/** ReactFlow selection flag. */
|
||||
selected?: boolean
|
||||
isEnabled: boolean
|
||||
isLocked: boolean
|
||||
/** Whether this subflow is the focused block in the editor panel. */
|
||||
isFocused: boolean
|
||||
/** Resolved run-path outcome for the execution ring. */
|
||||
runPathStatus?: BlockRunStatus
|
||||
/** Diff state when comparing workflow versions. */
|
||||
diffStatus?: DiffStatus
|
||||
/** Depth in the parent container hierarchy (drives nesting styling). */
|
||||
nestingLevel: number
|
||||
canEditWorkflow: boolean
|
||||
/** Selects this subflow in the editor panel. */
|
||||
onSelect: () => void
|
||||
/** Editor-only action bar; omit in read-only / preview contexts. */
|
||||
actionBar?: ReactNode
|
||||
}
|
||||
|
||||
const HANDLE_STYLE = {
|
||||
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
|
||||
transform: 'translateY(-50%)',
|
||||
} as const
|
||||
|
||||
/** Reusable handle classes, matching the workflow-block handle styling. */
|
||||
const getHandleClasses = (position: 'left' | 'right') => {
|
||||
const baseClasses = '!z-[10] !cursor-crosshair !border-none !transition-[colors] !duration-150'
|
||||
const colorClasses = '!bg-[var(--workflow-edge)]'
|
||||
|
||||
const positionClasses = {
|
||||
left: '!left-[-8px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover-hover:!left-[-11px] hover-hover:!w-[10px] hover-hover:!rounded-l-full',
|
||||
right:
|
||||
'!right-[-8px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover-hover:!right-[-11px] hover-hover:!w-[10px] hover-hover:!rounded-r-full',
|
||||
}
|
||||
|
||||
return cn(baseClasses, colorClasses, positionClasses[position])
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure renderer for loop/parallel execution containers: a resizable container
|
||||
* with a header (icon, name, disabled/locked badges), a start pill with its
|
||||
* source handle, and the left/right connection handles.
|
||||
*/
|
||||
export function SubflowNodeView({
|
||||
id,
|
||||
data,
|
||||
selected,
|
||||
isEnabled,
|
||||
isLocked,
|
||||
isFocused,
|
||||
runPathStatus,
|
||||
diffStatus,
|
||||
nestingLevel,
|
||||
canEditWorkflow,
|
||||
onSelect,
|
||||
actionBar,
|
||||
}: SubflowNodeViewProps) {
|
||||
const isPreview = data?.isPreview || false
|
||||
const isPreviewSelected = data?.isPreviewSelected || false
|
||||
const executionStatus = data.executionStatus
|
||||
|
||||
const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
|
||||
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
|
||||
const BlockIcon = data.kind === 'loop' ? RepeatIcon : SplitIcon
|
||||
const blockIconBg = data.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
|
||||
const blockName = data.name || (data.kind === 'loop' ? 'Loop' : 'Parallel')
|
||||
|
||||
const isSelected = !isPreview && selected
|
||||
const hasRing =
|
||||
isFocused ||
|
||||
isSelected ||
|
||||
isPreviewSelected ||
|
||||
diffStatus === 'new' ||
|
||||
diffStatus === 'edited' ||
|
||||
!!runPathStatus
|
||||
|
||||
/**
|
||||
* Ring color priority: selection (blue) → diff (green/orange) → run-path
|
||||
* (green/red). Uses boxShadow (not outline) so child nodes rendered as
|
||||
* viewport-level siblings by ReactFlow don't clip the parent's ring.
|
||||
*/
|
||||
const getRingColor = (): string | undefined => {
|
||||
if (!hasRing) return undefined
|
||||
if (isFocused || isSelected || isPreviewSelected) return 'var(--brand-secondary)'
|
||||
if (diffStatus === 'new') return 'var(--brand-accent)'
|
||||
if (diffStatus === 'edited') return 'var(--warning)'
|
||||
if (runPathStatus === 'success') {
|
||||
return executionStatus ? 'var(--brand-accent)' : 'var(--border-success)'
|
||||
}
|
||||
if (runPathStatus === 'error') return 'var(--text-error)'
|
||||
return undefined
|
||||
}
|
||||
const ringColor = getRingColor()
|
||||
|
||||
return (
|
||||
<div className='group pointer-events-none relative'>
|
||||
<div
|
||||
className='relative select-none rounded-lg border border-[var(--border-1)] transition-block-bg'
|
||||
style={{
|
||||
width: data.width || 500,
|
||||
height: data.height || 300,
|
||||
overflow: 'visible',
|
||||
pointerEvents: 'none',
|
||||
...(ringColor && {
|
||||
boxShadow: `0 0 0 1.75px ${ringColor}`,
|
||||
}),
|
||||
}}
|
||||
data-node-id={id}
|
||||
data-type='subflowNode'
|
||||
data-nesting-level={nestingLevel}
|
||||
data-subflow-selected={isFocused || isSelected || isPreviewSelected}
|
||||
>
|
||||
{!isPreview && actionBar}
|
||||
|
||||
{/* Header Section */}
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
aria-label={`Select ${blockName}`}
|
||||
onClick={onSelect}
|
||||
onKeyDown={(event) => handleKeyboardActivation(event, onSelect)}
|
||||
className='workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-2 pr-3 pl-2 [&:active]:cursor-grabbing'
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2.5'>
|
||||
<div
|
||||
className='flex size-[24px] flex-shrink-0 items-center justify-center rounded-md'
|
||||
style={{ backgroundColor: isEnabled ? blockIconBg : 'gray' }}
|
||||
>
|
||||
<BlockIcon className='size-[16px] text-white' />
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate font-medium text-md',
|
||||
!isEnabled && 'text-[var(--text-muted)]'
|
||||
)}
|
||||
title={blockName}
|
||||
>
|
||||
{blockName}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
|
||||
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* Subflow body background. Captures clicks to select the subflow in the
|
||||
* panel editor, matching the header click behavior. Child nodes and edges
|
||||
* are rendered as sibling divs at the viewport level by ReactFlow (not as
|
||||
* DOM children), so enabling pointer events here doesn't block them.
|
||||
*/}
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={isPreview ? -1 : 0}
|
||||
aria-label={`Select ${blockName}`}
|
||||
className='workflow-drag-handle absolute inset-0 top-[44px] cursor-grab rounded-b-[8px] [&:active]:cursor-grabbing'
|
||||
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
|
||||
onClick={onSelect}
|
||||
onKeyDown={(event) => handleKeyboardActivation(event, onSelect)}
|
||||
/>
|
||||
|
||||
{!isPreview && canEditWorkflow && (
|
||||
<div
|
||||
role='separator'
|
||||
aria-orientation='horizontal'
|
||||
className='absolute right-[8px] bottom-2 z-20 flex size-[32px] cursor-se-resize items-center justify-center text-muted-foreground'
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className='relative h-[calc(100%-50px)] pt-4 pr-[80px] pb-4 pl-4'
|
||||
data-dragarea='true'
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{/* Subflow Start */}
|
||||
<div
|
||||
className='absolute top-4 left-[16px] flex items-center justify-center rounded-lg border border-[var(--border-1)] bg-[var(--surface-2)] px-3 py-1.5'
|
||||
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
|
||||
data-parent-id={id}
|
||||
data-node-role={`${data.kind}-start`}
|
||||
data-extent='parent'
|
||||
>
|
||||
<span className='font-medium text-[var(--text-primary)] text-sm'>Start</span>
|
||||
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id={startHandleId}
|
||||
className={getHandleClasses('right')}
|
||||
style={{
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
data-parent-id={id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input handle on left middle */}
|
||||
<Handle
|
||||
type='target'
|
||||
position={Position.Left}
|
||||
className={getHandleClasses('left')}
|
||||
style={{
|
||||
...HANDLE_STYLE,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Output handle on right middle */}
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
className={getHandleClasses('right')}
|
||||
style={{
|
||||
...HANDLE_STYLE,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
id={endHandleId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Shared rendering types for the pure workflow renderer.
|
||||
*
|
||||
* These describe the visual state a View component needs, resolved by the
|
||||
* editor (or docs) container and passed in as props. They deliberately carry no
|
||||
* store, socket, or query coupling.
|
||||
*/
|
||||
|
||||
/** Diff state of an edge when comparing two workflow versions. */
|
||||
export type EdgeDiffStatus = 'new' | 'deleted' | 'unchanged' | null
|
||||
|
||||
/** Execution outcome of an edge for run-path visualization. */
|
||||
export type EdgeRunStatus = 'success' | 'error' | 'not-executed' | undefined
|
||||
|
||||
/** Diff state of a block when comparing two workflow versions. */
|
||||
export type DiffStatus = 'new' | 'edited' | undefined
|
||||
|
||||
/** Execution outcome of a block on its run path. */
|
||||
export type BlockRunStatus = 'success' | 'error' | undefined
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@sim/tsconfig/library.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user