From c74423bf38129a1f5b35db8584a5529fe08f87c3 Mon Sep 17 00:00:00 2001 From: Kaz Wesley Date: Thu, 16 May 2024 06:24:17 -0700 Subject: [PATCH] Track live selection (#9924) * Track live selection --- app/gui2/shared/util/data/iterable.ts | 6 +- .../components/ComponentBrowser/placement.ts | 4 +- app/gui2/src/components/GraphEditor.vue | 39 +++---- .../src/components/GraphEditor/GraphNodes.vue | 9 +- .../src/components/GraphEditor/clipboard.ts | 10 +- .../composables/__tests__/selection.test.ts | 10 +- app/gui2/src/composables/nodeCreation.ts | 5 +- app/gui2/src/composables/selection.ts | 109 +++++++++--------- app/gui2/src/providers/graphSelection.ts | 13 ++- app/gui2/src/stores/graph/index.ts | 2 +- app/gui2/src/util/data/iterable.ts | 5 + app/gui2/src/util/dom.ts | 18 +++ app/gui2/src/util/reactivity.ts | 5 + 13 files changed, 124 insertions(+), 111 deletions(-) create mode 100644 app/gui2/src/util/dom.ts diff --git a/app/gui2/shared/util/data/iterable.ts b/app/gui2/shared/util/data/iterable.ts index 4fb88e20de5d..1e338408acef 100644 --- a/app/gui2/shared/util/data/iterable.ts +++ b/app/gui2/shared/util/data/iterable.ts @@ -21,12 +21,16 @@ export function* range(start: number, stop: number, step = start <= stop ? 1 : - } } -export function* map(iter: Iterable, map: (value: T) => U) { +export function* map(iter: Iterable, map: (value: T) => U): IterableIterator { for (const value of iter) { yield map(value) } } +export function* filter(iter: Iterable, include: (value: T) => boolean): IterableIterator { + for (const value of iter) if (include(value)) yield value +} + export function* chain(...iters: Iterable[]) { for (const iter of iters) { yield* iter diff --git a/app/gui2/src/components/ComponentBrowser/placement.ts b/app/gui2/src/components/ComponentBrowser/placement.ts index 5f425f2969b1..8dc7b6221c31 100644 --- a/app/gui2/src/components/ComponentBrowser/placement.ts +++ b/app/gui2/src/components/ComponentBrowser/placement.ts @@ -1,8 +1,8 @@ import { assert } from '@/util/assert' import { Rect } from '@/util/data/rect' import { Vec2 } from '@/util/data/vec2' +import type { ToValue } from '@/util/reactivity' import theme from '@/util/theme.json' -import type { ComputedRef, MaybeRefOrGetter } from 'vue' import { toValue } from 'vue' // Assumed size of a newly created node. This is used to place the component browser and when creating a node before @@ -16,8 +16,6 @@ const orDefaultSize = (rect: Rect) => { return new Rect(rect.pos, new Vec2(width, height)) } -type ToValue = MaybeRefOrGetter | ComputedRef - export function usePlacement(nodeRects: ToValue>, screenBounds: ToValue) { const gap = themeGap() const environment = (selectedNodeRects: Iterable) => ({ diff --git a/app/gui2/src/components/GraphEditor.vue b/app/gui2/src/components/GraphEditor.vue index 965098c3a571..8432dc618b2c 100644 --- a/app/gui2/src/components/GraphEditor.vue +++ b/app/gui2/src/components/GraphEditor.vue @@ -49,7 +49,7 @@ import { bail } from '@/util/assert' import type { AstId } from '@/util/ast/abstract' import { colorFromString } from '@/util/colors' import { partition } from '@/util/data/array' -import { filterDefined } from '@/util/data/iterable' +import { every, filterDefined } from '@/util/data/iterable' import { Rect } from '@/util/data/rect' import { Vec2 } from '@/util/data/vec2' import { encoding, set } from 'lib0' @@ -97,9 +97,8 @@ function waitInitializationAndPanToAll() { function selectionBounds() { if (!viewportNode.value) return - const allNodes = graphStore.db.nodeIdToNode - const validSelected = [...nodeSelection.selected].filter((id) => allNodes.has(id)) - const nodesToCenter = validSelected.length === 0 ? allNodes.keys() : validSelected + const selected = nodeSelection.selected + const nodesToCenter = selected.size === 0 ? graphStore.db.nodeIdToNode.keys() : selected let bounds = Rect.Bounding() for (const id of nodesToCenter) { const rect = graphStore.visibleArea(id) @@ -135,6 +134,7 @@ const nodeSelection = provideGraphSelection( graphNavigator, graphStore.nodeRects, graphStore.isPortEnabled, + (id) => graphStore.db.nodeIdToNode.has(id), { onSelected(id) { graphStore.db.moveNodeToTop(id) @@ -168,7 +168,7 @@ const { createNode, createNodes, placeNode } = provideNodeCreation( // === Clipboard Copy/Paste === const { copySelectionToClipboard, createNodesFromClipboard } = useGraphEditorClipboard( - nodeSelection, + toRef(nodeSelection, 'selected'), createNodes, ) @@ -223,17 +223,13 @@ const graphBindingsHandler = graphBindings.handler({ projectStore.lsRpcConnection.profilingStop() }, openComponentBrowser() { - if (keyboardBusy()) return false if (graphNavigator.sceneMousePos != null && !componentBrowserVisible.value) { createWithComponentBrowser(fromSelection() ?? { placement: { type: 'mouse' } }) } }, deleteSelected, - zoomToSelected() { - zoomToSelected() - }, + zoomToSelected, selectAll() { - if (keyboardBusy()) return nodeSelection.selectAll() }, deselectAll() { @@ -242,37 +238,33 @@ const graphBindingsHandler = graphBindings.handler({ graphStore.undoManager.undoStackBoundary() }, toggleVisualization() { + const selected = nodeSelection.selected + const allVisible = every( + selected, + (id) => graphStore.db.nodeIdToNode.get(id)?.vis?.visible === true, + ) graphStore.transact(() => { - const allVisible = set - .toArray(nodeSelection.selected) - .every((id) => !(graphStore.db.nodeIdToNode.get(id)?.vis?.visible !== true)) - - for (const nodeId of nodeSelection.selected) { + for (const nodeId of selected) { graphStore.setNodeVisualization(nodeId, { visible: !allVisible }) } }) }, copyNode() { - if (keyboardBusy()) return false copySelectionToClipboard() }, pasteNode() { - if (keyboardBusy()) return false createNodesFromClipboard() }, collapse() { - if (keyboardBusy()) return false collapseNodes() }, enterNode() { - if (keyboardBusy()) return false const selectedNode = set.first(nodeSelection.selected) if (selectedNode) { stackNavigator.enterNode(selectedNode) } }, exitNode() { - if (keyboardBusy()) return false stackNavigator.exitNode() }, changeColorSelectedNodes() { @@ -292,10 +284,8 @@ const { handleClick } = useDoubleClick( ) function deleteSelected() { - graphStore.transact(() => { - graphStore.deleteNodes([...nodeSelection.selected]) - nodeSelection.selected.clear() - }) + graphStore.deleteNodes(nodeSelection.selected) + nodeSelection.deselectAll() } // === Code Editor === @@ -425,6 +415,7 @@ function addNodeAuto() { function fromSelection(): NewNodeOptions | undefined { if (graphStore.editedNodeInfo != null) return undefined const firstSelectedNode = set.first(nodeSelection.selected) + if (firstSelectedNode == null) return undefined return { placement: { type: 'source', node: firstSelectedNode }, sourcePort: graphStore.db.getNodeFirstOutputPort(firstSelectedNode), diff --git a/app/gui2/src/components/GraphEditor/GraphNodes.vue b/app/gui2/src/components/GraphEditor/GraphNodes.vue index da0f49570ae6..ad79ae59ec28 100644 --- a/app/gui2/src/components/GraphEditor/GraphNodes.vue +++ b/app/gui2/src/components/GraphEditor/GraphNodes.vue @@ -4,7 +4,6 @@ import UploadingFile from '@/components/GraphEditor/UploadingFile.vue' import { useDragging } from '@/components/GraphEditor/dragging' import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation' import { injectGraphNavigator } from '@/providers/graphNavigator' -import { injectGraphSelection } from '@/providers/graphSelection' import type { UploadingFile as File, FileName } from '@/stores/awareness' import { useGraphStore, type NodeId } from '@/stores/graph' import { useProjectStore } from '@/stores/project' @@ -27,7 +26,6 @@ const emit = defineEmits<{ const projectStore = useProjectStore() const graphStore = useGraphStore() const dragging = useDragging() -const selection = injectGraphSelection(true) const navigator = injectGraphNavigator(true) function nodeIsDragged(movedId: NodeId, offset: Vec2) { @@ -35,10 +33,6 @@ function nodeIsDragged(movedId: NodeId, offset: Vec2) { dragging.startOrUpdate(movedId, scaledOffset) } -function hoverNode(id: NodeId | undefined) { - if (selection != null) selection.hoveredNode = id -} - const uploadingFiles = computed<[FileName, File][]>(() => { const currentStackItem = projectStore.executionContext.getStackTop() return [...projectStore.awareness.allUploads()].filter(([, file]) => @@ -54,8 +48,7 @@ const uploadingFiles = computed<[FileName, File][]>(() => { :node="node" :edited="id === graphStore.editedNodeInfo?.id" :graphNodeSelections="props.graphNodeSelections" - @pointerenter="hoverNode(id)" - @pointerleave="hoverNode(undefined)" + :data-node="id" @delete="graphStore.deleteNodes([id])" @dragging="nodeIsDragged(id, $event)" @draggingCommited="dragging.finishDrag()" diff --git a/app/gui2/src/components/GraphEditor/clipboard.ts b/app/gui2/src/components/GraphEditor/clipboard.ts index b2db4869e505..23ae0aa70f27 100644 --- a/app/gui2/src/components/GraphEditor/clipboard.ts +++ b/app/gui2/src/components/GraphEditor/clipboard.ts @@ -1,11 +1,11 @@ import type { NodeCreation } from '@/composables/nodeCreation' -import type { GraphSelection } from '@/providers/graphSelection' -import type { Node } from '@/stores/graph' +import type { Node, NodeId } from '@/stores/graph' import { useGraphStore } from '@/stores/graph' import { Ast } from '@/util/ast' import { Pattern } from '@/util/ast/match' +import type { ToValue } from '@/util/reactivity' import type { NodeMetadataFields } from 'shared/ast' -import { computed } from 'vue' +import { computed, toValue } from 'vue' // MIME type in *vendor tree*; see https://www.rfc-editor.org/rfc/rfc6838#section-3.2 // The `web ` prefix is required by Chromium: @@ -119,7 +119,7 @@ function getClipboard() { } export function useGraphEditorClipboard( - nodeSelection: GraphSelection, + selected: ToValue>, createNodes: NodeCreation['createNodes'], ) { const graphStore = useGraphStore() @@ -127,7 +127,7 @@ export function useGraphEditorClipboard( /** Copy the content of the selected node to the clipboard. */ function copySelectionToClipboard() { const nodes = new Array() - for (const id of nodeSelection.selected) { + for (const id of toValue(selected)) { const node = graphStore.db.nodeIdToNode.get(id) if (!node) continue nodes.push(node) diff --git a/app/gui2/src/composables/__tests__/selection.test.ts b/app/gui2/src/composables/__tests__/selection.test.ts index fea3867195e5..e886a1465bf4 100644 --- a/app/gui2/src/composables/__tests__/selection.test.ts +++ b/app/gui2/src/composables/__tests__/selection.test.ts @@ -14,8 +14,8 @@ function selectionWithMockData(sceneMousePos?: Ref) { rects.set(3, Rect.FromBounds(1, 20, 10, 30)) rects.set(4, Rect.FromBounds(20, 20, 30, 30)) const navigator = proxyRefs({ sceneMousePos: sceneMousePos ?? ref(Vec2.Zero), scale: 1 }) - const allPortsEnabled = () => true - const selection = useSelection(navigator, rects, allPortsEnabled, 0) + const allNodesValid = () => true + const selection = useSelection(navigator, rects, 0, allNodesValid) selection.setSelection(new Set([1, 2])) return selection } @@ -44,7 +44,7 @@ test.each` const selection = selectionWithMockData() // Position is zero, because this method should not depend on click position selection.handleSelectionOf(mockPointerEvent('click', Vec2.Zero, binding), new Set([click])) - expect(Array.from(selection.selected)).toEqual(expected) + expect([...selection.selected.value]).toEqual(expected) }) const areas: Record = { @@ -94,9 +94,9 @@ test.each` selection.events.pointermove(mockPointerEvent('pointermove', mousePos.value, binding)) mousePos.value = stop selection.events.pointermove(mockPointerEvent('pointermove', mousePos.value, binding)) - expect(selection.selected).toEqual(new Set(expected)) + expect(selection.selected.value).toEqual(new Set(expected)) selection.events.pointerdown(mockPointerEvent('pointerup', mousePos.value, binding)) - expect(selection.selected).toEqual(new Set(expected)) + expect(selection.selected.value).toEqual(new Set(expected)) } // We should select same set of nodes, regardless of drag direction diff --git a/app/gui2/src/composables/nodeCreation.ts b/app/gui2/src/composables/nodeCreation.ts index 37f49fd0d892..281fafca0bdf 100644 --- a/app/gui2/src/composables/nodeCreation.ts +++ b/app/gui2/src/composables/nodeCreation.ts @@ -13,9 +13,10 @@ import { partition } from '@/util/data/array' import { filterDefined } from '@/util/data/iterable' import { Rect } from '@/util/data/rect' import { Vec2 } from '@/util/data/vec2' +import type { ToValue } from '@/util/reactivity' import { assertNever } from 'shared/util/assert' import { mustExtend } from 'shared/util/types' -import { toValue, type ComputedRef, type MaybeRefOrGetter } from 'vue' +import { toValue } from 'vue' export type NodeCreation = ReturnType @@ -46,8 +47,6 @@ export interface NodeCreationOptions = MaybeRefOrGetter | ComputedRef - export function useNodeCreation( viewport: ToValue, sceneMousePos: ToValue, diff --git a/app/gui2/src/composables/selection.ts b/app/gui2/src/composables/selection.ts index c0dc5f52792d..bae64939d076 100644 --- a/app/gui2/src/composables/selection.ts +++ b/app/gui2/src/composables/selection.ts @@ -6,75 +6,45 @@ import { type NodeId } from '@/stores/graph' import type { Rect } from '@/util/data/rect' import { intersectionSize } from '@/util/data/set' import type { Vec2 } from '@/util/data/vec2' -import { computed, proxyRefs, ref, shallowReactive, shallowRef } from 'vue' +import { dataAttribute, elementHierarchy } from '@/util/dom' +import * as set from 'lib0/set' +import { filter } from 'shared/util/data/iterable' +import { computed, ref, shallowReactive, shallowRef } from 'vue' -export type SelectionComposable = ReturnType> export function useSelection( navigator: { sceneMousePos: Vec2 | null; scale: number }, elementRects: Map, - isPortEnabled: (port: PortId) => boolean, margin: number, + isValid: (element: T) => boolean, callbacks: { onSelected?: (element: T) => void onDeselected?: (element: T) => void } = {}, ) { const anchor = shallowRef() - const initiallySelected = new Set() - const selected = shallowReactive(new Set()) - const hoveredNode = ref() - const hoveredElement = ref() + let initiallySelected = new Set() + // Selection, including elements that do not (currently) pass `isValid`. + const rawSelected = shallowReactive(new Set()) + const selected = computed(() => set.from(filter(rawSelected, isValid))) const isChanging = computed(() => anchor.value != null) - const committedSelection = computed(() => (isChanging.value ? initiallySelected : selected)) - - useEvent(document, 'pointerover', (event) => { - hoveredElement.value = event.target instanceof Element ? event.target : undefined - }) - - const hoveredPort = computed(() => { - if (!hoveredElement.value) return undefined - for (const element of elementHierarchy(hoveredElement.value, '.WidgetPort')) { - const portId = elementPortId(element) - if (portId && isPortEnabled(portId)) return portId - } - return undefined - }) - - function* elementHierarchy(element: Element, selectors: string) { - for (;;) { - const match = element.closest(selectors) - if (!match) return - yield match - if (!match.parentElement) return - element = match.parentElement - } - } - - function elementPortId(element: Element): PortId | undefined { - return ( - element instanceof HTMLElement && - 'port' in element.dataset && - typeof element.dataset.port === 'string' - ) ? - (element.dataset.port as PortId) - : undefined - } + const committedSelection = computed(() => + isChanging.value ? set.from(filter(initiallySelected, isValid)) : selected.value, + ) function readInitiallySelected() { - initiallySelected.clear() - for (const id of selected) initiallySelected.add(id) + initiallySelected = set.from(rawSelected) } function setSelection(newSelection: Set) { for (const id of newSelection) - if (!selected.has(id)) { - selected.add(id) + if (!rawSelected.has(id)) { + rawSelected.add(id) callbacks.onSelected?.(id) } - for (const id of selected) + for (const id of rawSelected) if (!newSelection.has(id)) { - selected.delete(id) + rawSelected.delete(id) callbacks.onDeselected?.(id) } } @@ -169,21 +139,48 @@ export function useSelection( { predicate: (e) => e.target === e.currentTarget }, ) - return proxyRefs({ + return { + // === Selected nodes === selected, - anchor, selectAll: () => { - for (const id of elementRects.keys()) selected.add(id) + for (const id of elementRects.keys()) rawSelected.add(id) }, - deselectAll: () => selected.clear(), - isSelected: (element: T) => selected.has(element), - isChanging, + deselectAll: () => rawSelected.clear(), + isSelected: (element: T) => rawSelected.has(element), committedSelection, setSelection, + // === Selection changes === + anchor, + isChanging, + // === Mouse events === handleSelectionOf, - hoveredNode, - hoveredPort, - mouseHandler: selectionEventHandler, events: pointer.events, + } +} + +// === Hover tracking for nodes and ports === + +export function useGraphHover(isPortEnabled: (port: PortId) => boolean) { + const hoveredElement = ref() + + useEvent(document, 'pointerover', (event) => { + hoveredElement.value = event.target instanceof Element ? event.target : undefined }) + + const hoveredPort = computed(() => { + if (!hoveredElement.value) return undefined + for (const element of elementHierarchy(hoveredElement.value, '.WidgetPort')) { + const portId = dataAttribute(element, 'port') + if (portId && isPortEnabled(portId)) return portId + } + return undefined + }) + + const hoveredNode = computed(() => { + const element = hoveredElement.value?.closest('.GraphNode') + if (!element) return undefined + return dataAttribute(element, 'node') + }) + + return { hoveredNode, hoveredPort } } diff --git a/app/gui2/src/providers/graphSelection.ts b/app/gui2/src/providers/graphSelection.ts index 02a88ac70736..e6328ebdf2bd 100644 --- a/app/gui2/src/providers/graphSelection.ts +++ b/app/gui2/src/providers/graphSelection.ts @@ -1,12 +1,12 @@ import type { NavigatorComposable } from '@/composables/navigator' -import { useSelection, type SelectionComposable } from '@/composables/selection' +import { useGraphHover, useSelection } from '@/composables/selection' import { createContextStore } from '@/providers' import { type NodeId } from '@/stores/graph' import type { Rect } from '@/util/data/rect' +import { proxyRefs } from 'vue' const SELECTION_BRUSH_MARGIN_PX = 6 -export type GraphSelection = SelectionComposable export { injectFn as injectGraphSelection, provideFn as provideGraphSelection } const { provideFn, injectFn } = createContextStore( 'graph selection', @@ -14,11 +14,14 @@ const { provideFn, injectFn } = createContextStore( navigator: NavigatorComposable, nodeRects: Map, isPortEnabled, + isValid: (id: NodeId) => boolean, callbacks: { onSelected?: (id: NodeId) => void onDeselected?: (id: NodeId) => void } = {}, - ) => { - return useSelection(navigator, nodeRects, isPortEnabled, SELECTION_BRUSH_MARGIN_PX, callbacks) - }, + ) => + proxyRefs({ + ...useSelection(navigator, nodeRects, SELECTION_BRUSH_MARGIN_PX, isValid, callbacks), + ...useGraphHover(isPortEnabled), + }), ) diff --git a/app/gui2/src/stores/graph/index.ts b/app/gui2/src/stores/graph/index.ts index 31aaf2e8bb56..5a07af7ba762 100644 --- a/app/gui2/src/stores/graph/index.ts +++ b/app/gui2/src/stores/graph/index.ts @@ -295,7 +295,7 @@ export const useGraphStore = defineStore('graph', () => { addImports(edit.getVersion(topLevel), importsToAdd) } - function deleteNodes(ids: NodeId[]) { + function deleteNodes(ids: Iterable) { edit( (edit) => { for (const id of ids) { diff --git a/app/gui2/src/util/data/iterable.ts b/app/gui2/src/util/data/iterable.ts index f5c6bf57a6f1..9cdb3eec9d0f 100644 --- a/app/gui2/src/util/data/iterable.ts +++ b/app/gui2/src/util/data/iterable.ts @@ -5,3 +5,8 @@ export function* filterDefined(iterable: Iterable): IterableIt if (value !== undefined) yield value } } + +export function every(iter: Iterable, f: (value: T) => boolean): boolean { + for (const value of iter) if (!f(value)) return false + return true +} diff --git a/app/gui2/src/util/dom.ts b/app/gui2/src/util/dom.ts new file mode 100644 index 000000000000..6d19c65c5142 --- /dev/null +++ b/app/gui2/src/util/dom.ts @@ -0,0 +1,18 @@ +export function* elementHierarchy(element: Element, selectors: string) { + for (;;) { + const match = element.closest(selectors) + if (!match) return + yield match + if (!match.parentElement) return + element = match.parentElement + } +} + +export function dataAttribute( + element: Element, + key: string, +): T | undefined { + return element instanceof HTMLElement && key in element.dataset ? + (element.dataset[key] as T) + : undefined +} diff --git a/app/gui2/src/util/reactivity.ts b/app/gui2/src/util/reactivity.ts index 449cba853c6e..1a23fb07ef2e 100644 --- a/app/gui2/src/util/reactivity.ts +++ b/app/gui2/src/util/reactivity.ts @@ -11,6 +11,8 @@ import { queuePostFlushCb, shallowRef, watch, + type ComputedRef, + type MaybeRefOrGetter, type Ref, type WatchSource, } from 'vue' @@ -156,3 +158,6 @@ export function syncSet(target: Set, newState: Set) { for (const oldKey of target) if (!newState.has(oldKey)) target.delete(oldKey) for (const newKey of newState) if (!target.has(newKey)) target.add(newKey) } + +/** Type of the parameter of `toValue`. */ +export type ToValue = MaybeRefOrGetter | ComputedRef