Skip to content

Commit ac4bbd8

Browse files
authored
Close dropdowns (#9206)
Fixes #7562. Close a dropdown when: - A click outside the dropdown occurs - `Esc` is pressed - Any other `Interaction` is started (i.e. using a shortcut) # Important Notes - Simplifies `Interaction` API and uses it for closing/canceling CB as well as dropdowns. - Adjusted some event handlers so that handled clicks don't also register as GraphEditor background clicks. - Introduces a CSS approach to prevent unwanted text-selections; we were doing it with JS until my previous PR, and the JS solution was breaking things.
1 parent 964fdfd commit ac4bbd8

File tree

8 files changed

+99
-126
lines changed

8 files changed

+99
-126
lines changed

app/gui2/src/bindings.ts

-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ export const componentBrowserBindings = defineKeybinds('component-browser', {
1313
applySuggestion: ['Tab'],
1414
acceptSuggestion: ['Enter'],
1515
acceptInput: ['Mod+Enter'],
16-
cancelEditing: ['Escape'],
1716
moveUp: ['ArrowUp'],
1817
moveDown: ['ArrowDown'],
1918
})

app/gui2/src/components/ComponentBrowser.vue

+21-20
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ import ToggleIcon from '@/components/ToggleIcon.vue'
1111
import { useApproach } from '@/composables/animation'
1212
import { useEvent, useResizeObserver } from '@/composables/events'
1313
import type { useNavigator } from '@/composables/navigator'
14+
import { injectInteractionHandler, type Interaction } from '@/providers/interactionHandler'
1415
import { useGraphStore } from '@/stores/graph'
1516
import type { RequiredImport } from '@/stores/graph/imports'
1617
import { useProjectStore } from '@/stores/project'
1718
import { groupColorStyle, useSuggestionDbStore } from '@/stores/suggestionDatabase'
1819
import { SuggestionKind, type SuggestionEntry } from '@/stores/suggestionDatabase/entry'
1920
import type { VisualizationDataSource } from '@/stores/visualization'
21+
import { targetIsOutside } from '@/util/autoBlur'
2022
import { tryGetIndex } from '@/util/data/array'
2123
import type { Opt } from '@/util/data/opt'
2224
import { allRanges } from '@/util/data/range'
@@ -35,6 +37,7 @@ const COMPONENT_BROWSER_TO_NODE_OFFSET = new Vec2(-4, -4)
3537
const projectStore = useProjectStore()
3638
const suggestionDbStore = useSuggestionDbStore()
3739
const graphStore = useGraphStore()
40+
const interaction = injectInteractionHandler()
3841
3942
const props = defineProps<{
4043
nodePosition: Vec2
@@ -47,7 +50,24 @@ const emit = defineEmits<{
4750
canceled: []
4851
}>()
4952
53+
const cbOpen: Interaction = {
54+
cancel: () => {
55+
emit('canceled')
56+
},
57+
click: (e: PointerEvent) => {
58+
if (targetIsOutside(e, cbRoot)) {
59+
if (input.anyChange.value) {
60+
acceptInput()
61+
} else {
62+
interaction.cancel(cbOpen)
63+
}
64+
}
65+
return false
66+
},
67+
}
68+
5069
onMounted(() => {
70+
interaction.setCurrent(cbOpen)
5171
input.reset(props.usage)
5272
if (inputField.value != null) {
5373
inputField.value.focus({ preventScroll: true })
@@ -159,23 +179,6 @@ function preventNonInputDefault(e: Event) {
159179
}
160180
}
161181
162-
useEvent(
163-
window,
164-
'pointerdown',
165-
(event) => {
166-
if (event.button !== 0) return
167-
if (!(event.target instanceof Element)) return
168-
if (!cbRoot.value?.contains(event.target)) {
169-
if (input.anyChange.value) {
170-
emit('accepted', input.code.value, input.importsToAdd())
171-
} else {
172-
emit('canceled')
173-
}
174-
}
175-
},
176-
{ capture: true },
177-
)
178-
179182
const inputElement = ref<HTMLElement>()
180183
const inputSize = useResizeObserver(inputElement, false)
181184
@@ -357,6 +360,7 @@ function acceptSuggestion(index: Opt<Component> = null) {
357360
358361
function acceptInput() {
359362
emit('accepted', input.code.value.trim(), input.importsToAdd())
363+
interaction.end(cbOpen)
360364
}
361365
362366
// === Key Events Handler ===
@@ -386,9 +390,6 @@ const handler = componentBrowserBindings.handler({
386390
}
387391
scrolling.scrollWithTransition({ type: 'selected' })
388392
},
389-
cancelEditing() {
390-
emit('canceled')
391-
},
392393
})
393394
</script>
394395

app/gui2/src/components/GraphEditor.vue

+30-85
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { keyboardBusy, keyboardBusyExceptIn, useEvent } from '@/composables/even
2121
import { useStackNavigator } from '@/composables/stackNavigator'
2222
import { provideGraphNavigator } from '@/providers/graphNavigator'
2323
import { provideGraphSelection } from '@/providers/graphSelection'
24-
import { provideInteractionHandler, type Interaction } from '@/providers/interactionHandler'
24+
import { provideInteractionHandler } from '@/providers/interactionHandler'
2525
import { provideWidgetRegistry } from '@/providers/widgetRegistry'
2626
import { useGraphStore, type NodeId } from '@/stores/graph'
2727
import type { RequiredImport } from '@/stores/graph/imports'
@@ -110,7 +110,7 @@ const nodeSelection = provideGraphSelection(graphNavigator, graphStore.nodeRects
110110
111111
const interactionBindingsHandler = interactionBindings.handler({
112112
cancel: () => interaction.handleCancel(),
113-
click: (e) => (e instanceof MouseEvent ? interaction.handleClick(e, graphNavigator) : false),
113+
click: (e) => (e instanceof PointerEvent ? interaction.handleClick(e, graphNavigator) : false),
114114
})
115115
116116
// Return the environment for the placement of a new node. The passed nodes should be the nodes that are
@@ -162,7 +162,8 @@ function sourcePortForSelection() {
162162
}
163163
164164
useEvent(window, 'keydown', (event) => {
165-
;(!keyboardBusy() && (interactionBindingsHandler(event) || graphBindingsHandler(event))) ||
165+
interactionBindingsHandler(event) ||
166+
(!keyboardBusy() && graphBindingsHandler(event)) ||
166167
(!keyboardBusyExceptIn(codeEditorArea.value) && codeEditorHandler(event))
167168
})
168169
useEvent(window, 'pointerdown', interactionBindingsHandler, { capture: true })
@@ -208,7 +209,7 @@ const graphBindingsHandler = graphBindings.handler({
208209
openComponentBrowser() {
209210
if (keyboardBusy()) return false
210211
if (graphNavigator.sceneMousePos != null && !componentBrowserVisible.value) {
211-
interaction.setCurrent(creatingNode)
212+
showComponentBrowser()
212213
}
213214
},
214215
newNode() {
@@ -331,7 +332,6 @@ const { handleClick } = useDoubleClick(
331332
}
332333
},
333334
() => {
334-
if (keyboardBusy()) return false
335335
stackNavigator.exitNode()
336336
},
337337
)
@@ -370,62 +370,25 @@ const groupColors = computed(() => {
370370
return styles
371371
})
372372
373-
const editingNode: Interaction = {
374-
init: () => {
375-
// component browser usage is set in `graphStore.editedNodeInfo` watch
376-
componentBrowserNodePosition.value = targetComponentBrowserNodePosition()
377-
},
378-
cancel: () => {
379-
hideComponentBrowser()
380-
graphStore.editedNodeInfo = undefined
381-
},
382-
}
383-
const nodeIsBeingEdited = computed(() => graphStore.editedNodeInfo != null)
384-
interaction.setWhen(nodeIsBeingEdited, editingNode)
385-
386-
const creatingNode: Interaction = {
387-
init: () => {
388-
componentBrowserUsage.value = { type: 'newNode', sourcePort: sourcePortForSelection() }
389-
componentBrowserNodePosition.value = targetComponentBrowserNodePosition()
390-
componentBrowserVisible.value = true
391-
},
392-
cancel: hideComponentBrowser,
393-
}
394-
395-
const creatingNodeFromButton: Interaction = {
396-
init: () => {
397-
componentBrowserUsage.value = { type: 'newNode', sourcePort: sourcePortForSelection() }
398-
let targetPos = placementPositionForSelection()
399-
if (targetPos == undefined) {
400-
targetPos = nonDictatedPlacement(DEFAULT_NODE_SIZE, placementEnvironment.value).position
401-
}
402-
componentBrowserNodePosition.value = targetPos
403-
componentBrowserVisible.value = true
404-
},
405-
cancel: hideComponentBrowser,
406-
}
407-
408-
const creatingNodeFromPortDoubleClick: Interaction = {
409-
init: () => {
410-
// component browser usage is set in event handler
411-
componentBrowserVisible.value = true
412-
},
413-
cancel: hideComponentBrowser,
373+
function showComponentBrowser(nodePosition?: Vec2, usage?: Usage) {
374+
componentBrowserUsage.value = usage ?? { type: 'newNode', sourcePort: sourcePortForSelection() }
375+
componentBrowserNodePosition.value = nodePosition ?? targetComponentBrowserNodePosition()
376+
componentBrowserVisible.value = true
414377
}
415378
416-
const creatingNodeFromEdgeDrop: Interaction = {
417-
init: () => {
418-
// component browser usage is set in event handler
419-
componentBrowserVisible.value = true
420-
},
421-
cancel: hideComponentBrowser,
379+
function startCreatingNodeFromButton() {
380+
const targetPos =
381+
placementPositionForSelection() ??
382+
nonDictatedPlacement(DEFAULT_NODE_SIZE, placementEnvironment.value).position
383+
showComponentBrowser(targetPos)
422384
}
423385
424386
function hideComponentBrowser() {
387+
graphStore.editedNodeInfo = undefined
425388
componentBrowserVisible.value = false
426389
}
427390
428-
function onComponentBrowserCommit(content: string, requiredImports: RequiredImport[]) {
391+
function commitComponentBrowser(content: string, requiredImports: RequiredImport[]) {
429392
if (content != null) {
430393
if (graphStore.editedNodeInfo) {
431394
// We finish editing a node.
@@ -442,29 +405,21 @@ function onComponentBrowserCommit(content: string, requiredImports: RequiredImpo
442405
if (createdNode) nodeSelection.setSelection(new Set([createdNode]))
443406
}
444407
}
445-
// Finish interaction. This should also hide component browser.
446-
interaction.setCurrent(undefined)
447-
}
448-
449-
function onComponentBrowserCancel() {
450-
// Finish interaction. This should also hide component browser.
451-
interaction.setCurrent(undefined)
408+
hideComponentBrowser()
452409
}
453410
454411
// Watch the `editedNode` in the graph store
455412
watch(
456413
() => graphStore.editedNodeInfo,
457414
(editedInfo) => {
458415
if (editedInfo) {
459-
componentBrowserNodePosition.value = targetComponentBrowserNodePosition()
460-
componentBrowserUsage.value = {
416+
showComponentBrowser(undefined, {
461417
type: 'editNode',
462418
node: editedInfo.id,
463419
cursorPos: editedInfo.initialCursorPos,
464-
}
465-
componentBrowserVisible.value = true
420+
})
466421
} else {
467-
componentBrowserVisible.value = false
422+
hideComponentBrowser()
468423
}
469424
},
470425
)
@@ -609,30 +564,23 @@ async function readNodeFromExcelClipboard(
609564
}
610565
611566
function handleNodeOutputPortDoubleClick(id: AstId) {
612-
componentBrowserUsage.value = { type: 'newNode', sourcePort: id }
613567
const srcNode = graphStore.db.getPatternExpressionNodeId(id)
614568
if (srcNode == null) {
615569
console.error('Impossible happened: Double click on port not belonging to any node: ', id)
616570
return
617571
}
618572
const placementEnvironment = environmentForNodes([srcNode].values())
619-
componentBrowserNodePosition.value = previousNodeDictatedPlacement(
620-
DEFAULT_NODE_SIZE,
621-
placementEnvironment,
622-
{
623-
horizontalGap: gapBetweenNodes,
624-
verticalGap: gapBetweenNodes,
625-
},
626-
).position
627-
interaction.setCurrent(creatingNodeFromPortDoubleClick)
573+
const position = previousNodeDictatedPlacement(DEFAULT_NODE_SIZE, placementEnvironment, {
574+
horizontalGap: gapBetweenNodes,
575+
verticalGap: gapBetweenNodes,
576+
}).position
577+
showComponentBrowser(position, { type: 'newNode', sourcePort: id })
628578
}
629579
630580
const stackNavigator = useStackNavigator()
631581
632582
function handleEdgeDrop(source: AstId, position: Vec2) {
633-
componentBrowserUsage.value = { type: 'newNode', sourcePort: source }
634-
componentBrowserNodePosition.value = position
635-
interaction.setCurrent(creatingNodeFromEdgeDrop)
583+
showComponentBrowser(position, { type: 'newNode', sourcePort: source })
636584
}
637585
</script>
638586

@@ -662,8 +610,8 @@ function handleEdgeDrop(source: AstId, position: Vec2) {
662610
:navigator="graphNavigator"
663611
:nodePosition="componentBrowserNodePosition"
664612
:usage="componentBrowserUsage"
665-
@accepted="onComponentBrowserCommit"
666-
@canceled="onComponentBrowserCancel"
613+
@accepted="commitComponentBrowser"
614+
@canceled="hideComponentBrowser"
667615
/>
668616
<TopBar
669617
v-model:recordMode="projectStore.recordMode"
@@ -679,11 +627,7 @@ function handleEdgeDrop(source: AstId, position: Vec2) {
679627
@zoomIn="graphNavigator.scale *= 1.1"
680628
@zoomOut="graphNavigator.scale *= 0.9"
681629
/>
682-
<PlusButton
683-
@click.stop="interaction.setCurrent(creatingNodeFromButton)"
684-
@pointerdown.stop
685-
@pointerup.stop
686-
/>
630+
<PlusButton @pointerdown.stop @click.stop="startCreatingNodeFromButton()" @pointerup.stop />
687631
<Transition>
688632
<Suspense ref="codeEditorArea">
689633
<CodeEditor v-if="showCodeEditor" />
@@ -698,6 +642,7 @@ function handleEdgeDrop(source: AstId, position: Vec2) {
698642
position: relative;
699643
contain: layout;
700644
overflow: clip;
645+
user-select: none;
701646
--group-color-fallback: #006b8a;
702647
--node-color-no-type: #596b81;
703648
}

app/gui2/src/components/GraphEditor/GraphEdge.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -509,7 +509,7 @@ const connected = computed(() => isConnected(props.edge))
509509
class="edge io"
510510
:data-source-node-id="sourceNode"
511511
:data-target-node-id="targetNode"
512-
@pointerdown="click"
512+
@pointerdown.stop="click"
513513
@pointerenter="hovered = true"
514514
@pointerleave="hovered = false"
515515
/>

app/gui2/src/components/GraphEditor/GraphEdges.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const editingEdge: Interaction = {
2626
cancel() {
2727
graph.clearUnconnected()
2828
},
29-
click(_e: MouseEvent, graphNavigator: GraphNavigator): boolean {
29+
click(_e: PointerEvent, graphNavigator: GraphNavigator): boolean {
3030
if (graph.unconnectedEdge == null) return false
3131
let source: AstId | undefined
3232
let sourceNode: NodeId | undefined

app/gui2/src/components/GraphEditor/widgets/WidgetSelection.vue

+21-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
33
import SvgIcon from '@/components/SvgIcon.vue'
44
import DropdownWidget from '@/components/widgets/DropdownWidget.vue'
5-
import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
5+
import { injectInteractionHandler } from '@/providers/interactionHandler'
6+
import { defineWidget, Score, WidgetInput, widgetProps } from '@/providers/widgetRegistry'
67
import {
78
singleChoiceConfiguration,
89
type ArgumentWidgetConfiguration,
@@ -16,6 +17,7 @@ import {
1617
} from '@/stores/suggestionDatabase/entry.ts'
1718
import { Ast } from '@/util/ast'
1819
import type { TokenId } from '@/util/ast/abstract.ts'
20+
import { targetIsOutside } from '@/util/autoBlur'
1921
import { ArgumentInfoKey } from '@/util/callTree'
2022
import { arrayEquals } from '@/util/data/array'
2123
import { asNot } from '@/util/data/types.ts'
@@ -29,6 +31,8 @@ import { computed, ref, watch } from 'vue'
2931
const props = defineProps(widgetProps(widgetDefinition))
3032
const suggestions = useSuggestionDbStore()
3133
const graph = useGraphStore()
34+
const interaction = injectInteractionHandler()
35+
const widgetRoot = ref<HTMLElement>()
3236
3337
interface Tag {
3438
/** If not set, the label is same as expression */
@@ -119,6 +123,15 @@ const innerWidgetInput = computed(() => {
119123
return { ...props.input, dynamicConfig: singleChoiceConfiguration(config) }
120124
})
121125
const showDropdownWidget = ref(false)
126+
interaction.setWhen(showDropdownWidget, {
127+
cancel: () => {
128+
showDropdownWidget.value = false
129+
},
130+
click: (e: PointerEvent) => {
131+
if (targetIsOutside(e, widgetRoot)) showDropdownWidget.value = false
132+
return false
133+
},
134+
})
122135
123136
function toggleDropdownWidget() {
124137
showDropdownWidget.value = !showDropdownWidget.value
@@ -172,7 +185,13 @@ export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
172185

173186
<template>
174187
<!-- See comment in GraphNode next to dragPointer definition about stopping pointerdown and pointerup -->
175-
<div class="WidgetSelection" @pointerdown.stop @pointerup.stop @click.stop="toggleDropdownWidget">
188+
<div
189+
ref="widgetRoot"
190+
class="WidgetSelection"
191+
@pointerdown.stop
192+
@pointerup.stop
193+
@click.stop="toggleDropdownWidget"
194+
>
176195
<NodeWidget ref="childWidgetRef" :input="innerWidgetInput" />
177196
<SvgIcon name="arrow_right_head_only" class="arrow" />
178197
<DropdownWidget

0 commit comments

Comments
 (0)