From a14a95c057d8278a7e24dc6ff143879f23f58c1e Mon Sep 17 00:00:00 2001 From: Kaz Wesley Date: Fri, 10 May 2024 09:13:59 -0700 Subject: [PATCH] Only Escape cancels edits (#9913) --- README.md | 10 +++---- app/gui2/src/components/ColorRing.vue | 15 +++++----- app/gui2/src/components/ComponentBrowser.vue | 30 ++++++++----------- .../src/components/GraphEditor/GraphEdges.vue | 12 ++++---- .../GraphEditor/GraphNodeComment.vue | 1 + .../GraphEditor/widgets/WidgetSelection.vue | 1 + app/gui2/src/providers/interactionHandler.ts | 25 ++++++++++++---- .../__tests__/editHandler.test.ts | 2 +- .../providers/widgetRegistry/editHandler.ts | 13 ++++---- app/gui2/src/util/autoBlur.ts | 25 +++++++++++++++- 10 files changed, 85 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index de679d88670a..228d56f367fc 100644 --- a/README.md +++ b/README.md @@ -207,11 +207,11 @@ Enso consists of several sub projects: command line tools. - **Enso IDE:** The - [Enso IDE](https://github.com/enso-org/enso/tree/develop/app/gui2) is a desktop - application that allows working with the visual form of Enso. It consists of - an Electron application, a high performance WebGL UI framework, and the - searcher which provides contextual search, hints, and documentation for all of - Enso's functionality. + [Enso IDE](https://github.com/enso-org/enso/tree/develop/app/gui2) is a + desktop application that allows working with the visual form of Enso. It + consists of an Electron application, a high performance WebGL UI framework, + and the searcher which provides contextual search, hints, and documentation + for all of Enso's functionality.
diff --git a/app/gui2/src/components/ColorRing.vue b/app/gui2/src/components/ColorRing.vue index 5e7a87e5c7f9..7494ceebaabd 100644 --- a/app/gui2/src/components/ColorRing.vue +++ b/app/gui2/src/components/ColorRing.vue @@ -5,7 +5,7 @@ import { rangesForInputs, } from '@/components/ColorRing/gradient' import { injectInteractionHandler } from '@/providers/interactionHandler' -import { targetIsOutside } from '@/util/autoBlur' +import { endOnClickOutside } from '@/util/autoBlur' import { cssSupported, ensoColor, formatCssColor, parseCssColor } from '@/util/colors' import { Rect } from '@/util/data/rect' import { Vec2 } from '@/util/data/vec2' @@ -49,13 +49,12 @@ const svgElement = ref() const interaction = injectInteractionHandler() onMounted(() => { - interaction.setCurrent({ - cancel: () => emit('close'), - pointerdown: (e: PointerEvent) => { - if (targetIsOutside(e, svgElement.value)) emit('close') - return false - }, - }) + interaction.setCurrent( + endOnClickOutside(svgElement, { + cancel: () => emit('close'), + end: () => emit('close'), + }), + ) }) const mouseSelectedAngle = ref() diff --git a/app/gui2/src/components/ComponentBrowser.vue b/app/gui2/src/components/ComponentBrowser.vue index 537837053414..7472c3755089 100644 --- a/app/gui2/src/components/ComponentBrowser.vue +++ b/app/gui2/src/components/ComponentBrowser.vue @@ -18,7 +18,7 @@ import { useProjectStore } from '@/stores/project' import { groupColorStyle, useSuggestionDbStore } from '@/stores/suggestionDatabase' import { SuggestionKind } from '@/stores/suggestionDatabase/entry' import type { VisualizationDataSource } from '@/stores/visualization' -import { targetIsOutside } from '@/util/autoBlur' +import { endOnClickOutside } from '@/util/autoBlur' import { tryGetIndex } from '@/util/data/array' import type { Opt } from '@/util/data/opt' import { allRanges } from '@/util/data/range' @@ -63,22 +63,19 @@ const emit = defineEmits<{ canceled: [] }>() -const cbOpen: Interaction = { - cancel: () => { - emit('canceled') - }, - pointerdown: (e: PointerEvent) => { - if (targetIsOutside(e, cbRoot.value)) { - // In AI prompt mode likely the input is not a valid mode. - if (input.anyChange.value && input.context.value.type !== 'aiPrompt') { - acceptInput() - } else { - interaction.cancel(cbOpen) - } +const cbRoot = ref() + +const cbOpen: Interaction = endOnClickOutside(cbRoot, { + cancel: () => emit('canceled'), + end: () => { + // In AI prompt mode likely the input is not a valid mode. + if (input.anyChange.value && input.context.value.type !== 'aiPrompt') { + acceptInput() + } else { + emit('canceled') } - return false }, -} +}) function scaleValues>( values: T, @@ -141,7 +138,6 @@ const transform = computed(() => { // === Input and Filtering === -const cbRoot = ref() const inputField = ref() const input = useComponentBrowserInput() const filterFlags = ref({ showUnstable: false, showLocal: false }) @@ -413,7 +409,7 @@ function acceptSuggestion(component: Opt = null) { function acceptInput() { emit('accepted', input.code.value.trim(), input.importsToAdd()) - interaction.end(cbOpen) + interaction.ended(cbOpen) } // === Key Events Handler === diff --git a/app/gui2/src/components/GraphEditor/GraphEdges.vue b/app/gui2/src/components/GraphEditor/GraphEdges.vue index 473e3674e8fc..03d1b5b7519f 100644 --- a/app/gui2/src/components/GraphEditor/GraphEdges.vue +++ b/app/gui2/src/components/GraphEditor/GraphEdges.vue @@ -25,13 +25,11 @@ const emits = defineEmits<{ const MIN_DRAG_MOVE = 10 const editingEdge: Interaction = { - cancel() { - graph.clearUnconnected() - }, - pointerdown(_e: PointerEvent, graphNavigator: GraphNavigator): boolean { - return edgeInteractionClick(graphNavigator) - }, - pointerup(e: PointerEvent, graphNavigator: GraphNavigator): boolean { + cancel: () => graph.clearUnconnected(), + end: () => graph.clearUnconnected(), + pointerdown: (_e: PointerEvent, graphNavigator: GraphNavigator) => + edgeInteractionClick(graphNavigator), + pointerup: (e: PointerEvent, graphNavigator: GraphNavigator) => { const originEvent = graph.unconnectedEdge?.event if (originEvent?.type === 'pointerdown') { const delta = new Vec2(e.screenX, e.screenY).sub( diff --git a/app/gui2/src/components/GraphEditor/GraphNodeComment.vue b/app/gui2/src/components/GraphEditor/GraphNodeComment.vue index b5d12b915370..b132138400e8 100644 --- a/app/gui2/src/components/GraphEditor/GraphNodeComment.vue +++ b/app/gui2/src/components/GraphEditor/GraphNodeComment.vue @@ -26,6 +26,7 @@ const editor = ref() const interactions = injectInteractionHandler() const editInteraction = { cancel: () => finishEdit(), + end: () => finishEdit(), click: (e: Event) => { if (e.target instanceof Element && !commentRoot.value?.contains(e.target)) finishEdit() return false diff --git a/app/gui2/src/components/GraphEditor/widgets/WidgetSelection.vue b/app/gui2/src/components/GraphEditor/widgets/WidgetSelection.vue index 7ace3d8d809f..610d496b1059 100644 --- a/app/gui2/src/components/GraphEditor/widgets/WidgetSelection.vue +++ b/app/gui2/src/components/GraphEditor/widgets/WidgetSelection.vue @@ -226,6 +226,7 @@ provideSelectionArrow( const isMulti = computed(() => props.input.dynamicConfig?.kind === 'Multiple_Choice') const dropDownInteraction = WidgetEditHandler.New('WidgetSelection', props.input, { cancel: () => {}, + end: () => {}, pointerdown: (e, _) => { if (targetIsOutside(e, unrefElement(dropdownElement))) { dropDownInteraction.end() diff --git a/app/gui2/src/providers/interactionHandler.ts b/app/gui2/src/providers/interactionHandler.ts index c4a70718e030..db549321f1bb 100644 --- a/app/gui2/src/providers/interactionHandler.ts +++ b/app/gui2/src/providers/interactionHandler.ts @@ -28,7 +28,7 @@ export class InteractionHandler { setCurrent(interaction: Interaction | undefined) { if (!this.isActive(interaction)) { - this.currentInteraction?.cancel?.() + this.currentInteraction?.end() this.currentInteraction = interaction } } @@ -37,19 +37,31 @@ export class InteractionHandler { return this.currentInteraction } - /** Unset the current interaction, if it is the specified instance. */ - end(interaction: Interaction) { + /** Clear the current interaction without calling any callback, if the current interaction is `interaction`. */ + ended(interaction: Interaction) { if (this.isActive(interaction)) this.currentInteraction = undefined } + /** End the current interaction, if it is the specified instance. */ + end(interaction: Interaction) { + if (this.isActive(interaction)) { + this.currentInteraction = undefined + interaction.end() + } + } + /** Cancel the current interaction, if it is the specified instance. */ cancel(interaction: Interaction) { - if (this.isActive(interaction)) this.setCurrent(undefined) + if (this.isActive(interaction)) { + this.currentInteraction = undefined + interaction.cancel() + } } handleCancel(): boolean { const hasCurrent = this.currentInteraction != null - if (hasCurrent) this.setCurrent(undefined) + this.currentInteraction?.cancel() + this.currentInteraction = undefined return hasCurrent } @@ -74,7 +86,10 @@ export class InteractionHandler { type InteractionEventHandler = (event: PointerEvent, navigator: GraphNavigator) => boolean | void export interface Interaction { + /** Called when the interaction is explicitly canceled, e.g. with the `Esc` key. */ cancel(): void + /** Called when the interaction is ended due to activity elsewhere. */ + end(): void /** Uses a `capture` event handler to allow an interaction to respond to clicks over any element. */ pointerdown?: InteractionEventHandler /** Uses a `capture` event handler to allow an interaction to respond to mouse button release diff --git a/app/gui2/src/providers/widgetRegistry/__tests__/editHandler.test.ts b/app/gui2/src/providers/widgetRegistry/__tests__/editHandler.test.ts index 62702d5a6fb7..51d5bfb9fd7f 100644 --- a/app/gui2/src/providers/widgetRegistry/__tests__/editHandler.test.ts +++ b/app/gui2/src/providers/widgetRegistry/__tests__/editHandler.test.ts @@ -112,7 +112,7 @@ test.each` expect(editedHandler.handler.isActive()).toBeTruthy() interactionHandler.setCurrent(undefined) expect(widgetTree.currentEdit).toBeUndefined() - checkCallbackCall('cancel') + checkCallbackCall('end', undefined) expect(editedHandler.handler.isActive()).toBeFalsy() }, ) diff --git a/app/gui2/src/providers/widgetRegistry/editHandler.ts b/app/gui2/src/providers/widgetRegistry/editHandler.ts index 09d5d34220c6..c2f21a52daae 100644 --- a/app/gui2/src/providers/widgetRegistry/editHandler.ts +++ b/app/gui2/src/providers/widgetRegistry/editHandler.ts @@ -66,7 +66,7 @@ export class WidgetEditHandler { noLongerActive() hooks.cancel?.() }, - end: (origin: WidgetId) => { + end: (origin?: WidgetId) => { noLongerActive() hooks.end?.(origin) }, @@ -151,7 +151,7 @@ export interface WidgetEditHooks extends Interaction { * {@link WidgetEditHandler} being called, or because a child is to be started. */ start?(origin: WidgetId): void - end?(origin: WidgetId): void + end(origin?: WidgetId | undefined): void /** * Hook called when a child widget, or this widget itself, provides an updated value. */ @@ -223,7 +223,7 @@ class PortEditInteraction implements Interaction { this.shutdown() } - end(origin: WidgetId) { + end(origin?: WidgetId) { for (const interaction of this.interactions) interaction.end?.(origin) this.shutdown() } @@ -231,7 +231,7 @@ class PortEditInteraction implements Interaction { private shutdown() { this.interactions.length = 0 this.active.value = false - this.interactionHandler.end(this) + this.interactionHandler.ended(this) } register(interaction: PortEditSubinteraction) { @@ -269,6 +269,8 @@ class SuspendedPortEdit implements Interaction { } cancel() {} + + end() {} } /** A sub-interaction of a @{link PortEditInteraction} */ @@ -276,7 +278,8 @@ interface PortEditSubinteraction extends Interaction { widgetId: WidgetId suspend?: () => { resume: () => void } - end?(origin: WidgetId): void + + end(origin?: WidgetId | undefined): void } /** @internal Public for unit testing. diff --git a/app/gui2/src/util/autoBlur.ts b/app/gui2/src/util/autoBlur.ts index 499c462e3f54..4b11573497c1 100644 --- a/app/gui2/src/util/autoBlur.ts +++ b/app/gui2/src/util/autoBlur.ts @@ -1,4 +1,6 @@ -import { useEvent } from '@/composables/events' +import { unrefElement, useEvent } from '@/composables/events' +import { injectInteractionHandler, type Interaction } from '@/providers/interactionHandler' +import type { VueInstance } from '@vueuse/core' import type { Opt } from 'shared/util/data/opt' import { watchEffect, type Ref } from 'vue' @@ -45,3 +47,24 @@ export function registerAutoBlurHandler() { export function targetIsOutside(e: Event, area: Opt): boolean { return !!area && e.target instanceof Element && !area.contains(e.target) } + +/** Returns a new interaction based on the given `interaction`. The new interaction will be ended if a pointerdown event + * occurs outside the given `area` element. */ +export function endOnClickOutside( + area: Ref, + interaction: Interaction, +): Interaction { + const chainedPointerdown = interaction.pointerdown + const handler = injectInteractionHandler() + const wrappedInteraction: Interaction = { + ...interaction, + pointerdown: (e: PointerEvent, ...args) => { + if (targetIsOutside(e, unrefElement(area))) { + handler.end(wrappedInteraction) + return false + } + return chainedPointerdown ? chainedPointerdown(e, ...args) : false + }, + } + return wrappedInteraction +}