Inhibit clipping when dropdown is opened (#9434)

Fixes #9379.

# Important Notes
- The existence-registry could be implemented with a counter, but a set is more debuggable and the performance cost is negligible.
This commit is contained in:
Kaz Wesley 2024-03-17 11:54:30 -04:00 committed by GitHub
parent 9a9eff1aa6
commit babf4eba03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 72 additions and 3 deletions

View File

@ -7,7 +7,7 @@ import { provideWidgetTree } from '@/providers/widgetTree'
import { useGraphStore, type NodeId } from '@/stores/graph'
import { Ast } from '@/util/ast'
import type { Icon } from '@/util/iconName'
import { computed, toRef } from 'vue'
import { computed, ref, toRef } from 'vue'
const props = defineProps<{
ast: Ast.Ast
@ -65,6 +65,26 @@ function handleWidgetUpdates(update: WidgetUpdate) {
return true
}
/**
* We have two goals for our DOM/CSS that are somewhat in conflict:
* - We position widget dialogs drawn outside the widget, like dropdowns, relative to their parents. If we teleported
* them, we'd have to maintain their positions through JS; the focus hierarchy would also be affected.
* - We animate showing/hiding conditionally-visible placeholder arguments; the implementation of this animation
* requires the use of `overflow-x: clip` for the placeholder argument. There doesn't seem to be any good alternative
* to clipping in order to achieve a suitable style of animation with CSS.
* Because clipping is absolute (there is no way for an element to draw outside its clipped ancestor), it is hard to
* reconcile with dropdowns.
*
* However, we can have our cake and eat it to--as long as we don't need both at once. The solution implemented here is
* for the widget tree to provide an interface for a widget to signal that it is in a state requiring drawing outside
* the node, and for widgets implementing clipping-based animations to mark them with a CSS class.
*
* This is not a perfect solution; it's possible for the user to cause a dropdown to be displayed before the
* showing-placeholders animation finishes. In that case the animation will run without clipping, which looks a little
* off. However, it allows us to use the DOM/CSS both for positioning the dropdown and animating the placeholders.
*/
const deepDisableClipping = ref(false)
const layoutTransitions = useTransitioning(observedLayoutTransitions)
provideWidgetTree(
toRef(props, 'ast'),
@ -77,6 +97,7 @@ provideWidgetTree(
() => {
emit('openFullMenu')
},
(clippingInhibitorsExist) => (deepDisableClipping.value = clippingInhibitorsExist),
)
</script>
<script lang="ts">
@ -86,7 +107,12 @@ export const ICON_WIDTH = 16
</script>
<template>
<div class="NodeWidgetTree" spellcheck="false" v-on="layoutTransitions.events">
<div
class="NodeWidgetTree"
:class="{ deepDisableClipping }"
spellcheck="false"
v-on="layoutTransitions.events"
>
<!-- Display an icon for the node if no widget in the tree provides one. -->
<SvgIcon
v-if="!props.connectedSelfArgumentId"
@ -128,4 +154,8 @@ export const ICON_WIDTH = 16
color: white;
margin: 0 v-bind('GRAB_HANDLE_X_MARGIN_PX');
}
.deepDisableClipping :deep(.overridableClipState) {
overflow: visible !important;
}
</style>

View File

@ -48,7 +48,10 @@ export const widgetDefinition = defineWidget(ArgumentApplicationKey, {
<NodeWidget :input="WidgetInput.FromAst(application.infixOperator)" />
</div>
<Transition name="collapse-argument">
<div v-if="tree.extended || !application.argument.hideByDefault" class="argument">
<div
v-if="tree.extended || !application.argument.hideByDefault"
class="argument overridableClipState"
>
<NodeWidget :input="application.argument.toWidgetInput()" nest />
</div>
</Transition>

View File

@ -9,6 +9,7 @@ import {
type ArgumentWidgetConfiguration,
} from '@/providers/widgetRegistry/configuration'
import { WidgetEditHandler } from '@/providers/widgetRegistry/editHandler'
import { injectWidgetTree } from '@/providers/widgetTree.ts'
import { useGraphStore } from '@/stores/graph'
import { requiredImports, type RequiredImport } from '@/stores/graph/imports.ts'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
@ -27,6 +28,9 @@ import { computed, ref, watch, type ComponentInstance } from 'vue'
const props = defineProps(widgetProps(widgetDefinition))
const suggestions = useSuggestionDbStore()
const graph = useGraphStore()
const tree = injectWidgetTree()
const widgetRoot = ref<HTMLElement>()
const dropdownElement = ref<ComponentInstance<typeof DropdownWidget>>()
@ -252,6 +256,17 @@ watch(selectedIndex, (_index) => {
}
props.onUpdate({ edit, portUpdate: { value, origin: props.input.portId } })
})
let endClippingInhibition: (() => void) | undefined
watch(dropdownVisible, (visible) => {
if (visible) {
const { unregister } = tree.inhibitClipping()
endClippingInhibition = unregister
} else {
endClippingInhibition?.()
endClippingInhibition = undefined
}
})
</script>
<script lang="ts">

View File

@ -5,6 +5,24 @@ import { Ast } from '@/util/ast'
import type { Icon } from '@/util/iconName'
import { computed, proxyRefs, type Ref } from 'vue'
function makeExistenceRegistry(onChange: (anyExist: boolean) => void) {
const registered = new Set<number>()
let nextId = 0
return {
register: () => {
const id = nextId++
if (registered.size === 0) onChange(true)
registered.add(id)
return {
unregister: () => {
registered.delete(id)
if (registered.size === 0) onChange(false)
},
}
},
}
}
export { injectFn as injectWidgetTree, provideFn as provideWidgetTree }
const { provideFn, injectFn } = createContextStore(
'Widget tree',
@ -17,9 +35,11 @@ const { provideFn, injectFn } = createContextStore(
extended: Ref<boolean>,
hasActiveAnimations: Ref<boolean>,
emitOpenFullMenu: () => void,
clippingInhibitorsChanged: (anyExist: boolean) => void,
) => {
const graph = useGraphStore()
const nodeSpanStart = computed(() => graph.moduleSource.getSpan(astRoot.value.id)![0])
const { register: inhibitClipping } = makeExistenceRegistry(clippingInhibitorsChanged)
return proxyRefs({
astRoot,
nodeId,
@ -30,6 +50,7 @@ const { provideFn, injectFn } = createContextStore(
nodeSpanStart,
hasActiveAnimations,
emitOpenFullMenu,
inhibitClipping,
})
},
)