diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 5f065decc..ac453e2bc 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -235,6 +235,12 @@ type LocalEditRegionInteractionState = { startRect: NormalizedCropRect; }; +type LocalEditPromptCompositionSnapshot = { + end: number; + start: number; + value: string; +}; + type AnglePreviewDragState = { pointerId: number; startX: number; @@ -2060,6 +2066,9 @@ export function ImageNode({ Record >({}); const localEditPromptDraftRefs = useRef>({}); + const localEditPromptCompositionStartRefs = useRef< + Record + >({}); const localEditPromptComposingRefs = useRef>({}); const localEditRegionsRef = useRef(localEditRegions); @@ -2601,6 +2610,110 @@ export function ImageNode({ [] ); + const restoreLocalEditPromptSelection = useCallback( + (regionId: string, position: number) => { + if (typeof window === "undefined") return; + window.requestAnimationFrame(() => { + const input = localEditPromptInputRefs.current[regionId]; + if (!input) return; + const safePosition = clampNumber(position, 0, input.value.length); + input.setSelectionRange(safePosition, safePosition); + }); + }, + [] + ); + + const applyLocalEditPromptTextEdit = useCallback( + ( + regionId: string, + input: HTMLTextAreaElement, + inputEvent: InputEvent + ) => { + const inputType = inputEvent.inputType || ""; + if (!inputType || inputEvent.isComposing) { + return false; + } + if (inputType === "insertCompositionText") { + return false; + } + + const currentValue = localEditPromptDraftRefs.current[regionId] ?? input.value; + const selectionStart = input.selectionStart ?? currentValue.length; + const selectionEnd = input.selectionEnd ?? selectionStart; + const start = clampNumber(selectionStart, 0, currentValue.length); + const end = clampNumber(selectionEnd, start, currentValue.length); + let nextValue: string | null = null; + let nextCursor = start; + + if (inputType.startsWith("insert")) { + const insertedText = inputEvent.data ?? ""; + if (!insertedText) { + return false; + } + nextValue = `${currentValue.slice(0, start)}${insertedText}${currentValue.slice(end)}`; + nextCursor = start + insertedText.length; + } else if (inputType === "deleteContentBackward") { + if (start !== end) { + nextValue = `${currentValue.slice(0, start)}${currentValue.slice(end)}`; + nextCursor = start; + } else if (start > 0) { + nextValue = `${currentValue.slice(0, start - 1)}${currentValue.slice(end)}`; + nextCursor = start - 1; + } else { + nextValue = currentValue; + nextCursor = start; + } + } else if (inputType === "deleteContentForward") { + if (start !== end) { + nextValue = `${currentValue.slice(0, start)}${currentValue.slice(end)}`; + } else if (start < currentValue.length) { + nextValue = `${currentValue.slice(0, start)}${currentValue.slice(start + 1)}`; + } else { + nextValue = currentValue; + } + nextCursor = start; + } + + if (nextValue === null) { + return false; + } + + updateLocalEditPromptDraft(regionId, nextValue); + restoreLocalEditPromptSelection(regionId, nextCursor); + return true; + }, + [restoreLocalEditPromptSelection, updateLocalEditPromptDraft] + ); + + const applyLocalEditPromptCompositionFallback = useCallback( + ( + regionId: string, + input: HTMLTextAreaElement, + compositionText: string + ) => { + const snapshot = localEditPromptCompositionStartRefs.current[regionId]; + delete localEditPromptCompositionStartRefs.current[regionId]; + if (!snapshot || !compositionText) { + updateLocalEditPromptDraft(regionId, input.value); + return; + } + + const draftValue = localEditPromptDraftRefs.current[regionId] ?? input.value; + if (input.value !== snapshot.value || draftValue !== snapshot.value) { + updateLocalEditPromptDraft(regionId, input.value); + return; + } + + const nextValue = `${snapshot.value.slice(0, snapshot.start)}${compositionText}${snapshot.value.slice(snapshot.end)}`; + updateLocalEditPromptDraft(regionId, nextValue); + restoreLocalEditPromptSelection( + regionId, + snapshot.start + compositionText.length + ); + }, + [restoreLocalEditPromptSelection, updateLocalEditPromptDraft] + ); + const refreshBoxCutoutMetrics = useCallback(() => { const imageElement = displayImageRef.current; if (!imageElement) { @@ -3699,6 +3812,7 @@ export function ImageNode({ delete next[regionId]; return next; }); + delete localEditPromptCompositionStartRefs.current[regionId]; delete localEditPromptComposingRefs.current[regionId]; commitLocalEditRegions( localEditRegionsRef.current.filter((region) => region.id !== regionId) @@ -3801,6 +3915,7 @@ export function ImageNode({ const clearLocalEditRegions = useCallback(() => { localEditPromptDraftRefs.current = {}; + localEditPromptCompositionStartRefs.current = {}; setLocalEditPromptDrafts({}); commitLocalEditRegions([]); setEditingLocalEditRegionId(null); @@ -8051,17 +8166,42 @@ export function ImageNode({ onClick={(event) => { event.stopPropagation(); }} + onBeforeInput={(event) => { + if ( + applyLocalEditPromptTextEdit( + region.id, + event.currentTarget, + event.nativeEvent as InputEvent + ) + ) { + event.preventDefault(); + } + }} onCompositionEnd={(event) => { localEditPromptComposingRefs.current[region.id] = false; - updateLocalEditPromptDraft( + applyLocalEditPromptCompositionFallback( region.id, - event.currentTarget.value + event.currentTarget, + event.data ); }} - onCompositionStart={() => { + onCompositionStart={(event) => { localEditPromptComposingRefs.current[region.id] = true; + localEditPromptCompositionStartRefs.current[ + region.id + ] = { + end: + event.currentTarget.selectionEnd ?? + event.currentTarget.value.length, + start: + event.currentTarget.selectionStart ?? + event.currentTarget.value.length, + value: + localEditPromptDraftRefs.current[region.id] ?? + event.currentTarget.value, + }; }} onFocus={(event) => { event.stopPropagation();