Skip to content

Commit ec833c1

Browse files
committed
improvement(notes): add focused canvas editing
1 parent 6bbb2eb commit ec833c1

16 files changed

Lines changed: 1517 additions & 190 deletions

File tree

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

Lines changed: 96 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
11
import { memo, useCallback, useEffect, useState } from 'react'
2-
import { Button, cn, Duplicate, PlayOutline, Tooltip, Trash2, toast } from '@sim/emcn'
3-
import { Circle, CircleOff, Lock, LogOut, Unlock } from 'lucide-react'
2+
import {
3+
Button,
4+
cn,
5+
DropdownMenu,
6+
DropdownMenuContent,
7+
DropdownMenuRadioGroup,
8+
DropdownMenuRadioItem,
9+
DropdownMenuTrigger,
10+
Duplicate,
11+
PlayOutline,
12+
Tooltip,
13+
Trash2,
14+
toast,
15+
} from '@sim/emcn'
16+
import {
17+
DEFAULT_NOTE_COLOR,
18+
isNoteColor,
19+
NOTE_COLOR_OPTIONS,
20+
type NoteColor,
21+
} from '@sim/workflow-renderer'
22+
import { Circle, CircleOff, Lock, LogOut, Palette, Unlock } from 'lucide-react'
423
import { useShallow } from 'zustand/react/shallow'
524
import { ThinkingLoader } from '@/components/ui'
625
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
@@ -29,7 +48,7 @@ const PROGRESS_LEFT_CAP_PATH =
2948
const PROGRESS_RIGHT_CAP_PATH =
3049
'M16.25 0A8 8 0 0 1 22.4 2.88L36.59 19.9A2.5 2.5 0 0 1 34.66 24L0 24L0 0Z'
3150

32-
type ActionId = 'run' | 'enabled' | 'lock' | 'duplicate' | 'remove' | 'delete'
51+
type ActionId = 'run' | 'enabled' | 'lock' | 'duplicate' | 'remove' | 'delete' | 'color'
3352

3453
function IndeterminateBlockProgress() {
3554
return (
@@ -89,6 +108,10 @@ interface ActionBarProps {
89108
variant?: 'floating' | 'swell'
90109
/** Whether the current workflow is executing. */
91110
isRunning?: boolean
111+
noteColor?: NoteColor
112+
onNoteColorChange?: (color: NoteColor) => void
113+
/** Keeps the note and its swell selected while the portalled color menu is open. */
114+
onNoteColorMenuOpen?: () => void
92115
}
93116

94117
/**
@@ -104,6 +127,9 @@ export const ActionBar = memo(
104127
disabled = false,
105128
variant = 'floating',
106129
isRunning = false,
130+
noteColor = DEFAULT_NOTE_COLOR,
131+
onNoteColorChange,
132+
onNoteColorMenuOpen,
107133
}: ActionBarProps) {
108134
const {
109135
collaborativeBatchAddBlocks,
@@ -200,16 +226,7 @@ export const ActionBar = memo(
200226
const canRunFromBlock =
201227
dependenciesSatisfied && !isNoteBlock && !isInsideSubflow && !isExecuting
202228
const isSwell = variant === 'swell'
203-
const firstActionId: ActionId =
204-
!isNoteBlock && !isInsideSubflow
205-
? 'run'
206-
: !isNoteBlock
207-
? 'enabled'
208-
: userPermissions.canAdmin
209-
? 'lock'
210-
: !isStartBlock && !isResponseBlock
211-
? 'duplicate'
212-
: 'delete'
229+
const firstActionId: ActionId = isNoteBlock ? 'color' : !isInsideSubflow ? 'run' : 'enabled'
213230
/*
214231
* Icon treatment follows the swell's own fill, published by the card view
215232
* as `data-node-selected`. Keying off React Flow's raw `selected` would
@@ -235,7 +252,8 @@ export const ActionBar = memo(
235252
* stays tight against neighboring actions. The row is right-[24px] to
236253
* match the swell anchor inset (right-aligned on the card). Glyphs shift
237254
* away from the outer cut (+6 / -6). Play gets an extra +2px because the
238-
* triangle’s optical center sits left of its viewBox center.
255+
* triangle’s optical center sits left of its viewBox center; the Note
256+
* palette uses the same inset so its first-action padding matches.
239257
*/
240258
const getActionButtonStyles = (actionId: ActionId) =>
241259
cn(
@@ -248,7 +266,9 @@ export const ActionBar = memo(
248266
"!w-[40px] [clip-path:path('M23.75_0A8_8_0_0_0_17.6_2.88L3.41_19.9A2.5_2.5_0_0_0_5.34_24L36_24A4_4_0_0_0_40_20L40_4A4_4_0_0_0_36_0Z')] [&_svg]:translate-y-px",
249267
isSwell &&
250268
actionId === firstActionId &&
251-
(actionId === 'run' ? '[&_svg]:translate-x-[8px]' : '[&_svg]:translate-x-[6px]'),
269+
(actionId === 'run' || actionId === 'color'
270+
? '[&_svg]:translate-x-[8px]'
271+
: '[&_svg]:translate-x-[6px]'),
252272
isSwell &&
253273
actionId === 'delete' &&
254274
"!w-[40px] [clip-path:path('M16.25_0A8_8_0_0_1_22.4_2.88L36.59_19.9A2.5_2.5_0_0_1_34.66_24L4_24A4_4_0_0_1_0_20L0_4A4_4_0_0_1_4_0Z')] [&_svg]:-translate-x-[6px] [&_svg]:translate-y-px",
@@ -382,6 +402,63 @@ export const ActionBar = memo(
382402
</Tooltip.Root>
383403
)}
384404

405+
{isNoteBlock && (
406+
<DropdownMenu
407+
onOpenChange={(open) => {
408+
if (open) onNoteColorMenuOpen?.()
409+
}}
410+
>
411+
<Tooltip.Root preferAbove>
412+
<Tooltip.Trigger asChild>
413+
<DropdownMenuTrigger asChild>
414+
<Button
415+
variant='ghost'
416+
className={getActionButtonStyles('color')}
417+
disabled={disabled || isLocked || isParentLocked || !onNoteColorChange}
418+
aria-label='Note color'
419+
onClick={(event) => event.stopPropagation()}
420+
>
421+
<Palette className={ICON_SIZE} />
422+
</Button>
423+
</DropdownMenuTrigger>
424+
</Tooltip.Trigger>
425+
<Tooltip.Content side='top'>Color</Tooltip.Content>
426+
</Tooltip.Root>
427+
<DropdownMenuContent
428+
align='center'
429+
side='top'
430+
sideOffset={8}
431+
className='w-fit min-w-0 rounded-full p-1'
432+
>
433+
<DropdownMenuRadioGroup
434+
value={noteColor}
435+
className='flex flex-col gap-0.5'
436+
onValueChange={(value) => {
437+
if (isNoteColor(value)) onNoteColorChange?.(value)
438+
}}
439+
>
440+
{NOTE_COLOR_OPTIONS.map((option) => (
441+
<DropdownMenuRadioItem
442+
key={option.id}
443+
value={option.id}
444+
aria-label={option.label}
445+
className='size-[28px] cursor-pointer justify-center rounded-full p-0 [&>span:first-child]:hidden'
446+
>
447+
<span
448+
className={cn(
449+
'size-[16px] rounded-full border border-black/15',
450+
option.swatchClassName,
451+
option.id === noteColor &&
452+
'ring-2 ring-[var(--text-primary)] ring-offset-1 ring-offset-[var(--bg)]'
453+
)}
454+
/>
455+
</DropdownMenuRadioItem>
456+
))}
457+
</DropdownMenuRadioGroup>
458+
</DropdownMenuContent>
459+
</DropdownMenu>
460+
)}
461+
385462
{userPermissions.canAdmin && (
386463
<Tooltip.Root preferAbove>
387464
<Tooltip.Trigger asChild>
@@ -524,7 +601,10 @@ export const ActionBar = memo(
524601
prevProps.blockType === nextProps.blockType &&
525602
prevProps.disabled === nextProps.disabled &&
526603
prevProps.variant === nextProps.variant &&
527-
prevProps.isRunning === nextProps.isRunning
604+
prevProps.isRunning === nextProps.isRunning &&
605+
prevProps.noteColor === nextProps.noteColor &&
606+
prevProps.onNoteColorChange === nextProps.onNoteColorChange &&
607+
prevProps.onNoteColorMenuOpen === nextProps.onNoteColorMenuOpen
528608
)
529609
}
530610
)

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx

Lines changed: 131 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,32 @@
1-
import { memo, useCallback, useMemo } from 'react'
2-
import { BLOCK_DIMENSIONS, getNoteBlockHeight, NoteBlockView } from '@sim/workflow-renderer'
3-
import type { NodeProps } from 'reactflow'
1+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
2+
import {
3+
BLOCK_DIMENSIONS,
4+
DEFAULT_NOTE_COLOR,
5+
estimateNoteBlockHeight,
6+
isNoteColor,
7+
NoteBlockView,
8+
type NoteColor,
9+
type NoteContentEditorProps,
10+
} from '@sim/workflow-renderer'
11+
import { type NodeProps, useReactFlow } from 'reactflow'
412
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
513
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
14+
import { NoteMarkdownEditor } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-markdown-editor'
15+
import type { WorkflowBlockProps } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types'
616
import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
717
import { useBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
18+
import { isBlockProtected } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
19+
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
20+
import { usePanelEditorStore } from '@/stores/panel'
821
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
9-
import type { WorkflowBlockProps } from '../workflow-block/types'
22+
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
1023

1124
interface NoteBlockNodeData extends WorkflowBlockProps {}
1225

13-
/** Extracts the string content from a raw subblock value (string or `{ value }`). */
14-
function extractFieldValue(rawValue: unknown): string | undefined {
26+
const NOTE_EXPAND_FOCUS_DURATION_MS = 300
27+
28+
/** Resolves a string stored directly or inside a serialized subblock value. */
29+
function getNoteStringValue(rawValue: unknown): string | undefined {
1530
if (typeof rawValue === 'string') return rawValue
1631
if (rawValue && typeof rawValue === 'object' && 'value' in rawValue) {
1732
const candidate = (rawValue as { value?: unknown }).value
@@ -20,6 +35,10 @@ function extractFieldValue(rawValue: unknown): string | undefined {
2035
return undefined
2136
}
2237

38+
function renderNoteContentEditor(props: NoteContentEditorProps) {
39+
return <NoteMarkdownEditor {...props} />
40+
}
41+
2342
/**
2443
* Editor container for {@link NoteBlockView}.
2544
*
@@ -34,12 +53,15 @@ export const NoteBlock = memo(function NoteBlock({
3453
selected,
3554
}: NodeProps<NoteBlockNodeData>) {
3655
const { type, name } = data
56+
const focusFrameRef = useRef<number | null>(null)
57+
const { getNode, getViewport, setCenter } = useReactFlow()
3758

38-
const { activeWorkflowId, isEnabled, handleClick, hasRing, ringStyles } = useBlockVisual({
59+
const { activeWorkflowId, isEnabled, hasRing, ringStyles } = useBlockVisual({
3960
blockId: id,
4061
data,
4162
isSelected: selected,
4263
})
64+
const { collaborativeSetSubblockValue, collaborativeUpdateBlockName } = useCollaborativeWorkflow()
4365
const storedValues = useSubBlockStore(
4466
useCallback(
4567
(state) => {
@@ -52,16 +74,91 @@ export const NoteBlock = memo(function NoteBlock({
5274

5375
const content = useMemo(() => {
5476
if (data.isPreview && data.subBlockValues) {
55-
const extractedContent = extractFieldValue(data.subBlockValues.content)
77+
const extractedContent = getNoteStringValue(data.subBlockValues.content)
5678
return typeof extractedContent === 'string' ? extractedContent : ''
5779
}
58-
const storedContent = extractFieldValue(storedValues?.content)
80+
const storedContent = getNoteStringValue(storedValues?.content)
5981
return typeof storedContent === 'string' ? storedContent : ''
6082
}, [data.isPreview, data.subBlockValues, storedValues])
6183

84+
const rawColor = data.isPreview
85+
? getNoteStringValue(data.subBlockValues?.color)
86+
: getNoteStringValue(storedValues?.color)
87+
const noteColor = isNoteColor(rawColor) ? rawColor : DEFAULT_NOTE_COLOR
88+
6289
const userPermissions = useUserPermissionsContext()
6390
const canEditWorkflow = userPermissions.canEdit && !data.isWorkflowLocked
64-
const isEmpty = content.trim().length === 0
91+
const isProtected = useWorkflowStore(
92+
useCallback((state) => isBlockProtected(id, state.blocks), [id])
93+
)
94+
const clearCurrentBlock = usePanelEditorStore((state) => state.clearCurrentBlock)
95+
const canEditNote = canEditWorkflow && !data.isPreview && !isProtected
96+
const [blockHeight, setBlockHeight] = useState(() => estimateNoteBlockHeight(content))
97+
const [isExpanded, setIsExpanded] = useState(false)
98+
const isFocused = selected
99+
100+
useEffect(() => {
101+
if (!canEditNote) setIsExpanded(false)
102+
}, [canEditNote])
103+
104+
useEffect(
105+
() => () => {
106+
if (focusFrameRef.current !== null) cancelAnimationFrame(focusFrameRef.current)
107+
},
108+
[]
109+
)
110+
111+
const handleNameChange = (nextName: string) => {
112+
if (!canEditNote) return false
113+
return collaborativeUpdateBlockName(id, nextName).success
114+
}
115+
116+
const handleContentChange = (nextContent: string) => {
117+
if (!canEditNote) return
118+
collaborativeSetSubblockValue(id, 'content', nextContent)
119+
}
120+
121+
const handleColorChange = useCallback(
122+
(color: NoteColor) => {
123+
if (!canEditNote) return
124+
collaborativeSetSubblockValue(id, 'color', color)
125+
},
126+
[canEditNote, collaborativeSetSubblockValue, id]
127+
)
128+
129+
const handleNoteSelect = useCallback(() => {
130+
if (data.isPreview || data.isEmbedded) return
131+
clearCurrentBlock()
132+
}, [clearCurrentBlock, data.isEmbedded, data.isPreview])
133+
134+
const handleExpandedChange = useCallback(
135+
(expanded: boolean) => {
136+
if (expanded && !canEditNote) return
137+
if (expanded) handleNoteSelect()
138+
setIsExpanded(expanded)
139+
140+
if (focusFrameRef.current !== null) cancelAnimationFrame(focusFrameRef.current)
141+
if (!expanded) return
142+
143+
focusFrameRef.current = requestAnimationFrame(() => {
144+
focusFrameRef.current = requestAnimationFrame(() => {
145+
focusFrameRef.current = null
146+
const node = getNode(id)
147+
const position = node?.positionAbsolute ?? node?.position
148+
if (!position) return
149+
void setCenter(
150+
position.x + BLOCK_DIMENSIONS.NOTE_WIDTH / 2,
151+
position.y + blockHeight / 2,
152+
{
153+
zoom: getViewport().zoom,
154+
duration: NOTE_EXPAND_FOCUS_DURATION_MS,
155+
}
156+
)
157+
})
158+
})
159+
},
160+
[blockHeight, canEditNote, getNode, getViewport, handleNoteSelect, id, setCenter]
161+
)
65162

66163
/**
67164
* Calculate deterministic dimensions based on content structure. Uses fixed
@@ -70,21 +167,41 @@ export const NoteBlock = memo(function NoteBlock({
70167
useBlockDimensions({
71168
blockId: id,
72169
calculateDimensions: () => {
73-
return { width: BLOCK_DIMENSIONS.FIXED_WIDTH, height: getNoteBlockHeight(isEmpty) }
170+
return {
171+
width: BLOCK_DIMENSIONS.NOTE_WIDTH,
172+
height: blockHeight,
173+
}
74174
},
75-
dependencies: [isEmpty],
175+
dependencies: [blockHeight],
76176
})
77177

78178
return (
79179
<NoteBlockView
80180
name={name}
81181
content={content}
182+
noteColor={noteColor}
82183
isEnabled={isEnabled}
184+
isFocused={isFocused}
185+
isExpanded={isExpanded}
186+
canEdit={canEditNote}
83187
hasRing={hasRing}
84188
ringStyles={ringStyles}
85-
onSelect={handleClick}
189+
onSelect={handleNoteSelect}
190+
onNameChange={handleNameChange}
191+
onContentChange={handleContentChange}
192+
onHeightChange={setBlockHeight}
193+
onExpandedChange={handleExpandedChange}
194+
renderContentEditor={renderNoteContentEditor}
86195
actionBar={
87-
<ActionBar blockId={id} blockType={type} disabled={!canEditWorkflow} variant='swell' />
196+
<ActionBar
197+
blockId={id}
198+
blockType={type}
199+
disabled={!canEditWorkflow}
200+
variant='swell'
201+
noteColor={noteColor}
202+
onNoteColorChange={handleColorChange}
203+
onNoteColorMenuOpen={handleNoteSelect}
204+
/>
88205
}
89206
/>
90207
)

0 commit comments

Comments
 (0)