Skip to content

Commit

Permalink
fix(onebox): Do not focus editor when inserting/updating search resul…
Browse files Browse the repository at this point in the history
…ts context

Changing the focus behavior was more difficult than expected. Apparently
when Lexical has a Selection then it will 'steal' the focus whenever
changes to its content is made.

There have been made changes to Lexical just recently that would allow
us to avoid this (facebook/lexical#6894) but we
haven't updated to this version yet and updating this closely before the
EAP seems risky.

I found a workaround in one of the discussions that suggests to set the
selection to `null` when making a change.

In order to do this I refactored some of the function to make them more
composable. Instead of calling `editor.update` directly these functions
are now supposed to be called from `editor.update`.

Since I've added a helper for creating a promise-version of
`editor.update` anyway, I also addressed one of the issues I've
encountered in the last PR, namely that `onUpdate` is not called when
the editor state doesn't change.
This will btw also be fixed on the Lexical side by the above mentioned PR.
  • Loading branch information
fkling committed Dec 17, 2024
1 parent ab5164b commit 9db807b
Show file tree
Hide file tree
Showing 3 changed files with 206 additions and 137 deletions.
257 changes: 131 additions & 126 deletions lib/prompt-editor/src/PromptEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { $insertFirst } from '@lexical/utils'
import {
type ContextItem,
type SerializedContextItem,
Expand All @@ -10,22 +9,28 @@ import {
} from '@sourcegraph/cody-shared'
import { clsx } from 'clsx'
import {
$createParagraphNode,
$createTextNode,
$getRoot,
$getSelection,
$insertNodes,
$setSelection,
type LexicalEditor,
} from 'lexical'
import type { EditorState, RootNode, SerializedEditorState, SerializedLexicalNode } from 'lexical'
import type { EditorState, SerializedEditorState, SerializedLexicalNode } from 'lexical'
import isEqual from 'lodash/isEqual'
import { type FunctionComponent, useCallback, useEffect, useImperativeHandle, useRef } from 'react'
import { BaseEditor } from './BaseEditor'
import styles from './PromptEditor.module.css'
import { useSetGlobalPromptEditorConfig } from './config'
import { isEditorContentOnlyInitialContext, lexicalNodesForContextItems } from './initialContext'
import { $selectEnd, getContextItemsForEditor, visitContextItemsForEditor } from './lexicalUtils'
import {
$insertMentions,
$selectEnd,
getContextItemsForEditor,
getWhitespace,
update,
walkContextItemMentionNodes,
} from './lexicalUtils'
import { $createContextItemMentionNode } from './nodes/ContextItemMentionNode'
import type { KeyboardEventPluginProps } from './plugins/keyboardEvent'

Expand Down Expand Up @@ -55,8 +60,18 @@ export interface PromptEditorRefAPI {
/**
* Similar to `addMentions`, but unlike `addMentions` it doesn't merge mentions with overlapping
* ranges. Instead it updates the meta data of existing mentions with the same uri.
*
* @param items The context items to add or update.
* @param position Where to insert the mentions, before or after the current input. Defaults to 'after'.
* @param sep The separator to use between mentions. Defaults to a space.
* @param focusEditor Whether to focus the editor after updating the mentions. Defaults to true.
*/
upsertMentions(items: ContextItem[], position?: 'before' | 'after', sep?: string): Promise<void>
upsertMentions(
items: ContextItem[],
position?: 'before' | 'after',
sep?: string,
focusEditor?: boolean
): Promise<void>
filterMentions(filter: (item: SerializedContextItem) => boolean): Promise<void>
setInitialContextMentions(items: ContextItem[]): Promise<void>
setEditorState(state: SerializedPromptEditorState): void
Expand Down Expand Up @@ -141,30 +156,31 @@ export const PromptEditor: FunctionComponent<Props> = ({
})
},
appendText(text: string): Promise<void> {
return new Promise(resolve =>
editorRef.current?.update(
() => {
const root = $getRoot()
root.selectEnd()
$insertNodes([$createTextNode(`${getWhitespace(root)}${text}`)])
root.selectEnd()
},
{ onUpdate: resolve }
)
)
if (!editorRef.current) {
return Promise.resolve()
}
return update(editorRef.current, () => {
const root = $getRoot()
root.selectEnd()
$insertNodes([$createTextNode(`${getWhitespace(root)}${text}`)])
root.selectEnd()
return true
})
},
filterMentions(filter: (item: SerializedContextItem) => boolean): Promise<void> {
return new Promise(resolve => {
if (!editorRef.current) {
resolve()
return
}
if (!editorRef.current) {
return Promise.resolve()
}

visitContextItemsForEditor(editorRef.current, node => {
return update(editorRef.current, () => {
let updated = false
walkContextItemMentionNodes($getRoot(), node => {
if (!filter(node.contextItem)) {
node.remove()
updated = true
}
}).then(resolve)
})
return updated
})
},
async addMentions(
Expand All @@ -181,8 +197,12 @@ export const PromptEditor: FunctionComponent<Props> = ({
const existingMentions = getContextItemsForEditor(editor)
const ops = getMentionOperations(existingMentions, newContextItems)

if (ops.modify.size + ops.delete.size > 0) {
await visitContextItemsForEditor(editor, existing => {
await update(editor, () => {
if (ops.modify.size + ops.delete.size === 0) {
return false
}

walkContextItemMentionNodes($getRoot(), existing => {
const update = ops.modify.get(existing.contextItem)
if (update) {
// replace the existing mention inline with the new one
Expand All @@ -192,15 +212,25 @@ export const PromptEditor: FunctionComponent<Props> = ({
existing.remove()
}
})
}
return true
})

if (ops.create.length === 0) {
return
}
return update(editor, () => {
if (ops.create.length === 0) {
return false
}

return insertMentions(editor, ops.create, position, sep)
$insertMentions(ops.create, position, sep)
$selectEnd()
return true
})
},
async upsertMentions(items, position = 'after', sep = ' '): Promise<void> {
async upsertMentions(
items,
position = 'after',
sep = ' ',
focusEditor = true
): Promise<void> {
const editor = editorRef.current
if (!editor) {
return
Expand All @@ -217,63 +247,85 @@ export const PromptEditor: FunctionComponent<Props> = ({
}
}

// ! visitContextItemsForEditor must only be called when we have changes to make. Otherwise
// the promise it returns will never resolve.
if (toUpdate.size > 0) {
await visitContextItemsForEditor(editor, existing => {
await update(editor, () => {
if (toUpdate.size === 0) {
return false
}

walkContextItemMentionNodes($getRoot(), existing => {
const update = toUpdate.get(getKeyForContextItem(existing.contextItem))
if (update) {
// replace the existing mention inline with the new one
existing.replace($createContextItemMentionNode(update))
}
})
}
if (items.length === toUpdate.size) {
return
}

return insertMentions(
editor,
items.filter(item => !toUpdate.has(getKeyForContextItem(item))),
position,
sep
)
if (focusEditor) {
$selectEnd()
} else {
// Workaround for preventing the editor from stealing focus
// (https://github.com/facebook/lexical/issues/2636#issuecomment-1184418601)
// We need this until we can use the new 'skip-dom-selection' tag as
// explained in https://lexical.dev/docs/concepts/selection#focus, introduced
// by https://github.com/facebook/lexical/pull/6894
$setSelection(null)
}
return true
})
return update(editor, () => {
if (items.length === toUpdate.size) {
return false
}
$insertMentions(
items.filter(item => !toUpdate.has(getKeyForContextItem(item))),
position,
sep
)
if (focusEditor) {
$selectEnd()
} else {
// Workaround for preventing the editor from stealing focus
// (https://github.com/facebook/lexical/issues/2636#issuecomment-1184418601)
// We need this until we can use the new 'skip-dom-selection' tag as
// explained in https://lexical.dev/docs/concepts/selection#focus, introduced
// by https://github.com/facebook/lexical/pull/6894
$setSelection(null)
}
return true
})
},
setInitialContextMentions(items: ContextItem[]): Promise<void> {
return new Promise(resolve => {
const editor = editorRef.current
if (!editor) {
return resolve()
const editor = editorRef.current
if (!editor) {
return Promise.resolve()
}

return update(editor, () => {
let updated = false

if (!hasSetInitialContext.current || isEditorContentOnlyInitialContext(editor)) {
if (isEditorContentOnlyInitialContext(editor)) {
// Only clear in this case so that we don't clobber any text that was
// inserted before initial context was received.
$getRoot().clear()
updated = true
}
const nodesToInsert = lexicalNodesForContextItems(items, {
isFromInitialContext: true,
})

// Add whitespace after initial context items chips
if (items.length > 0) {
nodesToInsert.push($createTextNode(' '))
updated = true
}

$setSelection($getRoot().selectStart()) // insert at start
$insertNodes(nodesToInsert)
$selectEnd()
hasSetInitialContext.current = true
}

editor.update(
() => {
if (
!hasSetInitialContext.current ||
isEditorContentOnlyInitialContext(editor)
) {
if (isEditorContentOnlyInitialContext(editor)) {
// Only clear in this case so that we don't clobber any text that was
// inserted before initial context was received.
$getRoot().clear()
}
const nodesToInsert = lexicalNodesForContextItems(items, {
isFromInitialContext: true,
})

// Add whitespace after initial context items chips
if (items.length > 0) {
nodesToInsert.push($createTextNode(' '))
}

$setSelection($getRoot().selectStart()) // insert at start
$insertNodes(nodesToInsert)
$selectEnd()
hasSetInitialContext.current = true
}
},
{ onUpdate: resolve }
)
return updated
})
},
}),
Expand All @@ -283,7 +335,7 @@ export const PromptEditor: FunctionComponent<Props> = ({
useSetGlobalPromptEditorConfig()

const onBaseEditorChange = useCallback(
(_editorState: EditorState, editor: LexicalEditor, tags: Set<string>): void => {
(_editorState: EditorState, editor: LexicalEditor): void => {
if (onChange) {
onChange(toSerializedPromptEditorValue(editor))
}
Expand Down Expand Up @@ -333,53 +385,6 @@ function normalizeEditorStateJSON(
return JSON.parse(JSON.stringify(value))
}

function getWhitespace(root: RootNode): string {
const needsWhitespaceBefore = !/(^|\s)$/.test(root.getTextContent())
return needsWhitespaceBefore ? ' ' : ''
}

function insertMentions(
editor: LexicalEditor,
items: (SerializedContextItem | ContextItem)[],
position: 'before' | 'after',
sep?: string
): Promise<void> {
return new Promise(resolve =>
editor.update(
() => {
const nodesToInsert = lexicalNodesForContextItems(
items,
{
isFromInitialContext: false,
},
sep
)
const pNode = $createParagraphNode()

switch (position) {
case 'before': {
pNode.append(...nodesToInsert)
$insertFirst($getRoot(), pNode)
break
}
case 'after': {
pNode.append(
$createTextNode(getWhitespace($getRoot())),
...nodesToInsert,
$createTextNode(sep)
)
$insertNodes([pNode])
break
}
}

$selectEnd()
},
{ onUpdate: resolve }
)
)
}

/**
* Computes a unique key for a context item that can be used in e.g. a Map.
*
Expand Down
Loading

0 comments on commit 9db807b

Please sign in to comment.