diff --git a/app/gui2/e2e/selectingNodes.spec.ts b/app/gui2/e2e/selectingNodes.spec.ts index 6b60754000..5cca8be281 100644 --- a/app/gui2/e2e/selectingNodes.spec.ts +++ b/app/gui2/e2e/selectingNodes.spec.ts @@ -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' diff --git a/app/gui2/shared/yjsModel.ts b/app/gui2/shared/yjsModel.ts index 609962081a..e262990087 100644 --- a/app/gui2/shared/yjsModel.ts +++ b/app/gui2/shared/yjsModel.ts @@ -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)) ) } diff --git a/app/gui2/src/bindings.ts b/app/gui2/src/bindings.ts index 0f96b8ee51..4926981c11 100644 --- a/app/gui2/src/bindings.ts +++ b/app/gui2/src/bindings.ts @@ -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'], diff --git a/app/gui2/src/components/ComponentBrowser.vue b/app/gui2/src/components/ComponentBrowser.vue index 29b8a9e99c..1d0fa30ec3 100644 --- a/app/gui2/src/components/ComponentBrowser.vue +++ b/app/gui2/src/components/ComponentBrowser.vue @@ -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" diff --git a/app/gui2/src/components/GraphEditor.vue b/app/gui2/src/components/GraphEditor.vue index 789926de7f..0f263f71fc 100644 --- a/app/gui2/src/components/GraphEditor.vue +++ b/app/gui2/src/components/GraphEditor.vue @@ -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 diff --git a/app/gui2/src/components/GraphEditor/GraphNode.vue b/app/gui2/src/components/GraphEditor/GraphNode.vue index f79dea2f19..cb399e6cb9 100644 --- a/app/gui2/src/components/GraphEditor/GraphNode.vue +++ b/app/gui2/src/components/GraphEditor/GraphNode.vue @@ -55,6 +55,8 @@ const emit = defineEmits<{ 'update:visualizationId': [id: Opt] '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(() => 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)" />
(() => { @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 })" /> +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 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(null) +let width = ref>(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() + +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()) + }, +)