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:
Paweł Grabarz 2024-06-07 03:48:11 +02:00 committed by GitHub
parent 291db8aa07
commit b0589d267d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 385 additions and 114 deletions

View File

@ -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'])

View File

@ -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)

View File

@ -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>

View File

@ -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>

View File

@ -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 {

View File

@ -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);

View File

@ -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 {

View File

@ -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);

View File

@ -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;

View File

@ -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 {

View File

@ -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>

View File

@ -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>

View File

@ -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 {

View File

@ -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>

View 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>

View 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>

View File

@ -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) {

View 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
},
}
})

View File

@ -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
}

View File

@ -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,
)
}
}
}
}
}

View File

@ -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) => {

View File

@ -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)),

View File

@ -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,

View File

@ -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,