Visualization Preview (#10310)

Fixes #8533

Visualization is shown when pressing ctrl (cmd on mac) and hovering output port.

[Screencast from 2024-06-18 17-06-59.webm](https://github.com/enso-org/enso/assets/3919101/4963eaa7-8b96-4fdb-af09-70de68c6df57)
This commit is contained in:
Adam Obuchowicz 2024-06-19 15:46:51 +02:00 committed by GitHub
parent 7eeea8e1c8
commit ef2f5f993d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 96 additions and 32 deletions

View File

@ -11,6 +11,8 @@
- The documentation editor supports [rendering images][10205].
- [Fixed a bug where drop-down were not displayed for some arguments][10297].
For example, `locale` parameter of `Equal_Ignore_Case` kind in join component.
- [Node previews][10310]: Node may be previewed by hovering output port while
pressing <kbd>Ctrl</kbd> key (<kbd>Cmd</kbd> on macOS).
[10064]: https://github.com/enso-org/enso/pull/10064
[10179]: https://github.com/enso-org/enso/pull/10179
@ -18,6 +20,7 @@
[10198]: https://github.com/enso-org/enso/pull/10198
[10205]: https://github.com/enso-org/enso/pull/10205
[10297]: https://github.com/enso-org/enso/pull/10297
[10310]: https://github.com/enso-org/enso/pull/10310
#### Enso Standard Library

View File

@ -5,7 +5,7 @@ import { computedContent } from './css'
import { expect } from './customExpect'
import * as locate from './locate'
test('node can open and load visualization', async ({ page }) => {
test('Node can open and load visualization', async ({ page }) => {
await actions.goToGraph(page)
const node = locate.graphNode(page).last()
await node.click({ position: { x: 8, y: 8 } })
@ -25,6 +25,29 @@ test('node can open and load visualization', async ({ page }) => {
expect(typeof jsonContent).toBe('object')
})
test('Previewing visualization', async ({ page }) => {
await actions.goToGraph(page)
const node = locate.graphNode(page).last()
const port = await locate.outputPortCoordinates(node)
await page.keyboard.down('Meta')
await page.keyboard.down('Control')
await expect(locate.anyVisualization(page)).not.toBeVisible()
await page.mouse.move(port.x, port.y)
await expect(locate.anyVisualization(node)).toBeVisible()
await page.keyboard.up('Meta')
await page.keyboard.up('Control')
await expect(locate.anyVisualization(page)).not.toBeVisible()
await page.keyboard.down('Meta')
await page.keyboard.down('Control')
await expect(locate.anyVisualization(node)).toBeVisible()
await page.mouse.move(1, 1)
await expect(locate.anyVisualization(page)).not.toBeVisible()
await page.keyboard.up('Meta')
await page.keyboard.up('Control')
await page.mouse.move(port.x, port.y)
await expect(locate.anyVisualization(page)).not.toBeVisible()
})
test('Warnings visualization', async ({ page }) => {
await actions.goToGraph(page)

View File

@ -11,14 +11,14 @@ const props = defineProps<{
isRecordingEnabledGlobally: boolean
isRecordingOverridden: boolean
isDocsVisible: boolean
isVisualizationVisible: boolean
isVisualizationEnabled: boolean
isFullMenuVisible: boolean
matchableNodeColors: Set<string>
}>()
const emit = defineEmits<{
'update:isRecordingOverridden': [isRecordingOverridden: boolean]
'update:isDocsVisible': [isDocsVisible: boolean]
'update:isVisualizationVisible': [isVisualizationVisible: boolean]
'update:isVisualizationEnabled': [isVisualizationEnabled: boolean]
startEditing: []
startEditingComment: []
openFullMenu: []
@ -62,8 +62,8 @@ const showColorPicker = ref(false)
icon="eye"
class="slot5"
title="Visualization"
:modelValue="props.isVisualizationVisible"
@update:modelValue="emit('update:isVisualizationVisible', $event)"
:modelValue="props.isVisualizationEnabled"
@update:modelValue="emit('update:isVisualizationEnabled', $event)"
/>
<SvgButton
name="edit"
@ -90,7 +90,7 @@ const showColorPicker = ref(false)
/>
</div>
<SmallPlusButton
v-if="!isVisualizationVisible"
v-if="!isVisualizationEnabled"
class="below-slot5"
@createNodes="emit('createNodes', $event)"
/>

View File

@ -19,6 +19,7 @@ import NodeWidgetTree, {
import SvgIcon from '@/components/SvgIcon.vue'
import { useDoubleClick } from '@/composables/doubleClick'
import { usePointer, useResizeObserver } from '@/composables/events'
import { useKeyboard } from '@/composables/keyboard'
import { injectGraphNavigator } from '@/providers/graphNavigator'
import { injectNodeColors } from '@/providers/graphNodeColors'
import { injectGraphSelection } from '@/providers/graphSelection'
@ -69,7 +70,7 @@ const emit = defineEmits<{
'update:hoverAnim': [progress: number]
'update:visualizationId': [id: Opt<VisualizationIdentifier>]
'update:visualizationRect': [rect: Rect | undefined]
'update:visualizationVisible': [visible: boolean]
'update:visualizationEnabled': [enabled: boolean]
'update:visualizationFullscreen': [fullscreen: boolean]
'update:visualizationWidth': [width: number]
'update:visualizationHeight': [height: number]
@ -220,9 +221,23 @@ function openFullMenu() {
}
const isDocsVisible = ref(false)
const outputHovered = ref(false)
const keyboard = useKeyboard()
const visualizationWidth = computed(() => props.node.vis?.width ?? null)
const visualizationHeight = computed(() => props.node.vis?.height ?? null)
const isVisualizationVisible = computed(() => props.node.vis?.visible ?? false)
const isVisualizationEnabled = computed(() => props.node.vis?.visible ?? false)
const isVisualizationPreviewed = computed(
() => keyboard.mod && outputHovered.value && !isVisualizationEnabled.value,
)
const isVisualizationVisible = computed(
() => isVisualizationEnabled.value || isVisualizationPreviewed.value,
)
watch(isVisualizationPreviewed, (newVal, oldVal) => {
if (newVal && !oldVal) {
graph.db.moveNodeToTop(nodeId.value)
}
})
const isVisualizationFullscreen = computed(() => props.node.vis?.fullscreen ?? false)
const bgStyleVariables = computed(() => {
@ -384,12 +399,12 @@ const { getNodeColor, getNodeColors } = injectNodeColors()
const matchableNodeColors = getNodeColors((node) => node !== nodeId.value)
const graphSelectionSize = computed(() =>
isVisualizationVisible.value && visRect.value ? visRect.value.size : nodeSize.value,
isVisualizationEnabled.value && visRect.value ? visRect.value.size : nodeSize.value,
)
const nodeRect = computed(() => new Rect(props.node.position, nodeSize.value))
const nodeOuterRect = computed(() =>
isVisualizationVisible.value && visRect.value ? visRect.value : nodeRect.value,
isVisualizationEnabled.value && visRect.value ? visRect.value : nodeRect.value,
)
watchEffect(() => {
if (!nodeOuterRect.value.size.isZero()) {
@ -405,14 +420,13 @@ watchEffect(() => {
class="GraphNode"
:style="{
transform,
minWidth: isVisualizationVisible ? `${visualizationWidth ?? 200}px` : undefined,
minWidth: isVisualizationEnabled ? `${visualizationWidth ?? 200}px` : undefined,
'--node-group-color': color,
...(node.zIndex ? { 'z-index': node.zIndex } : {}),
}"
:class="{
selected,
selectionVisible,
visualizationVisible: isVisualizationVisible,
['executionState-' + executionState]: true,
}"
:data-node-id="nodeId"
@ -452,11 +466,11 @@ watchEffect(() => {
v-model:isRecordingOverridden="isRecordingOverridden"
v-model:isDocsVisible="isDocsVisible"
:isRecordingEnabledGlobally="projectStore.isRecordingEnabled"
:isVisualizationVisible="isVisualizationVisible"
:isVisualizationEnabled="isVisualizationEnabled"
:isFullMenuVisible="menuVisible && menuFull"
:nodeColor="getNodeColor(nodeId)"
:matchableNodeColors="matchableNodeColors"
@update:isVisualizationVisible="emit('update:visualizationVisible', $event)"
@update:isVisualizationEnabled="emit('update:visualizationEnabled', $event)"
@startEditing="startEditingNode"
@startEditingComment="editingComment = true"
@openFullMenu="openFullMenu"
@ -479,9 +493,10 @@ watchEffect(() => {
:width="visualizationWidth"
:height="visualizationHeight"
:isFocused="isOnlyOneSelected"
:isPreview="isVisualizationPreviewed"
@update:rect="updateVisualizationRect"
@update:id="emit('update:visualizationId', $event)"
@update:visible="emit('update:visualizationVisible', $event)"
@update:enabled="emit('update:visualizationEnabled', $event)"
@update:fullscreen="emit('update:visualizationFullscreen', $event)"
@update:width="emit('update:visualizationWidth', $event)"
@update:height="emit('update:visualizationHeight', $event)"
@ -531,6 +546,7 @@ watchEffect(() => {
@portClick="(...args) => emit('outputPortClick', ...args)"
@portDoubleClick="(...args) => emit('outputPortDoubleClick', ...args)"
@update:hoverAnim="emit('update:hoverAnim', $event)"
@update:nodeHovered="outputHovered = $event"
/>
</svg>
</div>

View File

@ -4,7 +4,15 @@ import { useDoubleClick } from '@/composables/doubleClick'
import { useGraphStore, type NodeId } from '@/stores/graph'
import { setIfUndefined } from 'lib0/map'
import type { AstId } from 'shared/ast'
import { computed, effectScope, onScopeDispose, ref, watchEffect, type EffectScope } from 'vue'
import {
computed,
effectScope,
onScopeDispose,
ref,
watch,
watchEffect,
type EffectScope,
} from 'vue'
const props = defineProps<{ nodeId: NodeId; forceVisible: boolean }>()
@ -12,6 +20,7 @@ const emit = defineEmits<{
portClick: [event: PointerEvent, portId: AstId]
portDoubleClick: [event: PointerEvent, portId: AstId]
'update:hoverAnim': [progress: number]
'update:nodeHovered': [hovered: boolean]
}>()
const graph = useGraphStore()
@ -47,6 +56,11 @@ const outputPorts = computed((): PortData[] => {
const mouseOverOutput = ref<AstId>()
const outputHovered = computed(() => (graph.mouseEditedEdge ? undefined : mouseOverOutput.value))
watch(outputHovered, (newVal, oldVal) => {
if ((newVal != null) !== (oldVal != null)) {
emit('update:nodeHovered', newVal != null)
}
})
const anyPortDisconnected = computed(() => {
for (const port of outputPortsSet.value) {

View File

@ -79,7 +79,7 @@ const uploadingFiles = computed<[FileName, File][]>(() => {
graphStore.setNodeVisualization(id, $event != null ? { identifier: $event } : {})
"
@update:visualizationRect="graphStore.updateVizRect(id, $event)"
@update:visualizationVisible="graphStore.setNodeVisualization(id, { visible: $event })"
@update:visualizationEnabled="graphStore.setNodeVisualization(id, { visible: $event })"
@update:visualizationFullscreen="graphStore.setNodeVisualization(id, { fullscreen: $event })"
@update:visualizationWidth="graphStore.setNodeVisualization(id, { width: $event })"
@update:visualizationHeight="graphStore.setNodeVisualization(id, { height: $event })"

View File

@ -51,6 +51,7 @@ type RawDataSource = { type: 'raw'; data: any }
const props = defineProps<{
currentType?: Opt<VisualizationIdentifier>
isCircularMenuVisible: boolean
isPreview?: boolean
nodePosition: Vec2
nodeSize: Vec2
width: Opt<number>
@ -64,7 +65,7 @@ const props = defineProps<{
const emit = defineEmits<{
'update:rect': [rect: Rect | undefined]
'update:id': [id: VisualizationIdentifier]
'update:visible': [visible: boolean]
'update:enabled': [visible: boolean]
'update:fullscreen': [fullscreen: boolean]
'update:width': [width: number]
'update:height': [height: number]
@ -310,7 +311,10 @@ provideVisualizationConfig({
get nodeType() {
return props.typename
},
hide: () => emit('update:visible', false),
get isPreview() {
return props.isPreview ?? false
},
hide: () => emit('update:enabled', false),
updateType: (id) => emit('update:id', id),
createNodes: (...options) => emit('createNodes', options),
})

View File

@ -103,6 +103,7 @@ const contentStyle = computed(() => {
:style="{
'--color-visualization-bg': config.background,
'--node-height': `${config.nodeSize.y}px`,
...(config.isPreview ? { pointerEvents: 'none' } : {}),
}"
>
<div
@ -115,6 +116,7 @@ const contentStyle = computed(() => {
<slot></slot>
</div>
<ResizeHandles
v-if="!config.isPreview"
v-model="clientBounds"
left
right
@ -128,6 +130,7 @@ const contentStyle = computed(() => {
/>
<div class="toolbars">
<div
v-if="!config.isPreview"
:class="{
toolbar: true,
invisible: config.isCircularMenuVisible,
@ -136,7 +139,7 @@ const contentStyle = computed(() => {
>
<SvgButton name="eye" alt="Hide visualization" @click.stop="config.hide()" />
</div>
<div class="toolbar">
<div v-if="!config.isPreview" class="toolbar">
<SvgButton
:name="config.fullscreen ? 'exit_fullscreen' : 'fullscreen'"
:title="config.fullscreen ? 'Exit Fullscreen' : 'Fullscreen'"
@ -162,7 +165,7 @@ const contentStyle = computed(() => {
</Suspense>
</div>
</div>
<div v-if="$slots.toolbar" class="visualization-defined-toolbars">
<div v-if="$slots.toolbar && !config.isPreview" class="visualization-defined-toolbars">
<div class="toolbar"><slot name="toolbar"></slot></div>
</div>
<div

View File

@ -17,6 +17,7 @@ export interface VisualizationConfig {
readonly scale: number
readonly isFocused: boolean
readonly nodeType: string | undefined
readonly isPreview: boolean
isBelowToolbar: boolean
width: number
height: number

View File

@ -21,7 +21,7 @@ const isVisualizationVisible = ref(false)
<CircularMenu
v-model:is-auto-evaluation-disabled="isAutoEvaluationDisabled"
v-model:is-docs-visible="isDocsVisible"
v-model:is-visualization-visible="isVisualizationVisible"
v-model:is-visualization-enabled="isVisualizationVisible"
@update:isAutoEvaluationDisabled="logEvent('update:isAutoEvaluationDisabled', [$event])"
@update:isDocsVisible="logEvent('update:isDocsVisible', [$event])"
@update:isVisualizationVisible="logEvent('update:isVisualizationVisible', [$event])"

20
package-lock.json generated
View File

@ -486,16 +486,6 @@
"node": ">=12"
}
},
"app/gui2/node_modules/@vue/test-utils": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz",
"integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==",
"dev": true,
"dependencies": {
"js-beautify": "^1.14.9",
"vue-component-type-helpers": "^2.0.0"
}
},
"app/gui2/node_modules/@lexical/clipboard": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.16.0.tgz",
@ -618,6 +608,16 @@
"lexical": "0.16.0"
}
},
"app/gui2/node_modules/@vue/test-utils": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz",
"integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==",
"dev": true,
"dependencies": {
"js-beautify": "^1.14.9",
"vue-component-type-helpers": "^2.0.0"
}
},
"app/gui2/node_modules/lexical": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/lexical/-/lexical-0.16.0.tgz",