Small visualization fixes (#9130)

Closes #9009

- [x] Fixed big white space above full screen viz.
- [x] Escape closes the full screen visualization.
- [x] Viz shortcuts (Shift-Space for toggling fullscreen vis, Ctrl-Space for switching vis type) are implemented.
- [x] The width of visualizations is preserved across project reopens (do we need height as well?)

New video:


https://github.com/enso-org/enso/assets/6566674/d9036ce9-57a4-429b-9bd9-6392782136ea

Older videos:

https://github.com/enso-org/enso/assets/6566674/d7129307-0626-4343-8a76-b9bf764c6a5b


https://github.com/enso-org/enso/assets/6566674/0518d3d8-9ed1-4e6c-bbe0-b7ed00bf7db3

# Important Notes
- Metadata format changed in backward-compatible way
This commit is contained in:
Ilya Bogdanov 2024-02-29 20:37:40 +04:00 committed by GitHub
parent d7e8f271eb
commit c44b7f2c2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 162 additions and 58 deletions

View File

@ -1,6 +1,5 @@
import { expect, test } from '@playwright/test'
import assert from 'assert'
import { nextTick } from 'vue'
import * as actions from './actions'
import * as customExpect from './customExpect'
import * as locate from './locate'

View File

@ -21,6 +21,8 @@ export interface VisualizationIdentifier {
export interface VisualizationMetadata {
identifier: VisualizationIdentifier | null
visible: boolean
fullscreen: boolean
width: number | null
}
export function visMetadataEquals(
@ -29,7 +31,12 @@ export function visMetadataEquals(
) {
return (
(!a && !b) ||
(a && b && a.visible === b.visible && visIdentifierEquals(a.identifier, b.identifier))
(a &&
b &&
a.visible === b.visible &&
a.fullscreen == b.fullscreen &&
a.width == b.width &&
visIdentifierEquals(a.identifier, b.identifier))
)
}

View File

@ -25,6 +25,7 @@ export const graphBindings = defineKeybinds('graph-editor', {
openComponentBrowser: ['Enter'],
newNode: ['N'],
toggleVisualization: ['Space'],
toggleVisualizationFullscreen: ['Shift+Space'],
deleteSelected: ['OsDelete'],
zoomToSelected: ['Mod+Shift+A'],
selectAll: ['Mod+A'],
@ -38,6 +39,12 @@ export const graphBindings = defineKeybinds('graph-editor', {
exitNode: ['Mod+Shift+E'],
})
export const visualizationBindings = defineKeybinds('visualization', {
nextType: ['Mod+Space'],
toggleFullscreen: ['Shift+Space'],
exitFullscreen: ['Escape'],
})
export const selectionMouseBindings = defineKeybinds('selection', {
replace: ['PointerMain'],
add: ['Mod+Shift+PointerMain'],

View File

@ -497,6 +497,9 @@ const handler = componentBrowserBindings.handler({
:nodePosition="nodePosition"
:scale="1"
:isCircularMenuVisible="false"
:isFullscreen="false"
:isFocused="true"
:width="null"
:dataSource="previewDataSource"
:typename="previewedSuggestionReturnType"
:currentType="previewedVisualizationId"

View File

@ -238,17 +238,24 @@ const graphBindingsHandler = graphBindings.handler({
graphStore.stopCapturingUndo()
},
toggleVisualization() {
if (keyboardBusy()) return false
graphStore.transact(() => {
const allVisible = set
.toArray(nodeSelection.selected)
.every((id) => !(graphStore.db.nodeIdToNode.get(id)?.vis?.visible !== true))
for (const nodeId of nodeSelection.selected) {
graphStore.setNodeVisualizationVisible(nodeId, !allVisible)
graphStore.setNodeVisualization(nodeId, { visible: !allVisible })
}
})
},
toggleVisualizationFullscreen() {
if (nodeSelection.selected.size !== 1) return
graphStore.transact(() => {
const selected = set.first(nodeSelection.selected)
const isFullscreen = graphStore.db.nodeIdToNode.get(selected)?.vis?.fullscreen
graphStore.setNodeVisualization(selected, { visible: true, fullscreen: !isFullscreen })
})
},
copyNode() {
if (keyboardBusy()) return false
copyNodeContent()
@ -319,6 +326,9 @@ const graphBindingsHandler = graphBindings.handler({
const { handleClick } = useDoubleClick(
(e: MouseEvent) => {
graphBindingsHandler(e)
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
},
() => {
if (keyboardBusy()) return false

View File

@ -55,6 +55,8 @@ const emit = defineEmits<{
'update:visualizationId': [id: Opt<VisualizationIdentifier>]
'update:visualizationRect': [rect: Rect | undefined]
'update:visualizationVisible': [visible: boolean]
'update:visualizationFullscreen': [fullscreen: boolean]
'update:visualizationWidth': [width: number]
}>()
const nodeSelection = injectGraphSelection(true)
@ -119,6 +121,7 @@ const warning = computed(() => {
})
const isSelected = computed(() => nodeSelection?.isSelected(nodeId.value) ?? false)
const isOnlyOneSelected = computed(() => isSelected.value && nodeSelection?.selected.size === 1)
watch(isSelected, (selected) => {
if (!selected) {
menuVisible.value = MenuState.Off
@ -126,7 +129,9 @@ watch(isSelected, (selected) => {
})
const isDocsVisible = ref(false)
const visualizationWidth = computed(() => props.node.vis?.width ?? null)
const isVisualizationVisible = computed(() => props.node.vis?.visible ?? false)
const isVisualizationFullscreen = computed(() => props.node.vis?.fullscreen ?? false)
watchEffect(() => {
const size = nodeSize.value
@ -411,14 +416,19 @@ const documentation = computed<string | undefined>(() => props.node.documentatio
:nodePosition="props.node.position"
:isCircularMenuVisible="menuVisible === MenuState.Full || menuVisible === MenuState.Partial"
:currentType="node.vis?.identifier"
:isFullscreen="isVisualizationFullscreen"
:dataSource="{ type: 'node', nodeId: externalId }"
:typename="expressionInfo?.typename"
:width="visualizationWidth"
:isFocused="isOnlyOneSelected"
@update:rect="
emit('update:visualizationRect', $event),
(widthOverridePx = $event && $event.size.x > baseNodeSize.x ? $event.size.x : undefined)
"
@update:id="emit('update:visualizationId', $event)"
@update:visible="emit('update:visualizationVisible', $event)"
@update:fullscreen="emit('update:visualizationFullscreen', $event)"
@update:width="emit('update:visualizationWidth', $event)"
/>
<GraphNodeComment v-if="documentation" v-model="documentation" class="beforeNode" />
<div

View File

@ -55,9 +55,13 @@ const uploadingFiles = computed<[FileName, File][]>(() => {
@doubleClick="emit('nodeDoubleClick', id)"
@update:edited="graphStore.setEditedNode(id, $event)"
@update:rect="graphStore.updateNodeRect(id, $event)"
@update:visualizationId="graphStore.setNodeVisualizationId(id, $event)"
@update:visualizationId="
graphStore.setNodeVisualization(id, $event != null ? { identifier: $event } : {})
"
@update:visualizationRect="graphStore.updateVizRect(id, $event)"
@update:visualizationVisible="graphStore.setNodeVisualizationVisible(id, $event)"
@update:visualizationVisible="graphStore.setNodeVisualization(id, { visible: $event })"
@update:visualizationFullscreen="graphStore.setNodeVisualization(id, { fullscreen: $event })"
@update:visualizationWidth="graphStore.setNodeVisualization(id, { width: $event })"
/>
<UploadingFile
v-for="(nameAndFile, index) in uploadingFiles"

View File

@ -1,6 +1,8 @@
<script setup lang="ts">
import { visualizationBindings } from '@/bindings'
import LoadingErrorVisualization from '@/components/visualizations/LoadingErrorVisualization.vue'
import LoadingVisualization from '@/components/visualizations/LoadingVisualization.vue'
import { focusIsIn, useEvent } from '@/composables/events'
import { provideVisualizationConfig } from '@/providers/visualizationConfig'
import { useProjectStore, type NodeVisualizationConfiguration } from '@/stores/project'
import {
@ -18,11 +20,13 @@ import type { Result } from '@/util/data/result'
import type { URLString } from '@/util/data/urlString'
import { Vec2 } from '@/util/data/vec2'
import type { Icon } from '@/util/iconName'
import { debouncedGetter } from '@/util/reactivity'
import { computedAsync } from '@vueuse/core'
import { isIdentifier } from 'shared/ast'
import type { VisualizationIdentifier } from 'shared/yjsModel'
import { visIdentifierEquals, type VisualizationIdentifier } from 'shared/yjsModel'
import {
computed,
nextTick,
onErrorCaptured,
onUnmounted,
ref,
@ -43,7 +47,10 @@ const props = defineProps<{
isCircularMenuVisible: boolean
nodePosition: Vec2
nodeSize: Vec2
width: Opt<number>
scale: number
isFocused: boolean
isFullscreen: boolean
typename?: string | undefined
dataSource: VisualizationDataSource | RawDataSource | undefined
}>()
@ -51,6 +58,8 @@ const emit = defineEmits<{
'update:rect': [rect: Rect | undefined]
'update:id': [id: VisualizationIdentifier]
'update:visible': [visible: boolean]
'update:fullscreen': [fullscreen: boolean]
'update:width': [width: number]
}>()
const visPreprocessor = ref(DEFAULT_VISUALIZATION_CONFIGURATION)
@ -216,8 +225,11 @@ watchEffect(async () => {
})
const isBelowToolbar = ref(false)
let width = ref<number | null>(null)
let width = ref<Opt<number>>(props.width)
let height = ref(150)
// We want to debounce width changes, because they are saved to the metadata.
const debouncedWidth = debouncedGetter(() => width.value, 300)
watch(debouncedWidth, (value) => value != null && emit('update:width', value))
watchEffect(() =>
emit(
@ -234,13 +246,23 @@ watchEffect(() =>
onUnmounted(() => emit('update:rect', undefined))
const allTypes = computed(() => Array.from(visualizationStore.types(props.typename)))
provideVisualizationConfig({
fullscreen: false,
get isFocused() {
return props.isFocused
},
get fullscreen() {
return props.isFullscreen
},
set fullscreen(value) {
emit('update:fullscreen', value)
},
get scale() {
return props.scale
},
get width() {
return width.value
return width.value ?? null
},
set width(value) {
width.value = value
@ -258,7 +280,7 @@ provideVisualizationConfig({
isBelowToolbar.value = value
},
get types() {
return Array.from(visualizationStore.types(props.typename))
return allTypes.value
},
get isCircularMenuVisible() {
return props.isCircularMenuVisible
@ -289,10 +311,49 @@ const effectiveVisualization = computed(() => {
}
return visualization.value
})
const root = ref<HTMLElement>()
const keydownHandler = visualizationBindings.handler({
nextType: () => {
if (props.isFocused || focusIsIn(root.value)) {
const currentIndex = allTypes.value.findIndex((type) =>
visIdentifierEquals(type, currentType.value),
)
const nextIndex = (currentIndex + 1) % allTypes.value.length
emit('update:id', allTypes.value[nextIndex]!)
} else {
return false
}
},
toggleFullscreen: () => {
if (props.isFocused || focusIsIn(root.value)) {
emit('update:fullscreen', !props.isFullscreen)
} else {
return false
}
},
exitFullscreen: () => {
if (props.isFullscreen) {
emit('update:fullscreen', false)
} else {
return false
}
},
})
useEvent(window, 'keydown', (event) => keydownHandler(event))
watch(
() => props.isFullscreen,
(f) => {
f && nextTick(() => root.value?.focus())
},
)
</script>
<template>
<div class="GraphVisualization">
<div ref="root" class="GraphVisualization" tabindex="-1">
<Suspense>
<template #fallback><LoadingVisualization :data="{}" /></template>
<component

View File

@ -49,7 +49,7 @@ function blur(event: Event) {
const rootNode = ref<HTMLElement>()
const contentNode = ref<HTMLElement>()
onMounted(() => (config.width = Math.max(config.nodeSize.x, MIN_WIDTH_PX)))
onMounted(() => (config.width = Math.max(config.width ?? config.nodeSize.x, MIN_WIDTH_PX)))
function hideSelector() {
requestAnimationFrame(() => (isSelectorVisible.value = false))
@ -226,7 +226,7 @@ const resizeBottomRight = usePointer((pos, _, type) => {
}
.VisualizationContainer.fullscreen.below-toolbar {
padding-top: 78px;
padding-top: 40px;
}
.toolbars {
@ -256,7 +256,7 @@ const resizeBottomRight = usePointer((pos, _, type) => {
}
.VisualizationContainer.fullscreen .toolbars {
top: 40px;
top: 4px;
}
.toolbar {

View File

@ -72,10 +72,10 @@ import { useAutoBlur } from '@/util/autoBlur'
import { VisualizationContainer } from '@/util/visualizationBuiltins'
import '@ag-grid-community/styles/ag-grid.css'
import '@ag-grid-community/styles/ag-theme-alpine.css'
import type { ColumnResizedEvent } from 'ag-grid-community'
import { Grid, type ColumnResizedEvent } from 'ag-grid-community'
import type { ColDef, GridOptions, HeaderValueGetterParams } from 'ag-grid-enterprise'
import { computed, onMounted, reactive, ref, watchEffect, type Ref } from 'vue'
const { Grid, LicenseManager } = await import('ag-grid-enterprise')
import { computed, onMounted, onUnmounted, reactive, ref, watchEffect, type Ref } from 'vue'
const { LicenseManager } = await import('ag-grid-enterprise')
const props = defineProps<{ data: Data }>()
const emit = defineEmits<{
@ -374,10 +374,14 @@ onMounted(() => {
LicenseManager.setLicenseKey(window.AG_GRID_LICENSE_KEY)
} else {
console.warn('The AG_GRID_LICENSE_KEY is not defined.')
new Grid(tableNode.value!, agGridOptions.value)
}
new Grid(tableNode.value!, agGridOptions.value)
updateColumnWidths()
})
onUnmounted(() => {
agGridOptions.value.api?.destroy()
})
</script>
<template>

View File

@ -111,8 +111,8 @@ export function keyboardBusy() {
}
/** Whether focused element is within given element's subtree. */
export function focusIsIn(el: Element) {
return el.contains(document.activeElement)
export function focusIsIn(el: Element | undefined | null) {
return el && el.contains(document.activeElement)
}
/**
@ -132,7 +132,7 @@ export function modKey(e: KeyboardEvent): boolean {
}
/** A helper for getting Element out of VueInstance, it allows using `useResizeObserver` with Vue components. */
function unrefElement(
export function unrefElement(
element: Ref<Element | undefined | null | VueInstance>,
): Element | undefined | null {
const plain = toValue(element)

View File

@ -2,12 +2,11 @@
import { selectionMouseBindings } from '@/bindings'
import { useEvent, usePointer } from '@/composables/events'
import type { NavigatorComposable } from '@/composables/navigator'
import type { PortId } from '@/providers/portInfo.ts'
import { type NodeId } from '@/stores/graph'
import type { Rect } from '@/util/data/rect'
import type { Vec2 } from '@/util/data/vec2'
import { computed, proxyRefs, reactive, ref, shallowRef, watch, type Ref } from 'vue'
import { computed, proxyRefs, reactive, ref, shallowRef } from 'vue'
export type SelectionComposable<T> = ReturnType<typeof useSelection<T>>
export function useSelection<T>(

View File

@ -14,6 +14,7 @@ export interface VisualizationConfig {
readonly isCircularMenuVisible: boolean
readonly nodeSize: Vec2
readonly scale: number
readonly isFocused: boolean
isBelowToolbar: boolean
width: number | null
height: number

View File

@ -29,12 +29,7 @@ import { iteratorFilter } from 'lib0/iterator'
import { defineStore } from 'pinia'
import { SourceDocument } from 'shared/ast/sourceDocument'
import type { ExpressionUpdate, StackItem } from 'shared/languageServerTypes'
import type {
LocalOrigin,
SourceRangeKey,
VisualizationIdentifier,
VisualizationMetadata,
} from 'shared/yjsModel'
import type { LocalOrigin, SourceRangeKey, VisualizationMetadata } from 'shared/yjsModel'
import { defaultLocalOrigin, sourceRangeKey, visMetadataEquals } from 'shared/yjsModel'
import { computed, markRaw, reactive, ref, toRef, watch, type ShallowRef } from 'vue'
@ -300,34 +295,31 @@ export const useGraphStore = defineStore('graph', () => {
}
function normalizeVisMetadata(
id: Opt<VisualizationIdentifier>,
visible: boolean | undefined,
partial: Partial<VisualizationMetadata>,
): VisualizationMetadata | undefined {
const vis: VisualizationMetadata = { identifier: id ?? null, visible: visible ?? false }
if (visMetadataEquals(vis, { identifier: null, visible: false })) return undefined
const empty: VisualizationMetadata = {
identifier: null,
visible: false,
fullscreen: false,
width: null,
}
const vis: VisualizationMetadata = { ...empty, ...partial }
if (visMetadataEquals(vis, empty)) return undefined
else return vis
}
function setNodeVisualizationId(nodeId: NodeId, vis: Opt<VisualizationIdentifier>) {
function setNodeVisualization(nodeId: NodeId, vis: Partial<VisualizationMetadata>) {
const nodeAst = syncModule.value?.tryGet(nodeId)
if (!nodeAst) return
editNodeMetadata(nodeAst, (metadata) =>
metadata.set(
'visualization',
normalizeVisMetadata(vis, metadata.get('visualization')?.visible),
),
)
}
function setNodeVisualizationVisible(nodeId: NodeId, visible: boolean) {
const nodeAst = syncModule.value?.tryGet(nodeId)
if (!nodeAst) return
editNodeMetadata(nodeAst, (metadata) =>
metadata.set(
'visualization',
normalizeVisMetadata(metadata.get('visualization')?.identifier, visible),
),
)
editNodeMetadata(nodeAst, (metadata) => {
const data: Partial<VisualizationMetadata> = {
identifier: vis.identifier ?? metadata.get('visualization')?.identifier ?? null,
visible: vis.visible ?? metadata.get('visualization')?.visible ?? false,
fullscreen: vis.fullscreen ?? metadata.get('visualization')?.fullscreen ?? false,
width: vis.width ?? metadata.get('visualization')?.width ?? null,
}
metadata.set('visualization', normalizeVisMetadata(data))
})
}
function updateNodeRect(nodeId: NodeId, rect: Rect) {
@ -584,8 +576,7 @@ export const useGraphStore = defineStore('graph', () => {
ensureCorrectNodeOrder,
setNodeContent,
setNodePosition,
setNodeVisualizationId,
setNodeVisualizationVisible,
setNodeVisualization,
stopCapturingUndo,
topLevel,
updateNodeRect,

View File

@ -106,13 +106,17 @@ function translateVisualizationToFile(
case 'Library':
project = { project: 'Library', contents: vis.identifier.module.name } as const
break
default:
return { show: vis.visible }
}
return {
name: vis.identifier.name,
show: vis.visible,
project,
fullscreen: vis.fullscreen,
width: vis.width ?? undefined,
...(project == null || vis.identifier == null
? {}
: {
project: project,
name: vis.identifier.name,
}),
}
}
@ -136,6 +140,8 @@ export function translateVisualizationFromFile(
return {
identifier: module && vis.name ? { name: vis.name, module } : null,
visible: vis.show,
fullscreen: vis.fullscreen ?? false,
width: vis.width ?? null,
}
}

View File

@ -14,6 +14,8 @@ export type VisualizationMetadata = z.infer<typeof visualizationMetadata>
const visualizationMetadata = z
.object({
show: z.boolean().default(true),
width: z.number().optional(),
fullscreen: z.boolean().optional(),
project: visualizationProject.optional(),
name: z.string().optional(),
})