From 46065983f6ecef33add41a834c14074f2d3e30f1 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 3 Mar 2026 22:10:00 -0800 Subject: [PATCH] fix(editor): restore cursor position after tag/env-var completion in code editors (#3406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(editor): restore cursor position after tag/env-var completion in code editors * lint * refactor(editor): extract restoreCursorAfterInsertion helper, fix weak fallbacks * updated * fix(editor): replace useEffect with direct ref assignment for editorValueRef * fix(editor): guard cursor restoration behind preview/readOnly check Move restoreCursorAfterInsertion inside the !isPreview && !readOnly guard so cursor position isn't computed against newValue when the textarea still holds liveValue. Add comment documenting the cross-string index invariant in the shared helper. Co-Authored-By: Claude Opus 4.6 * fix(editor): escape blockId in CSS selector with CSS.escape() Prevents potential SyntaxError if blockId ever contains CSS special characters when querying the textarea for cursor restoration. Co-Authored-By: Claude Opus 4.6 * perf(editor): use ref for cursor fallback to stabilize useCallback Replace cursorPosition state in handleSubflowTagSelect's dependency array with a cursorPositionRef. This avoids recreating the callback on every keystroke since cursorPosition is only used as a fallback when textareaRef.current is null. Co-Authored-By: Claude Opus 4.6 * refactor(editor): pass cursor position explicitly from dropdowns Instead of inferring cursor position by searching for delimiters in the output string (which could match unrelated < or {{ in code), compute the exact cursor position in TagDropdown and EnvVarDropdown where the insertion range is definitively known, and pass it through onSelect. This follows the same pattern used by CodeMirror, Monaco, and ProseMirror: the insertion source always knows the range, so cursor position is computed at the source rather than inferred by the consumer. - TagDropdown/EnvVarDropdown: compute newCursorPosition, pass as 2nd arg - restoreCursorAfterInsertion: simplified to just (textarea, position) - code.tsx, condition-input.tsx, use-subflow-editor.ts: accept position - Removed editorValueRef and cursorPositionRef from use-subflow-editor (no longer needed since dropdown computes position) - Other consumers (native inputs) unaffected due to TS callback compat Co-Authored-By: Claude Opus 4.6 * docs(editor): fix JSDoc terminology — macrotask not microtask Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../sub-block/components/code/code.tsx | 25 ++++++----- .../condition-input/condition-input.tsx | 41 ++++++++++++++++--- .../sub-block/components/env-var-dropdown.tsx | 13 +++--- .../components/tag-dropdown/tag-dropdown.tsx | 8 +++- .../editor/hooks/use-subflow-editor.ts | 12 ++---- .../panel/components/editor/utils.ts | 20 +++++++++ 6 files changed, 86 insertions(+), 33 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx index 57bcae75b9..af7d63c168 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx @@ -31,6 +31,7 @@ import { } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block' +import { restoreCursorAfterInsertion } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils' import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand' @@ -537,36 +538,40 @@ export const Code = memo(function Code({ /** * Handles selection of a tag from the tag dropdown. * @param newValue - The new code value with the selected tag inserted + * @param newCursorPosition - The cursor position after the inserted tag */ - const handleTagSelect = (newValue: string) => { + const handleTagSelect = (newValue: string, newCursorPosition: number) => { + const textarea = editorRef.current?.querySelector('textarea') as HTMLTextAreaElement | null + if (!isPreview && !readOnly) { setCode(newValue) emitTagSelection(newValue) recordChange(newValue) + restoreCursorAfterInsertion(textarea, newCursorPosition) + } else { + setTimeout(() => textarea?.focus(), 0) } setShowTags(false) setActiveSourceBlockId(null) - - setTimeout(() => { - editorRef.current?.querySelector('textarea')?.focus() - }, 0) } /** * Handles selection of an environment variable from the dropdown. * @param newValue - The new code value with the selected env var inserted + * @param newCursorPosition - The cursor position after the inserted env var */ - const handleEnvVarSelect = (newValue: string) => { + const handleEnvVarSelect = (newValue: string, newCursorPosition: number) => { + const textarea = editorRef.current?.querySelector('textarea') as HTMLTextAreaElement | null + if (!isPreview && !readOnly) { setCode(newValue) emitTagSelection(newValue) recordChange(newValue) + restoreCursorAfterInsertion(textarea, newCursorPosition) + } else { + setTimeout(() => textarea?.focus(), 0) } setShowEnvVars(false) - - setTimeout(() => { - editorRef.current?.querySelector('textarea')?.focus() - }, 0) } /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx index 4b2c78c357..cc2f75cef3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx @@ -31,6 +31,7 @@ import { TagDropdown, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' +import { restoreCursorAfterInsertion } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import { normalizeName } from '@/executor/constants' import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation' @@ -554,9 +555,17 @@ export function ConditionInput({ ) } - const handleTagSelectImmediate = (blockId: string, newValue: string) => { + const handleTagSelectImmediate = ( + blockId: string, + newValue: string, + newCursorPosition: number + ) => { if (isPreview || disabled) return + const textarea = containerRef.current?.querySelector( + `[data-block-id="${CSS.escape(blockId)}"] textarea` + ) as HTMLTextAreaElement | null + shouldPersistRef.current = true setConditionalBlocks((blocks) => blocks.map((block) => @@ -582,11 +591,21 @@ export function ConditionInput({ : block ) emitTagSelection(JSON.stringify(updatedBlocks)) + + restoreCursorAfterInsertion(textarea, newCursorPosition) } - const handleEnvVarSelectImmediate = (blockId: string, newValue: string) => { + const handleEnvVarSelectImmediate = ( + blockId: string, + newValue: string, + newCursorPosition: number + ) => { if (isPreview || disabled) return + const textarea = containerRef.current?.querySelector( + `[data-block-id="${CSS.escape(blockId)}"] textarea` + ) as HTMLTextAreaElement | null + shouldPersistRef.current = true setConditionalBlocks((blocks) => blocks.map((block) => @@ -612,6 +631,8 @@ export function ConditionInput({ : block ) emitTagSelection(JSON.stringify(updatedBlocks)) + + restoreCursorAfterInsertion(textarea, newCursorPosition) } /** @@ -999,7 +1020,9 @@ export function ConditionInput({ {block.showEnvVars && ( handleEnvVarSelectImmediate(block.id, newValue)} + onSelect={(newValue, newCursorPosition) => + handleEnvVarSelectImmediate(block.id, newValue, newCursorPosition) + } searchTerm={block.searchTerm} inputValue={block.value} cursorPosition={block.cursorPosition} @@ -1023,7 +1046,9 @@ export function ConditionInput({ {block.showTags && ( handleTagSelectImmediate(block.id, newValue)} + onSelect={(newValue, newCursorPosition) => + handleTagSelectImmediate(block.id, newValue, newCursorPosition) + } blockId={blockId} activeSourceBlockId={block.activeSourceBlockId} inputValue={block.value} @@ -1207,7 +1232,9 @@ export function ConditionInput({ {block.showEnvVars && ( handleEnvVarSelectImmediate(block.id, newValue)} + onSelect={(newValue, newCursorPosition) => + handleEnvVarSelectImmediate(block.id, newValue, newCursorPosition) + } searchTerm={block.searchTerm} inputValue={block.value} cursorPosition={block.cursorPosition} @@ -1225,7 +1252,9 @@ export function ConditionInput({ {block.showTags && ( handleTagSelectImmediate(block.id, newValue)} + onSelect={(newValue, newCursorPosition) => + handleTagSelectImmediate(block.id, newValue, newCursorPosition) + } blockId={blockId} activeSourceBlockId={block.activeSourceBlockId} inputValue={block.value} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown.tsx index 416e07950e..2d3680114a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown.tsx @@ -23,7 +23,7 @@ interface EnvVarDropdownProps { /** Whether the dropdown is visible */ visible: boolean /** Callback when an environment variable is selected */ - onSelect: (newValue: string) => void + onSelect: (newValue: string, newCursorPosition: number) => void /** Search term to filter environment variables */ searchTerm?: string /** Additional CSS class names */ @@ -189,6 +189,8 @@ export const EnvVarDropdown: React.FC = ({ const isStandardEnvVarContext = lastOpenBraces !== -1 + const tagLength = 2 + envVar.length + 2 + if (isStandardEnvVarContext) { const startText = textBeforeCursor.slice(0, lastOpenBraces) @@ -196,13 +198,10 @@ export const EnvVarDropdown: React.FC = ({ const endText = closeIndex !== -1 ? textAfterCursor.slice(closeIndex + 2) : textAfterCursor const newValue = `${startText}{{${envVar}}}${endText}` - onSelect(newValue) + onSelect(newValue, lastOpenBraces + tagLength) } else { - if (inputValue.trim() !== '') { - onSelect(`{{${envVar}}}`) - } else { - onSelect(`{{${envVar}}}`) - } + const newValue = `{{${envVar}}}` + onSelect(newValue, tagLength) } onClose?.() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx index 8fcdf62c1c..b3b12c1201 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx @@ -70,7 +70,7 @@ interface TagDropdownProps { /** Whether the dropdown is visible */ visible: boolean /** Callback when a tag is selected */ - onSelect: (newValue: string) => void + onSelect: (newValue: string, newCursorPosition: number) => void /** ID of the block that owns the input field */ blockId: string /** ID of the specific source block being referenced, if any */ @@ -1598,10 +1598,12 @@ export const TagDropdown: React.FC = ({ } let newValue: string + let insertStart: number if (lastOpenBracket === -1) { // No '<' found - insert the full tag at cursor position newValue = `${textBeforeCursor}<${processedTag}>${textAfterCursor}` + insertStart = liveCursor } else { // '<' found - replace from '<' to cursor (and consume trailing '>' if present) const nextCloseBracket = textAfterCursor.indexOf('>') @@ -1615,9 +1617,11 @@ export const TagDropdown: React.FC = ({ } newValue = `${textBeforeCursor.slice(0, lastOpenBracket)}<${processedTag}>${remainingTextAfterCursor}` + insertStart = lastOpenBracket } - onSelect(newValue) + const newCursorPos = insertStart + 1 + processedTag.length + 1 + onSelect(newValue, newCursorPos) onClose?.() }, [workflowVariables, onSelect, onClose, getMergedSubBlocks] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-subflow-editor.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-subflow-editor.ts index c9343a90bf..08428f5d17 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-subflow-editor.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-subflow-editor.ts @@ -7,6 +7,7 @@ import { splitReferenceSegment, } from '@/lib/workflows/sanitization/references' import { checkTagTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' +import { restoreCursorAfterInsertion } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import { normalizeName, REFERENCE } from '@/executor/constants' import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation' @@ -60,7 +61,6 @@ export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId const textareaRef = useRef(null) const editorContainerRef = useRef(null) - const [tempInputValue, setTempInputValue] = useState(null) const [showTagDropdown, setShowTagDropdown] = useState(false) const [cursorPosition, setCursorPosition] = useState(0) @@ -289,8 +289,9 @@ export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId * Handle tag selection from dropdown */ const handleSubflowTagSelect = useCallback( - (newValue: string) => { + (newValue: string, newCursorPosition: number) => { if (!currentBlockId || !isSubflow || !currentBlock) return + collaborativeUpdateIterationCollection( currentBlockId, currentBlock.type as 'loop' | 'parallel', @@ -298,12 +299,7 @@ export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId ) setShowTagDropdown(false) - setTimeout(() => { - const textarea = textareaRef.current - if (textarea) { - textarea.focus() - } - }, 0) + restoreCursorAfterInsertion(textareaRef.current, newCursorPosition) }, [currentBlockId, isSubflow, currentBlock, collaborativeUpdateIterationCollection] ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils.ts new file mode 100644 index 0000000000..4cf78175e6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils.ts @@ -0,0 +1,20 @@ +/** + * Restores the cursor position in a textarea after a dropdown insertion. + * Schedules a macrotask (via setTimeout) that runs after React's controlled-component commit + * so that the cursor position sticks. + * + * @param textarea - The textarea element to restore cursor in (may be null) + * @param newCursorPosition - The exact position to place the cursor at + */ +export function restoreCursorAfterInsertion( + textarea: HTMLTextAreaElement | null, + newCursorPosition: number +): void { + setTimeout(() => { + if (textarea) { + textarea.focus() + textarea.selectionStart = newCursorPosition + textarea.selectionEnd = newCursorPosition + } + }, 0) +}