From 777ee741d0cfe0caf7855125b8c780a5d7a5a600 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 00:39:19 -0700 Subject: [PATCH] feat(workflow-renderer): extract edge, subflow, and note Views into @sim/workflow-renderer 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. --- .../components/note-block/note-block.tsx | 502 +---------------- .../[workflowId]/components/subflows/index.ts | 5 +- .../components/subflows/subflow-node.tsx | 254 +-------- .../workflow-block/workflow-block.tsx | 2 +- .../workflow-edge/workflow-edge.tsx | 130 +---- .../[workflowId]/hooks/use-node-utilities.ts | 2 +- .../[workflowId]/utils/node-position-utils.ts | 2 +- .../utils/workflow-canvas-helpers.ts | 2 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 4 +- .../components/block/block.tsx | 2 +- .../components/subflow/subflow.tsx | 2 +- .../preview-workflow/preview-workflow.tsx | 2 +- apps/sim/hooks/use-canvas-viewport.ts | 2 +- .../sim/lib/workflows/autolayout/constants.ts | 2 +- .../lib/workflows/autolayout/containers.ts | 2 +- apps/sim/lib/workflows/autolayout/core.ts | 2 +- apps/sim/lib/workflows/autolayout/targeted.ts | 2 +- apps/sim/lib/workflows/autolayout/utils.ts | 2 +- .../blocks/deterministic-dimensions.ts | 2 +- apps/sim/next.config.ts | 1 + apps/sim/package.json | 1 + apps/sim/tailwind.config.ts | 1 + bun.lock | 24 + packages/workflow-renderer/package.json | 45 ++ .../workflow-renderer/src/css-modules.d.ts | 9 + .../workflow-renderer/src/dimensions.ts | 0 .../src/edge/workflow-edge-view.tsx | 134 +++++ packages/workflow-renderer/src/index.ts | 9 + .../src/note/note-block-view.tsx | 517 ++++++++++++++++++ .../src/subflow/subflow-node-view.tsx | 264 +++++++++ packages/workflow-renderer/src/types.ts | 19 + packages/workflow-renderer/tsconfig.json | 9 + 32 files changed, 1113 insertions(+), 843 deletions(-) create mode 100644 packages/workflow-renderer/package.json create mode 100644 packages/workflow-renderer/src/css-modules.d.ts rename apps/sim/lib/workflows/blocks/block-dimensions.ts => packages/workflow-renderer/src/dimensions.ts (100%) create mode 100644 packages/workflow-renderer/src/edge/workflow-edge-view.tsx create mode 100644 packages/workflow-renderer/src/index.ts create mode 100644 packages/workflow-renderer/src/note/note-block-view.tsx create mode 100644 packages/workflow-renderer/src/subflow/subflow-node-view.tsx create mode 100644 packages/workflow-renderer/src/types.ts create mode 100644 packages/workflow-renderer/tsconfig.json diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx index b8550484950..38130ca0903 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx @@ -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 }) => ( -

- {children} -

- ), - h1: ({ children }: { children?: React.ReactNode }) => ( -

- {children} -

- ), - h2: ({ children }: { children?: React.ReactNode }) => ( -

- {children} -

- ), - h3: ({ children }: { children?: React.ReactNode }) => ( -

- {children} -

- ), - h4: ({ children }: { children?: React.ReactNode }) => ( -

- {children} -

- ), - ul: ({ children }: { children?: React.ReactNode }) => ( - - ), - ol: ({ children }: { children?: React.ReactNode }) => ( -
    - {children} -
- ), - li: ({ children }: { children?: React.ReactNode }) =>
  • {children}
  • , - inlineCode: ({ children }: { children?: React.ReactNode }) => ( - - {children} - - ), - code: ({ children, className, ...props }: { children?: React.ReactNode; className?: string }) => ( - - {children} - - ), - a: ({ href, children }: { href?: string; children?: React.ReactNode }) => { - const embedInfo = href ? getEmbedInfo(href) : null - if (embedInfo) { - return ( - - - {children} - - - {embedInfo.type === 'iframe' && ( - -