mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(editor): restore cursor position after tag/env-var completion in code editors (#3406)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * docs(editor): fix JSDoc terminology — macrotask not microtask Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
2c79d0249f
commit
46065983f6
+15
-10
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+35
-6
@@ -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 && (
|
||||
<EnvVarDropdown
|
||||
visible={block.showEnvVars}
|
||||
onSelect={(newValue) => 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 && (
|
||||
<TagDropdown
|
||||
visible={block.showTags}
|
||||
onSelect={(newValue) => 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 && (
|
||||
<EnvVarDropdown
|
||||
visible={block.showEnvVars}
|
||||
onSelect={(newValue) => 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 && (
|
||||
<TagDropdown
|
||||
visible={block.showTags}
|
||||
onSelect={(newValue) => handleTagSelectImmediate(block.id, newValue)}
|
||||
onSelect={(newValue, newCursorPosition) =>
|
||||
handleTagSelectImmediate(block.id, newValue, newCursorPosition)
|
||||
}
|
||||
blockId={blockId}
|
||||
activeSourceBlockId={block.activeSourceBlockId}
|
||||
inputValue={block.value}
|
||||
|
||||
+6
-7
@@ -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<EnvVarDropdownProps> = ({
|
||||
|
||||
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<EnvVarDropdownProps> = ({
|
||||
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?.()
|
||||
|
||||
+6
-2
@@ -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<TagDropdownProps> = ({
|
||||
}
|
||||
|
||||
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<TagDropdownProps> = ({
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
+4
-8
@@ -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<HTMLTextAreaElement | null>(null)
|
||||
const editorContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [tempInputValue, setTempInputValue] = useState<string | null>(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]
|
||||
)
|
||||
|
||||
+20
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user