Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Select node on any edit #9536

Merged
merged 9 commits into from
Mar 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/gui2/src/components/GraphEditor/GraphNode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ const documentation = computed<string | undefined>({
transform,
minWidth: isVisualizationVisible ? `${visualizationWidth}px` : undefined,
'--node-group-color': color,
...(node.zIndex ? { 'z-index': node.zIndex } : {}),
}"
:class="{
edited: props.edited,
Expand Down
14 changes: 13 additions & 1 deletion app/gui2/src/components/GraphEditor/NodeWidgetTree.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import { useTransitioning } from '@/composables/animation'
import { injectGraphSelection } from '@/providers/graphSelection'
import { WidgetInput, type WidgetUpdate } from '@/providers/widgetRegistry'
import { WidgetEditHandler } from '@/providers/widgetRegistry/editHandler'
import { provideWidgetTree } from '@/providers/widgetTree'
import { useGraphStore, type NodeId } from '@/stores/graph'
import { Ast } from '@/util/ast'
import type { Icon } from '@/util/iconName'
import { computed, ref, toRef } from 'vue'
import { computed, ref, toRef, watch } from 'vue'

const props = defineProps<{
ast: Ast.Ast
Expand All @@ -30,6 +32,7 @@ const rootPort = computed(() => {
}
return input
})
const selection = injectGraphSelection()

const observedLayoutTransitions = new Set([
'margin-left',
Expand All @@ -44,7 +47,12 @@ const observedLayoutTransitions = new Set([
'height',
])

function selectNode() {
selection.setSelection(new Set([props.nodeId]))
}

function handleWidgetUpdates(update: WidgetUpdate) {
selectNode()
const edit = update.edit ?? graph.startEdit()
if (update.portUpdate) {
const { value, origin } = update.portUpdate
Expand All @@ -67,6 +75,9 @@ function handleWidgetUpdates(update: WidgetUpdate) {
return true
}

const currentEdit = ref<WidgetEditHandler>()
watch(currentEdit, (edit) => edit && selectNode())

/**
* We have two goals for our DOM/CSS that are somewhat in conflict:
* - We position widget dialogs drawn outside the widget, like dropdowns, relative to their parents. If we teleported
Expand Down Expand Up @@ -97,6 +108,7 @@ provideWidgetTree(
toRef(props, 'conditionalPorts'),
toRef(props, 'extended'),
layoutTransitions.active,
currentEdit,
() => {
emit('openFullMenu')
},
Expand Down
14 changes: 13 additions & 1 deletion app/gui2/src/components/GraphEditor/widgets/WidgetNumber.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
<script setup lang="ts">
import NumericInputWidget from '@/components/widgets/NumericInputWidget.vue'
import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { WidgetEditHandler } from '@/providers/widgetRegistry/editHandler'
import { Ast } from '@/util/ast'
import { computed } from 'vue'
import { computed, ref, type ComponentInstance } from 'vue'

const props = defineProps(widgetProps(widgetDefinition))
const inputComponent = ref<ComponentInstance<typeof NumericInputWidget>>()
const value = computed({
get() {
const valueStr = WidgetInput.valueRepr(props.input)
Expand All @@ -25,6 +27,12 @@ const limits = computed(() => {
return undefined
}
})

const editHandler = WidgetEditHandler.New(props.input, {
cancel: () => inputComponent.value?.cancel(),
start: () => inputComponent.value?.focus(),
end: () => inputComponent.value?.blur(),
})
</script>

<script lang="ts">
Expand Down Expand Up @@ -52,11 +60,15 @@ export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
<template>
<!-- See comment in GraphNode next to dragPointer definition about stopping pointerdown and pointerup -->
<NumericInputWidget
ref="inputComponent"
v-model="value"
class="WidgetNumber r-24"
:limits="limits"
@pointerdown.stop
@pointerup.stop
@focus="editHandler.start()"
@blur="editHandler.end()"
@input="editHandler.edit($event)"
/>
</template>

Expand Down
15 changes: 12 additions & 3 deletions app/gui2/src/components/GraphEditor/widgets/WidgetSelection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import DropdownWidget, { type DropdownEntry } from '@/components/widgets/DropdownWidget.vue'
import { unrefElement } from '@/composables/events'
import type { PortId } from '@/providers/portInfo'
import { defineWidget, Score, WidgetInput, widgetProps } from '@/providers/widgetRegistry'
import {
multipleChoiceConfiguration,
Expand Down Expand Up @@ -34,7 +35,8 @@ const tree = injectWidgetTree()

const dropdownElement = ref<ComponentInstance<typeof DropdownWidget>>()

const editedValue = ref<Ast.Ast | string | undefined>()
const editedWidget = ref<PortId>()
const editedValue = ref<Ast.Owned | string | undefined>()
const isHovered = ref(false)

class ExpressionTag {
Expand Down Expand Up @@ -185,22 +187,29 @@ const dropDownInteraction = WidgetEditHandler.New(props.input, {
click: (e, _, childHandler) => {
if (targetIsOutside(e, unrefElement(dropdownElement))) {
if (childHandler) return childHandler()
else dropdownVisible.value = false
else {
dropDownInteraction.end()
if (editedWidget.value)
props.onUpdate({ portUpdate: { origin: editedWidget.value, value: editedValue.value } })
}
}
return false
},
start: () => {
dropdownVisible.value = true
editedWidget.value = undefined
editedValue.value = undefined
},
edit: (_, value) => {
edit: (origin, value) => {
editedWidget.value = origin
editedValue.value = value
},
end: () => {
dropdownVisible.value = false
},
addItem: () => {
dropdownVisible.value = true
editedWidget.value = undefined
editedValue.value = undefined
return true
},
Expand Down
28 changes: 23 additions & 5 deletions app/gui2/src/components/widgets/NumericInputWidget.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
<script setup lang="ts">
import { PointerButtonMask, usePointer } from '@/composables/events'
import { WidgetEditHandler } from '@/providers/widgetRegistry/editHandler'
import { computed, ref, watch, type ComponentInstance, type StyleValue } from 'vue'
import AutoSizedInput from './AutoSizedInput.vue'

const props = defineProps<{
modelValue: number | string
limits?: { min: number; max: number } | undefined
}>()
const emit = defineEmits<{ 'update:modelValue': [modelValue: number | string] }>()
const emit = defineEmits<{
'update:modelValue': [modelValue: number | string]
blur: []
focus: []
input: [content: string]
}>()

const inputFieldActive = ref(false)
// Edited value reflects the `modelValue`, but does not update it until the user defocuses the field.
Expand Down Expand Up @@ -87,14 +93,25 @@ function emitUpdate() {
}
}

function blur() {
function blurred() {
inputFieldActive.value = false
emit('blur')
emitUpdate()
}

function focus() {
function focused() {
inputFieldActive.value = true
emit('focus')
}

defineExpose({
cancel: () => {
editedValue.value = `${props.modelValue}`
inputComponent.value?.blur()
},
blur: () => inputComponent.value?.blur(),
focus: () => inputComponent.value?.focus(),
})
</script>

<template>
Expand All @@ -106,8 +123,9 @@ function focus() {
autoSelect
:style="inputStyle"
v-on="dragPointer.events"
@blur="blur"
@focus="focus"
@blur="blurred"
@focus="focused"
@input="emit('input', editedValue)"
/>
</label>
</template>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ function editHandlerTree(
widgets: string[],
interactionHandler: InteractionHandler,
createInteraction: (name: string) => Record<string, Mock>,
widgetTree: { currentEdit: WidgetEditHandler | undefined },
): Map<string, { handler: WidgetEditHandler; interaction: Record<string, Mock> }> {
const handlers = new Map()
for (const id of widgets) {
Expand All @@ -24,6 +25,7 @@ function editHandlerTree(
interaction,
parent ? handlers.get(parent)?.handler : undefined,
interactionHandler,
widgetTree,
)
handlers.set(id, { handler, interaction })
}
Expand All @@ -42,12 +44,18 @@ test.each`
'Edit interaction propagation starting from $edited in $widgets tree',
({ widgets, edited, expectedPropagation }) => {
const interactionHandler = new InteractionHandler()
const handlers = editHandlerTree(widgets, interactionHandler, () => ({
start: vi.fn(),
edit: vi.fn(),
end: vi.fn(),
cancel: vi.fn(),
}))
const widgetTree = { currentEdit: undefined }
const handlers = editHandlerTree(
widgets,
interactionHandler,
() => ({
start: vi.fn(),
edit: vi.fn(),
end: vi.fn(),
cancel: vi.fn(),
}),
widgetTree,
)
const expectedPropagationSet = new Set(expectedPropagation)
const checkCallbackCall = (callback: string, ...args: any[]) => {
for (const [id, { interaction }] of handlers) {
Expand All @@ -64,6 +72,7 @@ test.each`
assert(editedHandler != null)

editedHandler.handler.start()
expect(widgetTree.currentEdit).toBe(editedHandler.handler)
checkCallbackCall('start', edited)
for (const [id, { handler }] of handlers) {
expect(handler.isActive()).toBe(expectedPropagationSet.has(id))
Expand All @@ -76,21 +85,27 @@ test.each`
const endedHandler = handlers.get(ended)?.handler

editedHandler.handler.start()
expect(widgetTree.currentEdit).toBe(editedHandler.handler)
expect(editedHandler.handler.isActive()).toBeTruthy()
endedHandler?.end()
expect(widgetTree.currentEdit).toBeUndefined()
checkCallbackCall('end', ended)
expect(editedHandler.handler.isActive()).toBeFalsy()

editedHandler.handler.start()
expect(widgetTree.currentEdit).toBe(editedHandler.handler)
expect(editedHandler.handler.isActive()).toBeTruthy()
endedHandler?.cancel()
expect(widgetTree.currentEdit).toBeUndefined()
checkCallbackCall('cancel')
expect(editedHandler.handler.isActive()).toBeFalsy()
}

editedHandler.handler.start()
expect(widgetTree.currentEdit).toBe(editedHandler.handler)
expect(editedHandler.handler.isActive()).toBeTruthy()
interactionHandler.setCurrent(undefined)
expect(widgetTree.currentEdit).toBeUndefined()
checkCallbackCall('cancel')
expect(editedHandler.handler.isActive()).toBeFalsy()
},
Expand All @@ -110,28 +125,33 @@ test.each`
const event = new MouseEvent('click') as PointerEvent
const navigator = {} as GraphNavigator
const interactionHandler = new InteractionHandler()
const widgetTree = { currentEdit: undefined }

const propagatingHandlersSet = new Set(propagatingHandlers)
const nonPropagatingHandlersSet = new Set(nonPropagatingHandlers)
const expectedHandlerCallsSet = new Set(expectedHandlerCalls)

const handlers = editHandlerTree(widgets, interactionHandler, (id) =>
propagatingHandlersSet.has(id) ?
{
click: vi.fn((e, nav, childHandler) => {
expect(e).toBe(event)
expect(nav).toBe(navigator)
childHandler?.()
}),
}
: nonPropagatingHandlersSet.has(id) ?
{
click: vi.fn((e, nav) => {
expect(e).toBe(event)
expect(nav).toBe(navigator)
}),
}
: {},
const handlers = editHandlerTree(
widgets,
interactionHandler,
(id) =>
propagatingHandlersSet.has(id) ?
{
click: vi.fn((e, nav, childHandler) => {
expect(e).toBe(event)
expect(nav).toBe(navigator)
childHandler?.()
}),
}
: nonPropagatingHandlersSet.has(id) ?
{
click: vi.fn((e, nav) => {
expect(e).toBe(event)
expect(nav).toBe(navigator)
}),
}
: {},
widgetTree,
)
handlers.get(edited)?.handler.start()
interactionHandler.handleClick(event, navigator)
Expand Down
22 changes: 14 additions & 8 deletions app/gui2/src/providers/widgetRegistry/editHandler.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import type { PortId } from '@/providers//portInfo'
import type { GraphNavigator } from '@/providers/graphNavigator'
import {
injectInteractionHandler,
type Interaction,
type InteractionHandler,
} from '@/providers/interactionHandler'
import { injectInteractionHandler, type Interaction } from '@/providers/interactionHandler'
import type { WidgetInput } from '@/providers/widgetRegistry'
import type { Ast } from '@/util/ast'
import { markRaw } from 'vue'
import { injectWidgetTree } from '../widgetTree'

/** An extend {@link Interaction} used in {@link WidgetEditHandler} */
export interface WidgetEditInteraction extends Interaction {
Expand Down Expand Up @@ -61,11 +59,18 @@ export class WidgetEditHandler {
private portId: PortId,
innerInteraction: WidgetEditInteraction,
private parent?: WidgetEditHandler,
private interactionHandler: InteractionHandler = injectInteractionHandler(),
private interactionHandler = injectInteractionHandler(),
private widgetTree: { currentEdit: WidgetEditHandler | undefined } = injectWidgetTree(),
) {
const noLongerActive = () => {
this.activeInteraction = undefined
if (widgetTree.currentEdit === this) {
widgetTree.currentEdit = undefined
}
}
this.interaction = {
cancel: () => {
this.activeInteraction = undefined
noLongerActive()
innerInteraction.cancel?.()
parent?.interaction.cancel?.()
},
Expand All @@ -88,7 +93,7 @@ export class WidgetEditHandler {
parent?.interaction.edit?.(portId, value)
},
end: (portId) => {
this.activeInteraction = undefined
noLongerActive()
innerInteraction.end?.(portId)
parent?.interaction.end?.(portId)
},
Expand All @@ -110,6 +115,7 @@ export class WidgetEditHandler {

start() {
this.interactionHandler.setCurrent(this.interaction)
this.widgetTree.currentEdit = markRaw(this)
for (
let handler: WidgetEditHandler | undefined = this;
handler != null;
Expand Down
Loading
Loading