mirror of
https://github.com/enso-org/enso.git
synced 2024-11-05 03:59:38 +03:00
Implement new design for tooltips in GUI (#10172)
Fixes #10088, #10154 Implemented figma tooltip design through Vue components and store. Attached tooltips to all existing SVG buttons, based on assigned label property. https://github.com/enso-org/enso/assets/919491/85f5fef7-c6df-471b-b544-b45f45f4f51e # Important Notes Removed all usages of `v-bind` in styles due to issues during hot reloading when updating stores. The internal CSS binding composable sometimes crashes when the component is being unmounted due to hot reload.
This commit is contained in:
parent
291db8aa07
commit
b0589d267d
@ -79,7 +79,7 @@ test('Collapsing nodes', async ({ page }) => {
|
||||
.locator('.icon')
|
||||
.click({ modifiers: ['Shift'] })
|
||||
|
||||
await page.getByTitle('Group Selected Components').click()
|
||||
await page.getByLabel('Group Selected Components').click()
|
||||
await expect(locate.graphNode(page)).toHaveCount(initialNodesCount - 2)
|
||||
const collapsedNode = locate.graphNodeByBinding(page, 'prod')
|
||||
await expect(collapsedNode.locator('.WidgetToken')).toHaveText(['Main', '.', 'collapsed', 'five'])
|
||||
|
@ -45,21 +45,21 @@ function or(a: (page: Locator | Page) => Locator, b: (page: Locator | Page) => L
|
||||
}
|
||||
|
||||
export function toggleVisualizationButton(page: Locator | Page) {
|
||||
return page.getByTitle('Visualization')
|
||||
return page.getByLabel('Visualization')
|
||||
}
|
||||
|
||||
export function toggleVisualizationSelectorButton(page: Locator | Page) {
|
||||
return page.getByTitle('Visualization Selector')
|
||||
return page.getByLabel('Visualization Selector')
|
||||
}
|
||||
|
||||
// === Fullscreen ===
|
||||
|
||||
export function enterFullscreenButton(page: Locator | Page) {
|
||||
return page.getByTitle('Fullscreen')
|
||||
return page.getByLabel('Fullscreen')
|
||||
}
|
||||
|
||||
export function exitFullscreenButton(page: Locator | Page) {
|
||||
return page.getByTitle('Exit Fullscreen')
|
||||
return page.getByLabel('Exit Fullscreen')
|
||||
}
|
||||
|
||||
export const toggleFullscreenButton = or(enterFullscreenButton, exitFullscreenButton)
|
||||
|
@ -14,6 +14,8 @@ import {
|
||||
import ProjectView from '@/views/ProjectView.vue'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, toRef, watch } from 'vue'
|
||||
import TooltipDisplayer from './components/TooltipDisplayer.vue'
|
||||
import { provideTooltipRegistry } from './providers/tooltipState'
|
||||
import { initializePrefixes } from './util/ast/node'
|
||||
import { urlParams } from './util/urlParams'
|
||||
|
||||
@ -26,6 +28,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const classSet = provideAppClassSet()
|
||||
const appTooltips = provideTooltipRegistry()
|
||||
|
||||
initializePrefixes()
|
||||
|
||||
@ -69,6 +72,9 @@ registerAutoBlurHandler()
|
||||
:config="appConfig.config"
|
||||
/>
|
||||
<ProjectView v-else v-show="!props.hidden" class="App" :class="[...classSet.keys()]" />
|
||||
<Teleport to="body">
|
||||
<TooltipDisplayer :registry="appTooltips" />
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
@ -100,7 +100,8 @@ function eventAngle(event: MouseEvent) {
|
||||
|
||||
function setColorForEvent(event: MouseEvent) {
|
||||
mouseAngle.value = eventAngle(event)
|
||||
if (cssTriangleColor.value !== selectedColor.value) selectedColor.value = cssTriangleColor.value
|
||||
if (triangleStyle.value?.fill !== selectedColor.value)
|
||||
selectedColor.value = triangleStyle.value?.fill
|
||||
}
|
||||
function ringClick(event: MouseEvent) {
|
||||
setColorForEvent(event)
|
||||
@ -143,22 +144,26 @@ const cssGradient = computed(() => {
|
||||
`conic-gradient(in oklch increasing hue,${colorStops})`
|
||||
: `conic-gradient(${colorStops})`
|
||||
})
|
||||
const cssTriangleAngle = computed(() =>
|
||||
triangleAngle.value != null ? `${triangleAngle.value}turn` : undefined,
|
||||
)
|
||||
const cssTriangleColor = computed(() =>
|
||||
triangleAngle.value != null ? cssColor(angleToHue(snapAngle(triangleAngle.value))) : undefined,
|
||||
|
||||
const triangleStyle = computed(() =>
|
||||
triangleAngle.value != null ?
|
||||
{
|
||||
transform: `rotate(${triangleAngle.value}turn)`,
|
||||
fill: cssColor(angleToHue(snapAngle(triangleAngle.value))),
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ColorRing">
|
||||
<svg v-if="cssTriangleAngle != null" class="svg" viewBox="-2 -2 4 4">
|
||||
<polygon class="triangle" points="0,-1 -0.4,-1.35 0.4,-1.35" />
|
||||
<svg v-if="triangleStyle != null" class="svg" viewBox="-2 -2 4 4">
|
||||
<polygon :style="triangleStyle" points="0,-1 -0.4,-1.35 0.4,-1.35" />
|
||||
</svg>
|
||||
<div
|
||||
ref="svgElement"
|
||||
class="gradient"
|
||||
:style="{ background: cssGradient }"
|
||||
@pointerleave="mouseAngle = undefined"
|
||||
@pointermove="setColorForEvent"
|
||||
@click.stop="ringClick"
|
||||
@ -184,7 +189,6 @@ const cssTriangleColor = computed(() =>
|
||||
inset: 0;
|
||||
pointer-events: auto;
|
||||
margin-top: auto;
|
||||
background: v-bind('cssGradient');
|
||||
cursor: crosshair;
|
||||
border-radius: var(--radius-full);
|
||||
animation: grow 0.1s forwards;
|
||||
@ -197,9 +201,4 @@ const cssTriangleColor = computed(() =>
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.triangle {
|
||||
transform: rotate(v-bind('cssTriangleAngle'));
|
||||
fill: v-bind('cssTriangleColor');
|
||||
}
|
||||
</style>
|
||||
|
@ -589,6 +589,7 @@ const handler = componentBrowserBindings.handler({
|
||||
:icon="selectedSuggestionIcon"
|
||||
:nodeColor="nodeColor"
|
||||
class="component-editor"
|
||||
:style="{ '--component-editor-padding': cssComponentEditorPadding }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -721,7 +722,6 @@ const handler = componentBrowserBindings.handler({
|
||||
|
||||
.component-editor {
|
||||
position: absolute;
|
||||
--component-editor-padding: v-bind('cssComponentEditorPadding');
|
||||
}
|
||||
|
||||
.visualization-preview {
|
||||
|
@ -20,8 +20,6 @@ const inputField = ref<HTMLInputElement>()
|
||||
const fieldText = ref<string>('')
|
||||
const fieldSelection = ref<Range>()
|
||||
|
||||
const cssEdgeWidth = computed(() => `${4 * props.navigator.scale}px`)
|
||||
|
||||
watch(content, ({ text: newText, selection: newPos }) => {
|
||||
fieldText.value = newText
|
||||
if (inputField.value == null) return
|
||||
@ -69,10 +67,17 @@ defineExpose({
|
||||
blur: () => inputField.value?.blur(),
|
||||
focus: () => inputField.value?.focus(),
|
||||
})
|
||||
|
||||
const rootStyle = computed(() => {
|
||||
return {
|
||||
'--node-color-primary': props.nodeColor,
|
||||
'--port-edge-width': `${4 * props.navigator.scale}px`,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ComponentEditor">
|
||||
<div class="ComponentEditor" :style="rootStyle">
|
||||
<div v-if="props.icon" class="iconPort">
|
||||
<SvgIcon :name="props.icon" class="nodeIcon" />
|
||||
</div>
|
||||
@ -90,7 +95,7 @@ defineExpose({
|
||||
|
||||
<style scoped>
|
||||
.ComponentEditor {
|
||||
--node-color-port: color-mix(in oklab, v-bind('props.nodeColor') 85%, white 15%);
|
||||
--node-color-port: color-mix(in oklab, var(--node-color-primary) 85%, white 15%);
|
||||
--port-padding: 6px;
|
||||
--icon-height: 16px;
|
||||
--icon-text-gap: 6px;
|
||||
@ -127,7 +132,7 @@ defineExpose({
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: calc(var(--port-padding) - var(--component-editor-padding));
|
||||
width: v-bind('cssEdgeWidth');
|
||||
width: var(--port-edge-width);
|
||||
height: calc(var(--component-editor-padding) - var(--port-padding) + var(--icon-height) / 2);
|
||||
transform: translate(-50%, 0);
|
||||
background-color: var(--node-color-port);
|
||||
|
@ -3,6 +3,7 @@ import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import type { Icon } from '@/util/iconName'
|
||||
|
||||
import Breadcrumb from '@/components/DocumentationPanel/DocsBreadcrumb.vue'
|
||||
import SvgButton from '../SvgButton.vue'
|
||||
|
||||
export interface Item {
|
||||
label: string
|
||||
@ -29,22 +30,16 @@ function shrinkFactor(index: number): number {
|
||||
<template>
|
||||
<div class="Breadcrumbs" :style="{ 'background-color': color }">
|
||||
<div class="breadcrumbs-controls">
|
||||
<SvgIcon
|
||||
<SvgButton
|
||||
name="arrow_left"
|
||||
draggable="false"
|
||||
:class="['icon', 'button', 'arrow', { inactive: !props.canGoBackward }]"
|
||||
:disabled="!props.canGoBackward"
|
||||
@click.stop="emit('backward')"
|
||||
/>
|
||||
<SvgIcon
|
||||
name="arrow_right"
|
||||
draggable="false"
|
||||
:class="['icon', 'button', 'arrow', { inactive: !props.canGoForward }]"
|
||||
@click.stop="emit('forward')"
|
||||
/>
|
||||
<SvgButton name="arrow_right" :disabled="!props.canGoForward" @click.stop="emit('forward')" />
|
||||
</div>
|
||||
<TransitionGroup name="breadcrumbs">
|
||||
<template v-for="(breadcrumb, index) in props.breadcrumbs" :key="[index, breadcrumb.label]">
|
||||
<SvgIcon v-if="index > 0" name="arrow_right_head_only" class="arrow" />
|
||||
<SvgIcon v-if="index > 0" name="arrow_right_head_only" />
|
||||
<Breadcrumb
|
||||
:text="breadcrumb.label"
|
||||
:icon="index === props.breadcrumbs.length - 1 ? props.icon : undefined"
|
||||
@ -66,21 +61,13 @@ function shrinkFactor(index: number): number {
|
||||
border-radius: 16px;
|
||||
transition: background-color 0.5s;
|
||||
max-width: 100%;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.breadcrumbs-controls {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.inactive {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: white;
|
||||
transition: opacity 0.5s;
|
||||
}
|
||||
|
||||
.breadcrumbs-move,
|
||||
.breadcrumbs-enter-active,
|
||||
.breadcrumb-leave-active {
|
||||
|
@ -389,9 +389,11 @@ const documentationEditorHandler = documentationEditorBindings.handler({
|
||||
|
||||
const rightDockComputedSize = useResizeObserver(documentationEditorArea)
|
||||
const rightDockComputedBounds = computed(() => new Rect(Vec2.Zero, rightDockComputedSize.value))
|
||||
const cssRightDockWidth = computed(() =>
|
||||
rightDockWidth.value != null ? `${rightDockWidth.value}px` : 'var(--right-dock-default-width)',
|
||||
)
|
||||
const rightDockStyle = computed(() => {
|
||||
return {
|
||||
width: rightDockWidth.value != null ? `${rightDockWidth.value}px` : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
const { documentation } = useAstDocumentation(graphStore, () =>
|
||||
unwrapOr(graphStore.methodAst, undefined),
|
||||
@ -679,6 +681,7 @@ const groupColors = computed(() => {
|
||||
v-if="showDocumentationEditor"
|
||||
ref="documentationEditorArea"
|
||||
class="rightDock"
|
||||
:style="rightDockStyle"
|
||||
data-testid="rightDock"
|
||||
>
|
||||
<div class="scrollArea">
|
||||
@ -744,7 +747,7 @@ const groupColors = computed(() => {
|
||||
position: absolute;
|
||||
top: 46px;
|
||||
bottom: 0;
|
||||
width: v-bind('cssRightDockWidth');
|
||||
width: var(--right-dock-default-width);
|
||||
right: 0;
|
||||
border-radius: 7px 0 0;
|
||||
background-color: rgba(255, 255, 255, 0.35);
|
||||
|
@ -53,6 +53,11 @@ const CONTENT_PADDING_PX = `${CONTENT_PADDING}px`
|
||||
const CONTENT_PADDING_RIGHT_PX = `${CONTENT_PADDING_RIGHT}px`
|
||||
const MENU_CLOSE_TIMEOUT_MS = 300
|
||||
|
||||
const contentNodeStyle = {
|
||||
padding: CONTENT_PADDING_PX,
|
||||
paddingRight: CONTENT_PADDING_RIGHT_PX,
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
node: Node
|
||||
edited: boolean
|
||||
@ -577,6 +582,7 @@ watchEffect(() => {
|
||||
<div
|
||||
ref="contentNode"
|
||||
:class="{ content: true, dragged: isDragged }"
|
||||
:style="contentNodeStyle"
|
||||
v-on="dragPointer.events"
|
||||
@click="handleNodeClick"
|
||||
>
|
||||
@ -751,8 +757,6 @@ watchEffect(() => {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
padding: v-bind('CONTENT_PADDING_PX');
|
||||
padding-right: v-bind('CONTENT_PADDING_RIGHT_PX');
|
||||
z-index: 2;
|
||||
transition: outline 0.2s ease;
|
||||
outline: 0px solid transparent;
|
||||
|
@ -21,19 +21,22 @@ const visible = computed(() => props.selected || props.externalHovered || hovere
|
||||
|
||||
watchEffect(() => emit('visible', visible.value))
|
||||
|
||||
const transform = computed(() => {
|
||||
const rootStyle = computed(() => {
|
||||
const { x, y } = props.nodePosition
|
||||
return `translate(${x}px, ${y}px)`
|
||||
return {
|
||||
transform: `translate(${x}px, ${y}px)`,
|
||||
'--node-size-x': `${props.nodeSize.x}px`,
|
||||
'--node-size-y': `${props.nodeSize.y}px`,
|
||||
'--selection-color': props.color,
|
||||
}
|
||||
})
|
||||
const nodeWidthPx = computed(() => `${props.nodeSize.x}px`)
|
||||
const nodeHeightPx = computed(() => `${props.nodeSize.y}px`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="GraphNodeSelection"
|
||||
:class="{ visible, selected: props.selected }"
|
||||
:style="{ transform }"
|
||||
:style="rootStyle"
|
||||
:data-node-id="props.nodeId"
|
||||
@pointerenter="hovered = true"
|
||||
@pointerleave="hovered = false"
|
||||
@ -44,8 +47,9 @@ const nodeHeightPx = computed(() => `${props.nodeSize.y}px`)
|
||||
.GraphNodeSelection {
|
||||
position: absolute;
|
||||
inset: calc(0px - var(--selected-node-border-width));
|
||||
width: calc(var(--selected-node-border-width) * 2 + v-bind('nodeWidthPx'));
|
||||
height: calc(var(--selected-node-border-width) * 2 + v-bind('nodeHeightPx'));
|
||||
width: calc(var(--selected-node-border-width) * 2 + var(--node-size-x));
|
||||
height: calc(var(--selected-node-border-width) * 2 + var(--node-size-y));
|
||||
border-radius: calc(var(--node-border-radius) + var(--selected-node-border-width));
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
@ -53,7 +57,7 @@ const nodeHeightPx = computed(() => `${props.nodeSize.y}px`)
|
||||
opacity: 0.2;
|
||||
display: block;
|
||||
inset: var(--selected-node-border-width);
|
||||
box-shadow: 0 0 0 calc(0px - var(--node-border-radius)) v-bind('props.color');
|
||||
box-shadow: 0 0 0 calc(0px - var(--node-border-radius)) var(--selection-color);
|
||||
border-radius: var(--node-border-radius);
|
||||
|
||||
transition:
|
||||
@ -63,7 +67,7 @@ const nodeHeightPx = computed(() => `${props.nodeSize.y}px`)
|
||||
}
|
||||
|
||||
.GraphNodeSelection.visible::before {
|
||||
box-shadow: 0 0 0 var(--selected-node-border-width) v-bind('props.color');
|
||||
box-shadow: 0 0 0 var(--selected-node-border-width) var(--selection-color);
|
||||
}
|
||||
|
||||
.GraphNodeSelection:not(.selected):hover::before {
|
||||
|
@ -99,7 +99,6 @@ provideWidgetTree(
|
||||
</script>
|
||||
<script lang="ts">
|
||||
export const GRAB_HANDLE_X_MARGIN = 4
|
||||
const GRAB_HANDLE_X_MARGIN_PX = `${GRAB_HANDLE_X_MARGIN}px`
|
||||
export const ICON_WIDTH = 16
|
||||
</script>
|
||||
|
||||
@ -109,6 +108,7 @@ export const ICON_WIDTH = 16
|
||||
<SvgIcon
|
||||
v-if="!props.connectedSelfArgumentId"
|
||||
class="icon grab-handle nodeCategoryIcon"
|
||||
:style="{ margin: `0 ${GRAB_HANDLE_X_MARGIN}px` }"
|
||||
:name="props.icon"
|
||||
@click.right.stop.prevent="emit('openFullMenu')"
|
||||
/>
|
||||
@ -144,6 +144,5 @@ export const ICON_WIDTH = 16
|
||||
|
||||
.grab-handle {
|
||||
color: white;
|
||||
margin: 0 v-bind('GRAB_HANDLE_X_MARGIN_PX');
|
||||
}
|
||||
</style>
|
||||
|
@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import TooltipTrigger from '@/components/TooltipTrigger.vue'
|
||||
|
||||
/**
|
||||
* A button. Supports toggling and disabled state.
|
||||
*
|
||||
@ -10,7 +12,7 @@
|
||||
*/
|
||||
|
||||
const toggledOn = defineModel<boolean>({ default: undefined })
|
||||
const props = defineProps<{ disabled?: boolean | undefined }>()
|
||||
const props = defineProps<{ disabled?: boolean | undefined; title?: string | undefined }>()
|
||||
|
||||
function onClick() {
|
||||
if (!props.disabled && toggledOn.value != null) toggledOn.value = !toggledOn.value
|
||||
@ -18,14 +20,23 @@ function onClick() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="MenuButton"
|
||||
:class="{ toggledOn, toggledOff: toggledOn === false, disabled }"
|
||||
:disabled="disabled ?? false"
|
||||
@click.stop="onClick"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
<TooltipTrigger>
|
||||
<template #default="triggerProps">
|
||||
<button
|
||||
class="MenuButton"
|
||||
:aria-label="props.title ?? ''"
|
||||
:class="{ toggledOn, toggledOff: toggledOn === false, disabled }"
|
||||
:disabled="disabled ?? false"
|
||||
v-bind="triggerProps"
|
||||
@click.stop="onClick"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
<template v-if="$slots.tooltip || props.title" #tooltip>
|
||||
<slot name="tooltip">{{ props.title }}</slot>
|
||||
</template>
|
||||
</TooltipTrigger>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
@ -100,28 +100,27 @@ const clickTrack = {
|
||||
x: (event: MouseEvent) => handleTrackClick('x', event),
|
||||
y: (event: MouseEvent) => handleTrackClick('y', event),
|
||||
}
|
||||
|
||||
const scrollBarStyles = computed(() => ({
|
||||
'--track-width': `${TRACK_WIDTH}px`,
|
||||
'--bar-end-margin': `${BAR_END_MARGIN}px`,
|
||||
'--x-start': xStart.value,
|
||||
'--x-length': xLength.value,
|
||||
'--y-start': yStart.value,
|
||||
'--y-length': yLength.value,
|
||||
}))
|
||||
</script>
|
||||
<script lang="ts">
|
||||
export type ScrollbarEvent =
|
||||
| {
|
||||
type: 'start'
|
||||
}
|
||||
| {
|
||||
type: 'move'
|
||||
startOffset: Vec2
|
||||
}
|
||||
| {
|
||||
type: 'stop'
|
||||
}
|
||||
| {
|
||||
type: 'jump'
|
||||
axis: 'x' | 'y'
|
||||
position: number
|
||||
}
|
||||
| { type: 'start' }
|
||||
| { type: 'move'; startOffset: Vec2 }
|
||||
| { type: 'stop' }
|
||||
| { type: 'jump'; axis: 'x' | 'y'; position: number }
|
||||
export default {}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="element" class="ScrollBar">
|
||||
<div ref="element" class="ScrollBar" :style="scrollBarStyles">
|
||||
<div class="track vertical" @pointerdown.stop="clickTrack.y">
|
||||
<div class="bar vertical" v-on.stop="dragSlider.y" />
|
||||
</div>
|
||||
@ -141,30 +140,30 @@ export type ScrollbarEvent =
|
||||
|
||||
.vertical {
|
||||
position: absolute;
|
||||
width: v-bind('`${TRACK_WIDTH}px`');
|
||||
width: var(--track-width);
|
||||
height: 100%;
|
||||
right: 1px;
|
||||
margin-top: v-bind('`${BAR_END_MARGIN}px`');
|
||||
margin-bottom: v-bind('`${TRACK_WIDTH + BAR_END_MARGIN}px`');
|
||||
margin-top: var(--bar-end-margin);
|
||||
margin-bottom: calc(var(--track-width) + var(--bar-end-margin));
|
||||
}
|
||||
.bar.vertical {
|
||||
left: 1px;
|
||||
top: v-bind('yStart');
|
||||
height: v-bind('yLength');
|
||||
top: var(--y-start);
|
||||
height: var(--y-length);
|
||||
}
|
||||
|
||||
.horizontal {
|
||||
position: absolute;
|
||||
height: v-bind('`${TRACK_WIDTH}px`');
|
||||
height: var(--track-width);
|
||||
width: 100%;
|
||||
bottom: 1px;
|
||||
margin-left: v-bind('`${BAR_END_MARGIN}px`');
|
||||
margin-right: v-bind('`${TRACK_WIDTH + BAR_END_MARGIN}px`');
|
||||
margin-left: var(--bar-end-margin);
|
||||
margin-right: calc(var(--track-width) + var(--bar-end-margin));
|
||||
}
|
||||
.bar.horizontal {
|
||||
top: 1px;
|
||||
left: v-bind('xStart');
|
||||
width: v-bind('xLength');
|
||||
left: var(--x-start);
|
||||
width: var(--x-length);
|
||||
}
|
||||
|
||||
.bar {
|
||||
|
@ -8,11 +8,12 @@ const _props = defineProps<{
|
||||
name: Icon | URLString
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
title?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MenuButton :disabled="disabled" class="SvgButton">
|
||||
<MenuButton :disabled="disabled" class="SvgButton" :title="title">
|
||||
<SvgIcon :name="name" />
|
||||
<div v-if="label" v-text="label" />
|
||||
</MenuButton>
|
||||
|
86
app/gui2/src/components/TooltipDisplayer.vue
Normal file
86
app/gui2/src/components/TooltipDisplayer.vue
Normal file
@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { type TooltipRegistry } from '@/providers/tooltipState'
|
||||
import { debouncedGetter } from '@/util/reactivity'
|
||||
import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps<{ registry: TooltipRegistry }>()
|
||||
|
||||
// Time for which hover must remain on a single element for tooltip to show up.
|
||||
const TOOLTIP_SHOW_DELAY_MS = 1500
|
||||
// Time after which tooltip will disappear once an element is no longer hovered.
|
||||
const TOOLTIP_HIDE_DELAY_MS = 300
|
||||
|
||||
const activeTooltip = computed(() => {
|
||||
const element = props.registry.lastHoveredElement.value
|
||||
return { element, entry: props.registry.getElementEntry(element) }
|
||||
})
|
||||
const doShow = debouncedGetter(
|
||||
() => activeTooltip.value.element != null && activeTooltip.value.entry != null,
|
||||
TOOLTIP_SHOW_DELAY_MS,
|
||||
)
|
||||
const displayedEntry = debouncedGetter(activeTooltip, TOOLTIP_HIDE_DELAY_MS)
|
||||
const floatTarget = computed(() => {
|
||||
return doShow.value && displayedEntry.value.element?.isConnected ?
|
||||
displayedEntry.value.element
|
||||
: undefined
|
||||
})
|
||||
|
||||
const tooltip = ref<HTMLDivElement>()
|
||||
const floating = useFloating(floatTarget, tooltip, {
|
||||
placement: 'top',
|
||||
transform: false,
|
||||
middleware: [offset(5), flip(), shift()],
|
||||
whileElementsMounted: autoUpdate,
|
||||
})
|
||||
|
||||
const tooltipContents = computed(() => displayedEntry.value.entry?.contents.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition>
|
||||
<div
|
||||
v-if="floatTarget != null && tooltipContents != null"
|
||||
:key="displayedEntry.entry?.key ?? 0"
|
||||
ref="tooltip"
|
||||
class="Tooltip"
|
||||
:style="floating.floatingStyles.value"
|
||||
>
|
||||
<component :is="tooltipContents" />
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.Tooltip {
|
||||
position: absolute;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11.5px;
|
||||
min-height: 32px;
|
||||
line-height: 20px;
|
||||
background: rgba(0 0 0 / 80%);
|
||||
color: rgba(255 255 255 / 80%);
|
||||
padding: 4px 8px;
|
||||
border-radius: 16px;
|
||||
pointer-events: none;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
text-wrap: balance;
|
||||
|
||||
&.v-enter-active,
|
||||
&.v-leave-active {
|
||||
transition:
|
||||
transform 0.1s ease-out,
|
||||
opacity 0.1s ease-out;
|
||||
}
|
||||
|
||||
&.v-enter-from,
|
||||
&.v-leave-to {
|
||||
transform: translateY(-2px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
28
app/gui2/src/components/TooltipTrigger.vue
Normal file
28
app/gui2/src/components/TooltipTrigger.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { useTooltipRegistry } from '@/providers/tooltipState'
|
||||
import { usePropagateScopesToAllRoots } from '@/util/patching'
|
||||
import { toRef, useSlots } from 'vue'
|
||||
|
||||
usePropagateScopesToAllRoots()
|
||||
|
||||
const registry = useTooltipRegistry()
|
||||
|
||||
const slots = useSlots()
|
||||
const tooltipSlot = toRef(slots, 'tooltip')
|
||||
const registered = registry.registerTooltip(tooltipSlot)
|
||||
function onEnter(e: PointerEvent) {
|
||||
if (e.target instanceof HTMLElement) {
|
||||
registered.onTargetEnter(e.target)
|
||||
}
|
||||
}
|
||||
|
||||
function onLeave(e: PointerEvent) {
|
||||
if (e.target instanceof HTMLElement) {
|
||||
registered.onTargetLeave(e.target)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot v-bind="{ ...$attrs }" @pointerenter="onEnter" @pointerleave="onLeave" />
|
||||
</template>
|
@ -119,17 +119,12 @@ export function useSyncLocalStorage<StoredState extends Object>(
|
||||
|
||||
async function restoreState(storageKey: string) {
|
||||
abortLastRestore()
|
||||
assert(restoreIdInProgress.value == null)
|
||||
|
||||
const thisRestoreId = nextRestoreId++
|
||||
restoreIdInProgress.value = thisRestoreId
|
||||
const restored = storageMap.value.get(storageKey)
|
||||
|
||||
restoreAbort = abortScope.child()
|
||||
restoreAbort.onAbort(() => {
|
||||
if (restoreIdInProgress.value === thisRestoreId) restoreIdInProgress.value = undefined
|
||||
})
|
||||
|
||||
try {
|
||||
await restoreStateInCtx(restored, restoreAbort.signal)
|
||||
} catch (e) {
|
||||
|
66
app/gui2/src/providers/tooltipState.ts
Normal file
66
app/gui2/src/providers/tooltipState.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { createContextStore } from '@/providers'
|
||||
import { last } from '@/util/data/iterable'
|
||||
import {
|
||||
computed,
|
||||
onUnmounted,
|
||||
shallowReactive,
|
||||
type Ref,
|
||||
type ShallowReactive,
|
||||
type Slot,
|
||||
} from 'vue'
|
||||
|
||||
interface TooltipEntry {
|
||||
contents: Ref<Slot | undefined>
|
||||
key: symbol
|
||||
}
|
||||
|
||||
export type TooltipRegistry = ReturnType<typeof injectFn>
|
||||
export { provideFn as provideTooltipRegistry, injectFn as useTooltipRegistry }
|
||||
const { provideFn, injectFn } = createContextStore('tooltip registry', () => {
|
||||
type EntriesSet = ShallowReactive<Set<TooltipEntry>>
|
||||
const hoveredElements = shallowReactive<Map<HTMLElement, EntriesSet>>(new Map())
|
||||
|
||||
const lastHoveredElement = computed(() => {
|
||||
return last(hoveredElements.keys())
|
||||
})
|
||||
|
||||
return {
|
||||
lastHoveredElement,
|
||||
getElementEntry(el: HTMLElement | undefined): TooltipEntry | undefined {
|
||||
const set = el && hoveredElements.get(el)
|
||||
return set ? last(set) : undefined
|
||||
},
|
||||
registerTooltip(slot: Ref<Slot | undefined>) {
|
||||
const entry: TooltipEntry = {
|
||||
contents: slot,
|
||||
key: Symbol(),
|
||||
}
|
||||
const registeredElements = new Set<HTMLElement>()
|
||||
onUnmounted(() => {
|
||||
for (const el of registeredElements) {
|
||||
methods.onTargetLeave(el)
|
||||
}
|
||||
})
|
||||
|
||||
const methods = {
|
||||
onTargetEnter(target: HTMLElement) {
|
||||
const entriesSet: EntriesSet = hoveredElements.get(target) ?? shallowReactive(new Set())
|
||||
entriesSet.add(entry)
|
||||
// make sure that the newly entered target is on top of the map
|
||||
hoveredElements.delete(target)
|
||||
hoveredElements.set(target, entriesSet)
|
||||
registeredElements.add(target)
|
||||
},
|
||||
onTargetLeave(target: HTMLElement) {
|
||||
const entriesSet = hoveredElements.get(target)
|
||||
entriesSet?.delete(entry)
|
||||
registeredElements.delete(target)
|
||||
if (entriesSet?.size === 0) {
|
||||
hoveredElements.delete(target)
|
||||
}
|
||||
},
|
||||
}
|
||||
return methods
|
||||
},
|
||||
}
|
||||
})
|
@ -10,3 +10,14 @@ export function every<T>(iter: Iterable<T>, f: (value: T) => boolean): boolean {
|
||||
for (const value of iter) if (!f(value)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Return last element returned by the iterable.
|
||||
* NOTE: Linear complexity. This function always visits the whole iterable. Using this with an
|
||||
* infinite generator will cause an infinite loop.
|
||||
*/
|
||||
export function last<T>(iter: Iterable<T>): T | undefined {
|
||||
let last
|
||||
for (const el of iter) last = el
|
||||
return last
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { getCurrentInstance } from 'vue'
|
||||
|
||||
/**
|
||||
* Replace a function under given key on provided object and provide a hook that is called right
|
||||
* before the original function is executed.
|
||||
@ -11,3 +13,57 @@ export function hookBeforeFunctionCall(object: any, key: PropertyKey, hook: () =
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force propagation of parent scopes into all root nodes of this component, i.e. children of it's
|
||||
* top-level Fragment VNode.
|
||||
*
|
||||
* By default, Vue is restricting scope propagation to only happen within components with only
|
||||
* singular root node. Unfortunately, that also happens to block propagation when components adds an
|
||||
* additional root-node, such as `Teleport`, or when rendering a slot as root. This is a workaround
|
||||
* that tricks Vue into actually propagating the scopes past this component's top-level `Fragment`
|
||||
* element, allowing parent scoped styles to affect its actual root node or rendered slots.
|
||||
*/
|
||||
export function usePropagateScopesToAllRoots() {
|
||||
const instance = getCurrentInstance()
|
||||
if (instance != null) {
|
||||
let _subTree = instance.subTree
|
||||
// Intercept all `subTree` assignments for this instance, getting an early opportunity to modify
|
||||
// its internal root VNode (usually a Fragment).
|
||||
Object.defineProperty(instance, 'subTree', {
|
||||
get: () => _subTree,
|
||||
set: (value) => {
|
||||
_subTree = value
|
||||
// Gather all scopes that would naturally propagate to this node, and assign them to
|
||||
// `slotScopeIds`, which Vue propagates through fragments on its own. This is an internal
|
||||
// mechanism used in implementation of `:scoped` custom CSS selector.
|
||||
collectParentScopes(((_subTree as any).slotScopeIds ??= []), _subTree, null, null, instance)
|
||||
},
|
||||
})
|
||||
|
||||
// Mimics Vue's internal `setScopeIds`, but instead collects the scopes into an array.
|
||||
const collectParentScopes = (
|
||||
outScopes: string[],
|
||||
vnode: typeof instance.vnode,
|
||||
scopeId: string | null,
|
||||
slotScopeIds: string[] | null,
|
||||
parentComponent: typeof instance | null,
|
||||
) => {
|
||||
if (scopeId) outScopes.push(scopeId)
|
||||
if (slotScopeIds) outScopes.push(...slotScopeIds)
|
||||
if (parentComponent) {
|
||||
const subTree = parentComponent.subTree
|
||||
if (vnode === subTree) {
|
||||
const parentVNode = parentComponent.vnode
|
||||
collectParentScopes(
|
||||
outScopes,
|
||||
parentVNode,
|
||||
parentVNode.scopeId,
|
||||
(parentVNode as any).slotScopeIds,
|
||||
parentComponent.parent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ import {
|
||||
isRef,
|
||||
queuePostFlushCb,
|
||||
shallowRef,
|
||||
toValue,
|
||||
watch,
|
||||
type ComputedRef,
|
||||
type MaybeRefOrGetter,
|
||||
@ -140,11 +141,11 @@ export function cachedGetter<T>(
|
||||
* time, the timer is restarted.
|
||||
*/
|
||||
export function debouncedGetter<T>(
|
||||
getter: () => T,
|
||||
getter: WatchSource<T>,
|
||||
debounce: number,
|
||||
equalFn: (a: T, b: T) => boolean = defaultEquality,
|
||||
): Ref<T> {
|
||||
const valueRef = shallowRef<T>(getter())
|
||||
const valueRef = shallowRef<T>(toValue(getter))
|
||||
debouncedWatch(
|
||||
getter,
|
||||
(newValue) => {
|
||||
|
@ -1,5 +1,6 @@
|
||||
/// <reference types="histoire" />
|
||||
|
||||
import react from '@vitejs/plugin-react'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { getDefines, readEnvironmentFromFile } from 'enso-common/src/appConfig'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@ -27,16 +28,12 @@ export default defineConfig({
|
||||
envDir: fileURLToPath(new URL('.', import.meta.url)),
|
||||
plugins: [
|
||||
vue(),
|
||||
react({
|
||||
include: fileURLToPath(new URL('../ide-desktop/lib/dashboard/**/*.tsx', import.meta.url)),
|
||||
babel: { plugins: ['@babel/plugin-syntax-import-assertions'] },
|
||||
}),
|
||||
gatewayServer(),
|
||||
...(process.env.ELECTRON_DEV_MODE === 'true' ?
|
||||
[
|
||||
(await import('@vitejs/plugin-react')).default({
|
||||
include: fileURLToPath(new URL('../ide-desktop/lib/dashboard/**/*.tsx', import.meta.url)),
|
||||
babel: { plugins: ['@babel/plugin-syntax-import-assertions'] },
|
||||
}),
|
||||
]
|
||||
: process.env.NODE_ENV === 'development' ? [await projectManagerShim()]
|
||||
: []),
|
||||
...(process.env.NODE_ENV === 'development' ? [await projectManagerShim()] : []),
|
||||
],
|
||||
optimizeDeps: {
|
||||
entries: fileURLToPath(new URL('./index.html', import.meta.url)),
|
||||
|
@ -1069,6 +1069,13 @@ export default class RemoteBackend extends Backend {
|
||||
|
||||
/** Log an event that will be visible in the organization audit log. */
|
||||
async logEvent(message: string, projectId?: string | null, metadata?: object | null) {
|
||||
// Prevent events from being logged in dev mode, since we are often using production environment
|
||||
// and are polluting real logs.
|
||||
if (detect.IS_DEV_MODE && process.env.ENSO_CLOUD_ENVIRONMENT === 'production') {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return
|
||||
}
|
||||
|
||||
const path = remoteBackendPaths.POST_LOG_EVENT_PATH
|
||||
const response = await this.post(
|
||||
path,
|
||||
|
@ -222,7 +222,13 @@ export default function projectManagerShimMiddleware(
|
||||
const entries: FileSystemEntry[] = []
|
||||
for (const entryName of entryNames) {
|
||||
const entryPath = path.join(directoryPath, entryName)
|
||||
if (isHiddenFile.isHiddenFile(entryPath)) continue
|
||||
try {
|
||||
if (isHiddenFile.isHiddenFile(entryPath)) continue
|
||||
} catch {
|
||||
// Ignore errors from this library, it occasionally
|
||||
// fails on windows due to native library loading
|
||||
// issues.
|
||||
}
|
||||
const stat = await fs.stat(entryPath)
|
||||
const attributes: Attributes = {
|
||||
byteSize: stat.size,
|
||||
|
Loading…
Reference in New Issue
Block a user