Skip to content

Commit 256c97b

Browse files
committed
improvement(notes): add focused canvas editing
1 parent 7f50240 commit 256c97b

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, Trash, toast } from '@sim/emcn'
3-
import { Ban, Circle, Lock, LogOut, Unlock } from '@sim/emcn/icons'
2+
import {
3+
Button,
4+
cn,
5+
DropdownMenu,
6+
DropdownMenuContent,
7+
DropdownMenuRadioGroup,
8+
DropdownMenuRadioItem,
9+
DropdownMenuTrigger,
10+
Duplicate,
11+
PlayOutline,
12+
Tooltip,
13+
Trash,
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 { Ban, Circle, Lock, LogOut, Palette, Unlock } from '@sim/emcn/icons'
423
import { useShallow } from 'zustand/react/shallow'
524
import { ThinkingLoader } from '@/components/ui'
625
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
@@ -32,7 +51,7 @@ const PROGRESS_LEFT_CAP_PATH =
3251
const PROGRESS_RIGHT_CAP_PATH =
3352
'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'
3453

35-
type ActionId = 'run' | 'enabled' | 'lock' | 'duplicate' | 'remove' | 'delete'
54+
type ActionId = 'run' | 'enabled' | 'lock' | 'duplicate' | 'remove' | 'delete' | 'color'
3655

3756
function IndeterminateBlockProgress() {
3857
return (
@@ -92,6 +111,10 @@ interface ActionBarProps {
92111
variant?: 'floating' | 'swell'
93112
/** Whether the current workflow is executing. */
94113
isRunning?: boolean
114+
noteColor?: NoteColor
115+
onNoteColorChange?: (color: NoteColor) => void
116+
/** Keeps the note and its swell selected while the portalled color menu is open. */
117+
onNoteColorMenuOpen?: () => void
95118
}
96119

97120
/**
@@ -107,6 +130,9 @@ export const ActionBar = memo(
107130
disabled = false,
108131
variant = 'floating',
109132
isRunning = false,
133+
noteColor = DEFAULT_NOTE_COLOR,
134+
onNoteColorChange,
135+
onNoteColorMenuOpen,
110136
}: ActionBarProps) {
111137
const {
112138
collaborativeBatchAddBlocks,
@@ -189,16 +215,7 @@ export const ActionBar = memo(
189215
const canRunFromBlock =
190216
dependenciesSatisfied && !isNoteBlock && !isInsideSubflow && !isExecuting
191217
const isSwell = variant === 'swell'
192-
const firstActionId: ActionId =
193-
!isNoteBlock && !isInsideSubflow
194-
? 'run'
195-
: !isNoteBlock
196-
? 'enabled'
197-
: userPermissions.canAdmin
198-
? 'lock'
199-
: !isStartBlock && !isResponseBlock
200-
? 'duplicate'
201-
: 'delete'
218+
const firstActionId: ActionId = isNoteBlock ? 'color' : !isInsideSubflow ? 'run' : 'enabled'
202219
/*
203220
* Icon treatment follows the swell's own fill, published by the card view
204221
* as `data-node-selected`. Keying off React Flow's raw `selected` would
@@ -224,7 +241,8 @@ export const ActionBar = memo(
224241
* stays tight against neighboring actions. The row is right-[24px] to
225242
* match the swell anchor inset (right-aligned on the card). Glyphs shift
226243
* away from the outer cut (+6 / -6). Play gets an extra +2px because the
227-
* triangle’s optical center sits left of its viewBox center.
244+
* triangle’s optical center sits left of its viewBox center; the Note
245+
* palette uses the same inset so its first-action padding matches.
228246
*/
229247
const getActionButtonStyles = (actionId: ActionId) =>
230248
cn(
@@ -237,7 +255,9 @@ export const ActionBar = memo(
237255
"!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",
238256
isSwell &&
239257
actionId === firstActionId &&
240-
(actionId === 'run' ? '[&_svg]:translate-x-[8px]' : '[&_svg]:translate-x-[6px]'),
258+
(actionId === 'run' || actionId === 'color'
259+
? '[&_svg]:translate-x-[8px]'
260+
: '[&_svg]:translate-x-[6px]'),
241261
isSwell &&
242262
actionId === 'delete' &&
243263
"!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",
@@ -371,6 +391,63 @@ export const ActionBar = memo(
371391
</Tooltip.Root>
372392
)}
373393

394+
{isNoteBlock && (
395+
<DropdownMenu
396+
onOpenChange={(open) => {
397+
if (open) onNoteColorMenuOpen?.()
398+
}}
399+
>
400+
<Tooltip.Root preferAbove>
401+
<Tooltip.Trigger asChild>
402+
<DropdownMenuTrigger asChild>
403+
<Button
404+
variant='ghost'
405+
className={getActionButtonStyles('color')}
406+
disabled={disabled || isLocked || isParentLocked || !onNoteColorChange}
407+
aria-label='Note color'
408+
onClick={(event) => event.stopPropagation()}
409+
>
410+
<Palette className={ICON_SIZE} />
411+
</Button>
412+
</DropdownMenuTrigger>
413+
</Tooltip.Trigger>
414+
<Tooltip.Content side='top'>Color</Tooltip.Content>
415+
</Tooltip.Root>
416+
<DropdownMenuContent
417+
align='center'
418+
side='top'
419+
sideOffset={8}
420+
className='w-fit min-w-0 rounded-full p-1'
421+
>
422+
<DropdownMenuRadioGroup
423+
value={noteColor}
424+
className='flex flex-col gap-0.5'
425+
onValueChange={(value) => {
426+
if (isNoteColor(value)) onNoteColorChange?.(value)
427+
}}
428+
>
429+
{NOTE_COLOR_OPTIONS.map((option) => (
430+
<DropdownMenuRadioItem
431+
key={option.id}
432+
value={option.id}
433+
aria-label={option.label}
434+
className='size-[28px] cursor-pointer justify-center rounded-full p-0 [&>span:first-child]:hidden'
435+
>
436+
<span
437+
className={cn(
438+
'size-[16px] rounded-full border border-black/15',
439+
option.swatchClassName,
440+
option.id === noteColor &&
441+
'ring-2 ring-[var(--text-primary)] ring-offset-1 ring-offset-[var(--bg)]'
442+
)}
443+
/>
444+
</DropdownMenuRadioItem>
445+
))}
446+
</DropdownMenuRadioGroup>
447+
</DropdownMenuContent>
448+
</DropdownMenu>
449+
)}
450+
374451
{userPermissions.canAdmin && (
375452
<Tooltip.Root preferAbove>
376453
<Tooltip.Trigger asChild>
@@ -513,7 +590,10 @@ export const ActionBar = memo(
513590
prevProps.blockType === nextProps.blockType &&
514591
prevProps.disabled === nextProps.disabled &&
515592
prevProps.variant === nextProps.variant &&
516-
prevProps.isRunning === nextProps.isRunning
593+
prevProps.isRunning === nextProps.isRunning &&
594+
prevProps.noteColor === nextProps.noteColor &&
595+
prevProps.onNoteColorChange === nextProps.onNoteColorChange &&
596+
prevProps.onNoteColorMenuOpen === nextProps.onNoteColorMenuOpen
517597
)
518598
}
519599
)

‎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)