Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import CodeMirrorRoot from '@/components/CodeMirrorRoot.vue'
import VueHostRender, { VueHostInstance } from '@/components/VueHostRender.vue'
import { Ast } from '@/util/ast'
import { targetIsOutside } from '@/util/autoBlur'
import { selectOnMouseFocus, useCodeMirror, useStringSync } from '@/util/codemirror'
import { selectAllOnMouseFocus, useCodeMirror, useStringSync } from '@/util/codemirror'
import { highlightStyle } from '@/util/codemirror/highlight'
import { useToast } from '@/util/toast'
import { SelectionRange, type Extension } from '@codemirror/state'
import { type Extension } from '@codemirror/state'
import { Ok } from 'enso-common/src/utilities/data/result'
import { ref, useTemplateRef, watch, type ComponentInstance } from 'vue'

Expand All @@ -37,7 +37,6 @@ const props = defineProps<{
const model = defineModel<string>({ default: '' })
const emit = defineEmits<{
textEdited: [text: string]
userAction: [text: string, selection: SelectionRange]
blur: []
}>()

Expand All @@ -48,7 +47,6 @@ const { syncExt, getText, setText } = useStringSync({
editing.value.edit(props.transformUserInput?.(text) ?? text)
emit('textEdited', text)
},
onUserAction: (text, selection) => emit('userAction', text, selection),
})
const vueHost = new VueHostInstance()
const { editorView } = useCodeMirror(editorRoot, {
Expand All @@ -57,7 +55,7 @@ const { editorView } = useCodeMirror(editorRoot, {
syncExt,
() => (editorRoot.value ? highlightStyle(editorRoot.value.highlightClasses) : []),
() =>
props.lineMode !== 'multi' && props.lineMode !== 'autoMulti' ? [selectOnMouseFocus] : [],
props.lineMode !== 'multi' && props.lineMode !== 'autoMulti' ? [selectAllOnMouseFocus] : [],
() => props.extensions ?? [],
],
readonly: false,
Expand Down Expand Up @@ -91,6 +89,9 @@ const editing = WidgetEditHandler.New(props, {
})

function blurEditor() {
// Work around an apparent browser bug: When the selection is changed after the editor is blurred, the old selection
// continues to be rendered.
editorView.dispatch({ selection: { anchor: 0 } })
editorView.contentDOM.blur()
emit('blur')
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ const { editorView, setExtraExtensions } = useCodeMirror(editorRoot, {
lineMode: 'multi',
contentTestId,
scrollerTestId,
// Using `useEditorFocus` instead.
disableDeselectOnBlur: true,
})

useLinkTitles(editorView, { readonly: () => readonly })
Expand Down
4 changes: 2 additions & 2 deletions app/gui/src/project-view/components/NavBreadcrumb.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import CodeMirrorRoot from '@/components/CodeMirrorRoot.vue'
import { selectOnMouseFocus, useCodeMirror, useStringSync } from '@/util/codemirror'
import { selectAllOnMouseFocus, useCodeMirror, useStringSync } from '@/util/codemirror'
import { useTemplateRef, watch } from 'vue'

const model = defineModel<string>({ required: true })
Expand All @@ -10,7 +10,7 @@ const editorRoot = useTemplateRef('editorRoot')

const { syncExt, getText, setText } = useStringSync()
const { editorView } = useCodeMirror(editorRoot, {
extensions: [syncExt, selectOnMouseFocus],
extensions: [syncExt, selectAllOnMouseFocus],
readonly: false,
lineMode: 'single',
})
Expand Down
83 changes: 40 additions & 43 deletions app/gui/src/project-view/util/codemirror/contentFocusedExt.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,52 @@
import { valueExt } from '@/util/codemirror/stateEffect'
import type { Extension } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { createDebouncer } from 'lib0/eventloop'

/**
* A CodeMirror extension enabling other extensions to respond to whether the editor content is
* focused.
*
* The state field is updated asynchronously based on DOM focusin/focusout events. Although
* dispatching the updates synchronously would provide a more predictable order of event handlers,
* this would cause problems because the `mousedown` handler defined in CodeMirror's
* `MouseSelection` can focus the editor explicitly *before* running the rest of the mouse selection
* logic. This results in an inversion of the usual order of events: The `focusin` handler is run
* before the `mousedown` handler finishes, so that a transaction with the `pointer.select`
* user-event attribute may be received after the `focusin` event that it caused, making it
* impossible to tell whether the element was already focused when the selection was changed. By
* dispatching the transaction asynchronously, it is handled after the `mousedown` event even if the
* `mousedown` handler explicitly focuses the element.
*
* This extension is similar to {@link EditorView.focusChangeEffect}, but more reliable. In some
* cases `focusChangeEffect` it can skip emitting a {@link StateEffect}: When focus changes,
* `EditorView.update` creates a transaction applying any `StateEffect`s produced by the facet; it
* schedules this transaction to be dispatched asynchronously. If there are any intervening
* transactions, the transaction's `startState` doesn't match the current state, and can't be
* applied; in that case the implementation silently drops it.
*/
export function contentFocusedExt(): Extension {
return extInstance
}

const {
set: setContentFocused,
get: contentFocused,
changed: contentFocusedChanged,
extension: valueExtension,
} = valueExt<boolean>(false)

export { contentFocused, contentFocusedChanged, setContentFocused }

/**
* A CodeMirror extension enabling other extensions to respond to whether the editor content is
* focused.
*
* The state field is updated asynchronously based on DOM focusin/focusout events.
*/
export function contentFocusedExt() {
// It might be preferable to dispatch the updates synchronously, for a more predictable order of
// event handlers; however, this would cause problems because the `mousedown` handler defined in
// CodeMirror's `MouseSelection` can focus the editor explicitly *before* running the rest of the
// mouse selection logic. This results in an inversion of the usual order of events: The `focusin`
// handler is run before the `mousedown` handler finishes, so that a transaction with the
// `pointer.select` user-event attribute may be received after the `focusin` event that it caused,
// making it impossible to tell whether the element was already focused when the selection was
// changed. By dispatching the transaction asynchronously, it is handled after the `mousedown`
// event even if the `mousedown` handler explicitly focuses the element.
const debounce = createDebouncer(0)
let focused = false
function observeFocus(view: EditorView) {
if (view.state.field(contentFocused) === focused) return
view.dispatch({ effects: setContentFocused.of(focused) })
}
return [
valueExtension,
// `EditorView.focusChangeEffect` serves a similar purpose, but I found it unsuitable, as in
// some cases it can skip emitting a `StateEffect`: When focus changes, `EditorView.update`
// creates a transaction applying any `StateEffect`s produced by the facet; it schedules this
// transaction to be dispatched asynchronously. If there are any intervening transactions, the
// transaction's `startState` doesn't match the current state, and can't be applied; in that
// case the implementation silently drops it.
EditorView.domEventObservers({
focusin: (_event, view) => {
focused = true
debounce(() => observeFocus(view))
},
focusout: (_event, view) => {
focused = false
debounce(() => observeFocus(view))
},
}),
]
function observeFocus(view: EditorView, focused: boolean) {
view.dispatch({ effects: setContentFocused.of(focused) })
}
const extInstance: Extension = [
valueExtension,
EditorView.domEventObservers({
focusin: (_event, view) => {
setTimeout(() => observeFocus(view, true))
},
focusout: (_event, view) => {
setTimeout(() => observeFocus(view, false))
},
}),
]
24 changes: 21 additions & 3 deletions app/gui/src/project-view/util/codemirror/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ interface CodeMirrorOptions {
scrollerTestId?: string | undefined
readonly?: ToValue<boolean>
lineMode: ToValue<LineMode>
/**
* If not set to `true`, an extension will be used which causes the selection range to be
* collapsed to a cursor when the editor is defocused, so that the selection isn't rendered.
* This behavior is probably desirable for all text controls but can be disabled in case an
* editor uses a more specialized extension.
*/
disableDeselectOnBlur?: boolean
}

/**
Expand All @@ -97,6 +104,7 @@ export function useCodeMirror(
scrollerTestId,
readonly,
lineMode,
disableDeselectOnBlur,
}: CodeMirrorOptions,
) {
const dispatch = { dispatch: (...specs: TransactionSpec[]) => view.dispatch(...specs) }
Expand Down Expand Up @@ -151,6 +159,7 @@ export function useCodeMirror(
reactiveExtensions,
tooltipsConfigExt,
vueHost ? vueHostExt : NULL_EXTENSION,
disableDeselectOnBlur ? NULL_EXTENSION : deselectOnBlur,
],
}),
}),
Expand Down Expand Up @@ -260,7 +269,7 @@ export function useStringSync({ onTextEdited, onUserAction }: StringSyncOptions
if (userAction) {
const text = update.state.doc.toString()
if (onUserAction) onUserAction(text, update.state.selection.main)
if (onTextEdited) onTextEdited(text)
if (onTextEdited && textEdit) onTextEdited(text)
}
}),
getText: (view: EditorView): string => {
Expand Down Expand Up @@ -376,11 +385,18 @@ function theme({ singleLine }: { singleLine?: boolean | undefined } = {}): Exten
return [baseTheme, singleLine ? inlineTheme : multilineTheme]
}

export const selectOnMouseFocus = [
export const selectAllOnMouseFocus = [
contentFocusedExt(),
EditorState.transactionFilter.of((tr) => {
if (tr.isUserEvent('select.pointer') && tr.startState.field(contentFocused) === false)
return { selection: { anchor: 0, head: tr.startState.doc.length } }
return [tr, { selection: { anchor: 0, head: tr.startState.doc.length } }]
return tr
}),
]

const deselectOnBlur = [
contentFocusedExt(),
EditorState.transactionFilter.of((tr) => {
if (lastEffect(tr.effects, setContentFocused) === false)
return [tr, { selection: { anchor: 0 } }]
return tr
Expand Down Expand Up @@ -419,6 +435,8 @@ export function putTextAtCoords(view: EditorView, text: string, coords: Vec2) {
* exhibits some hysteresis: When the scrollbar is clicked, the computed focus state doesn't change.
* Thus, this should be used in lieu of the element's focus when the rendering of the editor's
* content is focus-dependent in a way that may affect its size.
* TODO (someday): This behavior could included in {@link useCodeMirror} and used for all editors,
* but it should be refactored to fit the {@link Extension} API.
*/
export function useEditorFocus(view: EditorView) {
const focused = ref(false)
Expand Down
Loading