Skip to content

Commit 5be6847

Browse files
committed
feat: add single-line mode to EditorInput for improved text handling
1 parent 5ea3a7c commit 5be6847

1 file changed

Lines changed: 72 additions & 80 deletions

File tree

src/components/form/EditorInput.tsx

Lines changed: 72 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -32,38 +32,49 @@ export interface EditorInputProps extends Omit<InputWrapperProps, "onChange">, V
3232
onSuggestionSelect?: (suggestion: InputSuggestion) => void
3333
disabled?: boolean
3434
readonly?: boolean
35+
/** Render as a single-line input: Enter is blocked, newlines in pasted or external values become spaces. */
36+
singleLine?: boolean
3537
onChange?: (value: string) => void
3638
placeholder?: string
3739
}
3840

39-
type CustomText = {text: string; tokenRuleIndex?: number; tokenText?: string; tokenMatch?: RegExpExecArray}
41+
type CustomText = { text: string; tokenRuleIndex?: number; tokenText?: string; tokenMatch?: RegExpExecArray }
4042

4143
const textToSlate = (text: string): Descendant[] =>
4244
(text || "").split("\n").map(line => ({type: "paragraph" as const, children: [{text: line}]}))
4345

4446
const slateToText = (value: Descendant[]): string =>
4547
value.map(n => SlateNode.string(n)).join("\n")
4648

47-
const EMPTY_TOKEN_RULES: EditorTokenRule[] = []
48-
4949
export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
5050

5151
const {
52-
title, right, left, rightType, leftType,
52+
title,
53+
right,
54+
left,
55+
rightType,
56+
leftType,
5357
description, wrapperComponent,
54-
tokenRules = EMPTY_TOKEN_RULES,
58+
tokenRules = [],
5559
suggestions,
5660
suggestionsEmptyState,
57-
onSuggestionSelect: onSuggestionSelectProp,
61+
onSuggestionSelect,
5862
formValidation, onChange,
59-
disabled = false, readonly = false,
63+
disabled = false,
64+
readonly = false,
65+
singleLine = false,
6066
placeholder,
61-
value: valueProp, initialValue: initialValueProp, defaultValue: defaultValueProp,
62-
required: _required,
67+
value: valueProp,
68+
initialValue,
69+
defaultValue,
70+
required,
6371
...rest
6472
} = props
6573

66-
const externalText = String(valueProp ?? initialValueProp ?? defaultValueProp ?? "")
74+
// In single-line mode, newlines never reach the document: they are stripped
75+
// here (external values), on Enter (keydown), and on paste.
76+
const rawText = String(valueProp ?? initialValue ?? defaultValue ?? "")
77+
const externalText = singleLine ? rawText.replace(/\n/g, " ") : rawText
6778

6879
const editor = useMemo(() => withHistory(withReact(createEditor())), [])
6980

@@ -72,11 +83,6 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
7283

7384
const prevTextRef = useRef(externalText)
7485

75-
// Read at onChange time (handleChange is defined before `open` / the position
76-
// updater exist), so keep the latest values in refs.
77-
const openRef = useRef(false)
78-
const updateTriggerPositionRef = useRef<(() => void) | undefined>(undefined)
79-
8086
// Sync external value resets (e.g. form reset)
8187
React.useEffect(() => {
8288
if (externalText === prevTextRef.current) return
@@ -91,18 +97,6 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
9197
})
9298
}, [externalText, editor])
9399

94-
const setFormValue = formValidation?.setValue
95-
const handleChange = useCallback((newValue: Descendant[]) => {
96-
// Slate fires onChange for selection changes too — keep the menu anchored
97-
// to the caret as it moves while the suggestions are open.
98-
if (openRef.current) updateTriggerPositionRef.current?.()
99-
const text = slateToText(newValue)
100-
if (text === prevTextRef.current) return
101-
prevTextRef.current = text
102-
setFormValue?.(text)
103-
onChange?.(text)
104-
}, [setFormValue, onChange])
105-
106100
const decorate = useCallback(([node, path]: any) => {
107101
const ranges: any[] = []
108102
if (!Text.isText(node) || !tokenRules.length) return ranges
@@ -112,7 +106,13 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
112106
let match: RegExpExecArray | null
113107
while ((match = re.exec(text)) !== null) {
114108
if (!match[0].length) break
115-
ranges.push({anchor: {path, offset: match.index}, focus: {path, offset: match.index + match[0].length}, tokenRuleIndex: ri, tokenText: match[0], tokenMatch: match})
109+
ranges.push({
110+
anchor: {path, offset: match.index},
111+
focus: {path, offset: match.index + match[0].length},
112+
tokenRuleIndex: ri,
113+
tokenText: match[0],
114+
tokenMatch: match
115+
})
116116
}
117117
})
118118
return ranges
@@ -124,7 +124,11 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
124124
const rule = tokenRules[l.tokenRuleIndex]
125125
if (rule) {
126126
if (rule.wrap) return <span {...attributes}>{rule.wrap(l.tokenText ?? "", children, l.tokenMatch ?? [] as any)}</span>
127-
return <span {...attributes}><span style={rule.style} className={rule.className}>{children}</span></span>
127+
return <span {...attributes}>
128+
<span style={rule.style} className={rule.className}>
129+
{children}
130+
</span>
131+
</span>
128132
}
129133
}
130134
return <span {...attributes}>{children}</span>
@@ -134,7 +138,7 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
134138
const itemsHandleRef = useRef<InputSuggestionMenuContentItemsHandle>(null)
135139
const editorContainerRef = useRef<HTMLDivElement>(null)
136140
const triggerRef = useRef<HTMLButtonElement>(null)
137-
const blurTimer = useRef<ReturnType<typeof setTimeout>>()
141+
const blurTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
138142

139143
// Move the invisible Radix trigger to sit right behind the caret, so the
140144
// suggestion menu is anchored under the text cursor instead of the start of
@@ -148,7 +152,9 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
148152
let rect: DOMRect | null = null
149153
try {
150154
if (editor.selection) rect = ReactEditor.toDOMRange(editor, editor.selection).getBoundingClientRect()
151-
} catch { rect = null }
155+
} catch {
156+
rect = null
157+
}
152158
// Collapsed selections can report an empty rect — fall back to the editor start.
153159
if (!rect || (!rect.width && !rect.height && !rect.left && !rect.top)) rect = container.getBoundingClientRect()
154160

@@ -159,22 +165,17 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
159165
btn.style.height = `${rect.height || parseFloat(getComputedStyle(container).lineHeight) || 16}px`
160166
}, [editor])
161167

162-
openRef.current = open
163-
updateTriggerPositionRef.current = updateTriggerPosition
164-
165-
// A ref-like handle that focuses the Slate editor. The InputSuggestion menu
166-
// uses this to keep the caret in the editor while the menu is open, so the
167-
// menu never steals focus (which would otherwise blur the editor and make
168-
// typing impossible).
169-
const editorFocusRef = useMemo(
170-
() => ({current: {focus: () => { try { ReactEditor.focus(editor) } catch { /* not mounted yet */ } }}}),
171-
[editor]
172-
) as unknown as React.RefObject<HTMLInputElement>
173-
174-
// When the menu is opened via keyboard we want to highlight an item straight
175-
// away, but the items only mount once `open` flips — so remember the intent
176-
// and apply it in an effect after the menu (and its handle) has mounted.
177-
const pendingHighlightRef = useRef<null | "first" | "last">(null)
168+
const setFormValue = formValidation?.setValue
169+
const handleChange = useCallback((newValue: Descendant[]) => {
170+
// Slate fires onChange for selection changes too — keep the menu anchored
171+
// to the caret as it moves while the suggestions are open.
172+
if (open) updateTriggerPosition()
173+
const text = slateToText(newValue)
174+
if (text === prevTextRef.current) return
175+
prevTextRef.current = text
176+
setFormValue?.(text)
177+
onChange?.(text)
178+
}, [open, updateTriggerPosition, setFormValue, onChange])
178179

179180
const openMenu = useCallback(() => {
180181
if (blurTimer.current) clearTimeout(blurTimer.current)
@@ -185,27 +186,8 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
185186
}, [suggestions, updateTriggerPosition])
186187

187188
React.useEffect(() => {
188-
if (!open) {
189-
pendingHighlightRef.current = null
190-
return
191-
}
192-
// Correct the anchor position once the menu has actually mounted.
189+
if (!open) return
193190
updateTriggerPosition()
194-
const highlight = pendingHighlightRef.current
195-
if (!highlight) return
196-
let raf = 0
197-
const apply = () => {
198-
const handle = itemsHandleRef.current
199-
if (!handle) {
200-
raf = requestAnimationFrame(apply)
201-
return
202-
}
203-
if (highlight === "first") handle.focusFirstItem()
204-
else handle.focusLastItem()
205-
pendingHighlightRef.current = null
206-
}
207-
apply()
208-
return () => { if (raf) cancelAnimationFrame(raf) }
209191
}, [open])
210192

211193
// Defer closing so a transient blur (e.g. Radix moving focus on open, or a
@@ -216,6 +198,9 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
216198
}, [])
217199

218200
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
201+
// No line breaks in single-line mode (menu selection below still works —
202+
// it calls preventDefault itself).
203+
if (singleLine && e.key === "Enter") e.preventDefault()
219204
if (!suggestions) return
220205
const handle = itemsHandleRef.current
221206

@@ -224,12 +209,8 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
224209
// on macOS it may need the input-source switch shortcut disabled.
225210
if (e.ctrlKey && e.code === "Space") {
226211
e.preventDefault()
227-
if (!open) {
228-
pendingHighlightRef.current = "first"
229-
openMenu()
230-
} else {
231-
handle?.focusFirstItem()
232-
}
212+
if (!open) openMenu()
213+
else handle?.focusFirstItem()
233214
return
234215
}
235216

@@ -251,20 +232,30 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
251232
e.preventDefault()
252233
setOpen(false)
253234
}
254-
}, [suggestions, open, openMenu])
235+
}, [suggestions, open, openMenu, singleLine])
255236

256237

257238
const editorContent = (
258239
<div {...mergeComponentProps("editor-input", rest)} ref={editorContainerRef}>
259240
<Editable
260-
className="input-wrapper__control"
241+
className={singleLine ? "input-wrapper__control input-wrapper__control--single-line" : "input-wrapper__control"}
261242
decorate={decorate}
262243
renderLeaf={renderLeaf}
263244
readOnly={disabled || readonly}
264245
spellCheck={false}
265246
placeholder={placeholder}
266247
onBlur={scheduleClose}
248+
// A transient blur (focus bounced back by the menu) must not
249+
// close the menu — cancel the pending close on refocus.
250+
onFocus={() => {
251+
if (blurTimer.current) clearTimeout(blurTimer.current)
252+
}}
267253
onKeyDown={handleKeyDown}
254+
onPaste={singleLine ? (e) => {
255+
// Like a native <input>: paste plain text with newlines as spaces.
256+
e.preventDefault()
257+
editor.insertText(e.clipboardData.getData("text/plain").replace(/\r?\n/g, " "))
258+
} : undefined}
268259
/>
269260
</div>
270261
)
@@ -277,7 +268,10 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
277268
rightType={rightType} leftType={leftType}
278269
formValidation={formValidation}
279270
wrapperComponent={suggestions
280-
? {...(wrapperComponent ?? {}), style: {position: "relative" as const, ...(wrapperComponent as any)?.style}}
271+
? {
272+
...(wrapperComponent ?? {}),
273+
style: {position: "relative" as const, ...(wrapperComponent as any)?.style}
274+
}
281275
: wrapperComponent
282276
}
283277
>
@@ -291,13 +285,12 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
291285
tabIndex={-1}
292286
aria-hidden
293287
onMouseDown={(e) => e.preventDefault()}
294-
style={{position: "absolute", left: 0, top: 0, width: 0, height: 0, opacity: 0, pointerEvents: "none"}}
288+
className="editor-input__menu-anchor"
295289
/>
296290
</MenuTrigger>
297291
<MenuPortal>
298292
<InputSuggestionMenuContent
299293
color="primary"
300-
inputRef={editorFocusRef}
301294
onInteractOutside={(e) => {
302295
// The Slate editor is a contenteditable <div>, not an
303296
// <input>, so keep the menu open while the user clicks
@@ -310,9 +303,8 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
310303
<InputSuggestionMenuContentItems
311304
ref={itemsHandleRef}
312305
suggestions={suggestions}
313-
inputRef={editorFocusRef}
314306
onSuggestionSelect={(s) => {
315-
onSuggestionSelectProp?.(s)
307+
onSuggestionSelect?.(s)
316308
setOpen(false)
317309
}}
318310
/>

0 commit comments

Comments
 (0)