Skip to content

Commit 1cce486

Browse files
authored
feat(notes): add notes (#1898)
* Notes v1 * v2 * Lint * Consolidate into hook * Simplify workflow code * Fix hitl casing * Don't allow edges in note block and explicitly exclude from executor * Add hooks * Consolidate hook * Consolidate utils checks * Consolidate dimensions
1 parent 7c398e6 commit 1cce486

File tree

18 files changed

+623
-150
lines changed

18 files changed

+623
-150
lines changed
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import { memo, useCallback, useMemo } from 'react'
2+
import ReactMarkdown from 'react-markdown'
3+
import type { NodeProps } from 'reactflow'
4+
import remarkGfm from 'remark-gfm'
5+
import { cn } from '@/lib/utils'
6+
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
7+
import { useBlockCore } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
8+
import {
9+
BLOCK_DIMENSIONS,
10+
useBlockDimensions,
11+
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
12+
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
13+
import { ActionBar } from '../workflow-block/components'
14+
import type { WorkflowBlockProps } from '../workflow-block/types'
15+
16+
interface NoteBlockNodeData extends WorkflowBlockProps {}
17+
18+
/**
19+
* Extract string value from subblock value object or primitive
20+
*/
21+
function extractFieldValue(rawValue: unknown): string | undefined {
22+
if (typeof rawValue === 'string') return rawValue
23+
if (rawValue && typeof rawValue === 'object' && 'value' in rawValue) {
24+
const candidate = (rawValue as { value?: unknown }).value
25+
return typeof candidate === 'string' ? candidate : undefined
26+
}
27+
return undefined
28+
}
29+
30+
/**
31+
* Compact markdown renderer for note blocks with tight spacing
32+
*/
33+
const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }) {
34+
return (
35+
<ReactMarkdown
36+
remarkPlugins={[remarkGfm]}
37+
components={{
38+
p: ({ children }) => <p className='mb-0 text-[#E5E5E5] text-sm'>{children}</p>,
39+
h1: ({ children }) => (
40+
<h1 className='mt-0 mb-[-2px] font-semibold text-[#E5E5E5] text-lg'>{children}</h1>
41+
),
42+
h2: ({ children }) => (
43+
<h2 className='mt-0 mb-[-2px] font-semibold text-[#E5E5E5] text-base'>{children}</h2>
44+
),
45+
h3: ({ children }) => (
46+
<h3 className='mt-0 mb-[-2px] font-semibold text-[#E5E5E5] text-sm'>{children}</h3>
47+
),
48+
h4: ({ children }) => (
49+
<h4 className='mt-0 mb-[-2px] font-semibold text-[#E5E5E5] text-xs'>{children}</h4>
50+
),
51+
ul: ({ children }) => (
52+
<ul className='-mt-[2px] mb-0 list-disc pl-4 text-[#E5E5E5] text-sm'>{children}</ul>
53+
),
54+
ol: ({ children }) => (
55+
<ol className='-mt-[2px] mb-0 list-decimal pl-4 text-[#E5E5E5] text-sm'>{children}</ol>
56+
),
57+
li: ({ children }) => <li className='mb-0'>{children}</li>,
58+
code: ({ inline, children }: any) =>
59+
inline ? (
60+
<code className='rounded bg-[#393939] px-1 py-0.5 text-[#F59E0B] text-xs'>
61+
{children}
62+
</code>
63+
) : (
64+
<code className='block rounded bg-[#1A1A1A] p-2 text-[#E5E5E5] text-xs'>
65+
{children}
66+
</code>
67+
),
68+
a: ({ href, children }) => (
69+
<a
70+
href={href}
71+
target='_blank'
72+
rel='noopener noreferrer'
73+
className='text-[#33B4FF] underline-offset-2 hover:underline'
74+
>
75+
{children}
76+
</a>
77+
),
78+
strong: ({ children }) => <strong className='font-semibold text-white'>{children}</strong>,
79+
em: ({ children }) => <em className='text-[#B8B8B8]'>{children}</em>,
80+
blockquote: ({ children }) => (
81+
<blockquote className='m-0 border-[#F59E0B] border-l-2 pl-3 text-[#B8B8B8] italic'>
82+
{children}
83+
</blockquote>
84+
),
85+
}}
86+
>
87+
{content}
88+
</ReactMarkdown>
89+
)
90+
})
91+
92+
export const NoteBlock = memo(function NoteBlock({ id, data }: NodeProps<NoteBlockNodeData>) {
93+
const { type, config, name } = data
94+
95+
const { activeWorkflowId, isEnabled, isFocused, handleClick, hasRing, ringStyles } = useBlockCore(
96+
{ blockId: id, data }
97+
)
98+
const storedValues = useSubBlockStore(
99+
useCallback(
100+
(state) => {
101+
if (!activeWorkflowId) return undefined
102+
return state.workflowValues[activeWorkflowId]?.[id]
103+
},
104+
[activeWorkflowId, id]
105+
)
106+
)
107+
108+
const noteValues = useMemo(() => {
109+
if (data.isPreview && data.subBlockValues) {
110+
const extractedPreviewFormat = extractFieldValue(data.subBlockValues.format)
111+
const extractedPreviewContent = extractFieldValue(data.subBlockValues.content)
112+
return {
113+
format: typeof extractedPreviewFormat === 'string' ? extractedPreviewFormat : 'plain',
114+
content: typeof extractedPreviewContent === 'string' ? extractedPreviewContent : '',
115+
}
116+
}
117+
118+
const format = extractFieldValue(storedValues?.format)
119+
const content = extractFieldValue(storedValues?.content)
120+
121+
return {
122+
format: typeof format === 'string' ? format : 'plain',
123+
content: typeof content === 'string' ? content : '',
124+
}
125+
}, [data.isPreview, data.subBlockValues, storedValues])
126+
127+
const content = noteValues.content ?? ''
128+
const isEmpty = content.trim().length === 0
129+
const showMarkdown = noteValues.format === 'markdown' && !isEmpty
130+
131+
const userPermissions = useUserPermissionsContext()
132+
133+
/**
134+
* Calculate deterministic dimensions based on content structure.
135+
* Uses fixed width and computed height to avoid ResizeObserver jitter.
136+
*/
137+
useBlockDimensions({
138+
blockId: id,
139+
calculateDimensions: () => {
140+
const contentHeight = isEmpty
141+
? BLOCK_DIMENSIONS.NOTE_MIN_CONTENT_HEIGHT
142+
: BLOCK_DIMENSIONS.NOTE_BASE_CONTENT_HEIGHT
143+
const calculatedHeight =
144+
BLOCK_DIMENSIONS.HEADER_HEIGHT + BLOCK_DIMENSIONS.NOTE_CONTENT_PADDING + contentHeight
145+
146+
return { width: BLOCK_DIMENSIONS.FIXED_WIDTH, height: calculatedHeight }
147+
},
148+
dependencies: [isEmpty],
149+
})
150+
151+
return (
152+
<div className='group relative'>
153+
<div
154+
className={cn(
155+
'relative z-[20] w-[250px] cursor-default select-none rounded-[8px] bg-[#232323]'
156+
)}
157+
onClick={handleClick}
158+
>
159+
<ActionBar blockId={id} blockType={type} disabled={!userPermissions.canEdit} />
160+
161+
<div
162+
className='note-drag-handle flex cursor-grab items-center justify-between border-[#393939] border-b p-[8px] [&:active]:cursor-grabbing'
163+
onMouseDown={(event) => {
164+
event.stopPropagation()
165+
}}
166+
>
167+
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
168+
<div
169+
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
170+
style={{ backgroundColor: isEnabled ? config.bgColor : 'gray' }}
171+
>
172+
<config.icon className='h-[16px] w-[16px] text-white' />
173+
</div>
174+
<span
175+
className={cn('font-medium text-[16px]', !isEnabled && 'truncate text-[#808080]')}
176+
title={name}
177+
>
178+
{name}
179+
</span>
180+
</div>
181+
</div>
182+
183+
<div className='relative px-[12px] pt-[6px] pb-[8px]'>
184+
<div className='relative whitespace-pre-wrap break-words'>
185+
{isEmpty ? (
186+
<p className='text-[#868686] text-sm italic'>Add a note...</p>
187+
) : showMarkdown ? (
188+
<NoteMarkdown content={content} />
189+
) : (
190+
<p className='whitespace-pre-wrap text-[#E5E5E5] text-sm leading-relaxed'>
191+
{content}
192+
</p>
193+
)}
194+
</div>
195+
</div>
196+
{hasRing && (
197+
<div
198+
className={cn('pointer-events-none absolute inset-0 z-40 rounded-[8px]', ringStyles)}
199+
/>
200+
)}
201+
</div>
202+
</div>
203+
)
204+
})

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-r
33
import { Button, Duplicate, Tooltip, Trash2 } from '@/components/emcn'
44
import { cn } from '@/lib/utils'
55
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
6+
import { supportsHandles } from '@/executor/consts'
67
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
78
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
89

@@ -146,29 +147,31 @@ export const ActionBar = memo(
146147
</Tooltip.Root>
147148
)}
148149

149-
<Tooltip.Root>
150-
<Tooltip.Trigger asChild>
151-
<Button
152-
variant='ghost'
153-
onClick={() => {
154-
if (!disabled) {
155-
collaborativeToggleBlockHandles(blockId)
156-
}
157-
}}
158-
className='h-[30px] w-[30px] rounded-[8px] bg-[#363636] p-0 text-[#868686] hover:bg-[#33B4FF] hover:text-[#1B1B1B] dark:text-[#868686] dark:hover:bg-[#33B4FF] dark:hover:text-[#1B1B1B]'
159-
disabled={disabled}
160-
>
161-
{horizontalHandles ? (
162-
<ArrowLeftRight className='h-[14px] w-[14px]' />
163-
) : (
164-
<ArrowUpDown className='h-[14px] w-[14px]' />
165-
)}
166-
</Button>
167-
</Tooltip.Trigger>
168-
<Tooltip.Content side='right'>
169-
{getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
170-
</Tooltip.Content>
171-
</Tooltip.Root>
150+
{supportsHandles(blockType) && (
151+
<Tooltip.Root>
152+
<Tooltip.Trigger asChild>
153+
<Button
154+
variant='ghost'
155+
onClick={() => {
156+
if (!disabled) {
157+
collaborativeToggleBlockHandles(blockId)
158+
}
159+
}}
160+
className='h-[30px] w-[30px] rounded-[8px] bg-[#363636] p-0 text-[#868686] hover:bg-[#33B4FF] hover:text-[#1B1B1B] dark:text-[#868686] dark:hover:bg-[#33B4FF] dark:hover:text-[#1B1B1B]'
161+
disabled={disabled}
162+
>
163+
{horizontalHandles ? (
164+
<ArrowLeftRight className='h-[14px] w-[14px]' />
165+
) : (
166+
<ArrowUpDown className='h-[14px] w-[14px]' />
167+
)}
168+
</Button>
169+
</Tooltip.Trigger>
170+
<Tooltip.Content side='right'>
171+
{getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
172+
</Tooltip.Content>
173+
</Tooltip.Root>
174+
)}
172175

173176
{!isStarterBlock && (
174177
<Tooltip.Root>

0 commit comments

Comments
 (0)