Implement text ellipsis on dropdowns (#10198)

Fixes #10091

https://github.com/enso-org/enso/assets/919491/148d117a-8f2c-4176-b0c8-c7d2390173c3
This commit is contained in:
Paweł Grabarz 2024-06-12 15:13:48 +02:00 committed by GitHub
parent 8423f31884
commit 0e17beba73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 88 additions and 37 deletions

View File

@ -4,6 +4,8 @@
- [Arrows navigation][10179] selected nodes may be moved around, or entire scene
if no node is selected.
- [Added a limit for dropdown width][10198], implemented ellipsis and scrolling
for long labels when hovered.
- [Copy-pasting multiple nodes][10194].
- The documentation editor has [formatting toolbars][10064].
- The documentation editor supports [rendering images][10205].
@ -11,6 +13,7 @@
[10064]: https://github.com/enso-org/enso/pull/10064
[10179]: https://github.com/enso-org/enso/pull/10179
[10194]: https://github.com/enso-org/enso/pull/10194
[10198]: https://github.com/enso-org/enso/pull/10198
[10205]: https://github.com/enso-org/enso/pull/10205
#### Enso Standard Library

View File

@ -30,7 +30,7 @@ const showColorPicker = ref(false)
</script>
<template>
<div class="CircularMenu">
<div class="CircularMenu" :class="{ partial: !props.isFullMenuVisible }">
<div
v-if="!showColorPicker"
class="circle menu"

View File

@ -11,7 +11,8 @@ import GraphNodeSelection from '@/components/GraphEditor/GraphNodeSelection.vue'
import GraphVisualization from '@/components/GraphEditor/GraphVisualization.vue'
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
import NodeWidgetTree, {
GRAB_HANDLE_X_MARGIN,
GRAB_HANDLE_X_MARGIN_L,
GRAB_HANDLE_X_MARGIN_R,
ICON_WIDTH,
} from '@/components/GraphEditor/NodeWidgetTree.vue'
import SvgIcon from '@/components/SvgIcon.vue'
@ -210,7 +211,8 @@ watchEffect(() => {
}
const inZone = (pos: Vec2 | undefined) =>
pos != null &&
pos.sub(props.node.position).x < CONTENT_PADDING + ICON_WIDTH + GRAB_HANDLE_X_MARGIN * 2
pos.sub(props.node.position).x <
CONTENT_PADDING + ICON_WIDTH + GRAB_HANDLE_X_MARGIN_L + GRAB_HANDLE_X_MARGIN_R
const hovered =
menuHovered.value ||
inZone(nodeHoverPos.value) ||
@ -757,7 +759,7 @@ watchEffect(() => {
flex-direction: row;
align-items: center;
white-space: nowrap;
z-index: 2;
z-index: 24;
transition: outline 0.2s ease;
outline: 0px solid transparent;
}
@ -781,12 +783,6 @@ watchEffect(() => {
opacity: 1;
}
.container {
position: relative;
display: flex;
gap: 4px;
}
.CircularMenu {
z-index: 25;
}

View File

@ -97,7 +97,7 @@ const defaultVisualizationRaw = projectStore.useVisualizationData(
const defaultVisualizationForCurrentNodeSource = computed<VisualizationIdentifier | undefined>(
() => {
const raw = defaultVisualizationRaw.value
if (!raw?.ok || !raw.value) return
if (!raw?.ok || !raw.value || !raw.value.name) return
return {
name: raw.value.name,
module:

View File

@ -98,7 +98,8 @@ provideWidgetTree(
)
</script>
<script lang="ts">
export const GRAB_HANDLE_X_MARGIN = 4
export const GRAB_HANDLE_X_MARGIN_L = 4
export const GRAB_HANDLE_X_MARGIN_R = 8
export const ICON_WIDTH = 16
</script>
@ -108,7 +109,7 @@ export const ICON_WIDTH = 16
<SvgIcon
v-if="!props.connectedSelfArgumentId"
class="icon grab-handle nodeCategoryIcon"
:style="{ margin: `0 ${GRAB_HANDLE_X_MARGIN}px` }"
:style="{ margin: `0 ${GRAB_HANDLE_X_MARGIN_R}px 0 ${GRAB_HANDLE_X_MARGIN_L}px` }"
:name="props.icon"
@click.right.stop.prevent="emit('openFullMenu')"
/>

View File

@ -279,7 +279,8 @@ export const widgetDefinition = defineWidget(
z-index: -1;
}
.isSelfArgument {
margin-right: 2px;
/* Selector specificity must be high enough to override r-24 specific styles. */
.WidgetPort.isSelfArgument.isSelfArgument {
margin-right: 6px;
}
</style>

View File

@ -26,7 +26,7 @@ import { ArgumentInfoKey } from '@/util/callTree'
import { arrayEquals } from '@/util/data/array'
import type { Opt } from '@/util/data/opt'
import { qnLastSegment, tryQualifiedName } from '@/util/qualifiedName'
import { autoUpdate, offset, size, useFloating } from '@floating-ui/vue'
import { autoUpdate, offset, shift, size, useFloating } from '@floating-ui/vue'
import { computed, proxyRefs, ref, type ComponentInstance, type RendererNode } from 'vue'
const props = defineProps(widgetProps(widgetDefinition))
@ -42,6 +42,10 @@ const editedWidget = ref<string>()
const editedValue = ref<Ast.Owned | string | undefined>()
const isHovered = ref(false)
// How much wider a dropdown can be than a port it is attached to, when a long text is present.
// Any text beyond that limit will receive an ellipsis and sliding animation on hover.
const MAX_DROPDOWN_OVERSIZE_PX = 150
const { floatingStyles } = useFloating(widgetRoot, dropdownElement, {
middleware: computed(() => {
return [
@ -51,13 +55,26 @@ const { floatingStyles } = useFloating(widgetRoot, dropdownElement, {
mainAxis: (NODE_HEIGHT - state.rects.reference.height) / 2,
}
}),
size({
apply({ elements, rects }) {
Object.assign(elements.floating.style, {
minWidth: `${rects.reference.width + 16}px`,
})
size(() => ({
elementContext: 'reference',
apply({ elements, rects, availableWidth }) {
const PORT_PADDING_X = 8
const screenOverflow = Math.max(
(rects.floating.width - availableWidth) / 2 + PORT_PADDING_X,
0,
)
const portWidth = rects.reference.width + PORT_PADDING_X * 2
const minWidth = `${Math.max(portWidth - screenOverflow, 0)}px`
const maxWidth = `${portWidth + MAX_DROPDOWN_OVERSIZE_PX}px`
Object.assign(elements.floating.style, { minWidth, maxWidth })
elements.floating.style.setProperty('--dropdown-max-width', maxWidth)
},
}),
})),
// Try to keep the dropdown within node's bounds.
shift(() => (tree.nodeElement ? { boundary: tree.nodeElement } : {})),
shift(), // Always keep within screen bounds, overriding node bounds.
]
}),
whileElementsMounted: autoUpdate,
@ -185,6 +202,7 @@ const selectedExpressions = computed(() => {
}
} else {
const code = removeSurroundingParens(WidgetInput.valueRepr(props.input))
if (code?.includes(' ')) selected.add(code.substring(0, code.indexOf(' ')))
if (code) selected.add(code)
}
return selected

View File

@ -26,14 +26,8 @@ export const widgetDefinition = defineWidget(
<template>
<SvgIcon
class="WidgetSelfIcon icon nodeCategoryIcon"
class="WidgetSelfIcon icon nodeCategoryIcon r-24"
:name="icon"
@click.right.stop.prevent="tree.emitOpenFullMenu()"
/>
</template>
<style scoped>
.icon {
margin: 0 4px;
}
</style>

View File

@ -23,6 +23,7 @@ const props = defineProps<{
<style scoped>
svg {
overflow: visible; /* Prevent slight cutting off icons that are using all available space. */
width: var(--icon-width, var(--icon-size, 16px));
height: var(--icon-height, var(--icon-size, 16px));
transform: var(--icon-transform);

View File

@ -1,5 +1,6 @@
<script setup lang="ts" generic="Entry extends DropdownEntry">
import SvgIcon from '@/components/SvgIcon.vue'
import { injectGraphNavigator } from '@/providers/graphNavigator'
import type { Icon } from '@/util/iconName'
import { computed, ref } from 'vue'
@ -13,6 +14,7 @@ const props = defineProps<{ color: string; entries: Entry[] }>()
const emit = defineEmits<{ clickEntry: [entry: Entry, keepOpen: boolean] }>()
const sortDirection = ref<SortDirection>(SortDirection.none)
const graphNavigator = injectGraphNavigator(true)
function lexicalCmp(a: string, b: string) {
return (
@ -51,6 +53,15 @@ const NEXT_SORT_DIRECTION: Record<SortDirection, SortDirection> = {
// Currently unused.
const enableSortButton = ref(false)
const styleVars = computed(() => {
return {
'--dropdown-bg': props.color,
// Slightly shift the top border of drawn dropdown away from node's top border by a fraction of
// a pixel, to prevent it from poking through and disturbing node's siluette.
'--extend-margin': `${0.2 / (graphNavigator?.scale ?? 1)}px`,
}
})
</script>
<script lang="ts">
@ -61,7 +72,7 @@ export interface DropdownEntry {
</script>
<template>
<div class="DropdownWidget" :style="{ '--dropdown-bg': color }">
<div class="DropdownWidget" :style="styleVars">
<ul class="list scrollable" @wheel.stop>
<template v-for="entry in sortedValues" :key="entry.value">
<li v-if="entry.selected">
@ -88,12 +99,12 @@ export interface DropdownEntry {
.DropdownWidget {
position: relative;
user-select: none;
overflow: clip;
min-width: 100%;
z-index: 21;
/* When dropdown is displayed right below the last node's argument, the rounded corner needs to be
covered. This is done by covering extra node-sized space at the top of the dropdown. */
--dropdown-extend: calc(var(--node-height) - 1px);
--dropdown-extend: calc(var(--node-height) - var(--extend-margin));
margin-top: calc(0px - var(--dropdown-extend));
padding-top: var(--dropdown-extend);
background-color: var(--dropdown-bg);
@ -112,21 +123,22 @@ export interface DropdownEntry {
}
.list {
overflow: auto;
width: min-content;
border-radius: 0 0 16px 16px;
min-width: 100%;
min-height: 16px;
max-height: 152px;
list-style-type: none;
color: var(--color-text-light);
background: var(--dropdown-bg);
scrollbar-width: thin;
padding: 4px 0;
border: 2px solid var(--dropdown-bg);
padding: 6px;
position: relative;
}
li {
text-align: left;
max-width: 100%;
overflow: hidden;
}
.item:not(.selected):hover {
@ -135,8 +147,35 @@ li {
.list span {
display: inline-block;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
vertical-align: middle;
margin: 3px 0;
text-wrap: nowrap;
text-overflow: ellipsis;
}
li.item:hover {
span {
--text-scroll-max: calc(var(--dropdown-max-width) - 28px);
will-change: transform;
animation: 6s 1s infinite text-scroll;
}
}
@keyframes text-scroll {
0%,
80%,
100% {
max-width: unset;
transform: translateX(0);
}
50%,
70% {
max-width: unset;
transform: translateX(calc(min(var(--text-scroll-max, 100%), 100%) - 100%));
}
}
.sort-background {
@ -166,8 +205,6 @@ li {
}
.item {
margin-right: 4px;
margin-left: 4px;
padding-left: 8px;
padding-right: 8px;
border-radius: var(--radius-full);