mirror of
https://github.com/enso-org/enso.git
synced 2024-08-17 00:40:34 +03:00
Self-argument access chain ports are conditional (#9439)
Ports for `PropertyAccess` AST nodes that (transitively) contain a `WidgetSelfArgument` are not targets for a disconnected edge, unless the mod key is held. https://github.com/enso-org/enso/assets/1047859/68ac1953-c8b1-4e51-8c4c-211595e0c034 (Video shows with and without mod key)
This commit is contained in:
parent
47d9e73ead
commit
e1893b65af
@ -1,5 +1,4 @@
|
||||
import { test, type Page } from '@playwright/test'
|
||||
import assert from 'assert'
|
||||
import os from 'os'
|
||||
import * as actions from './actions'
|
||||
import { expect } from './customExpect'
|
||||
@ -47,23 +46,16 @@ test('Different ways of opening Component Browser', async ({ page }) => {
|
||||
await locate.graphEditor(page).press('Enter')
|
||||
await expectAndCancelBrowser(page, 'final.')
|
||||
// Dragging out an edge
|
||||
// `click` method of locator could be simpler, but `position` option doesn't work.
|
||||
const outputPortArea = await locate
|
||||
.graphNodeByBinding(page, 'final')
|
||||
.locator('.outputPortHoverArea')
|
||||
.boundingBox()
|
||||
assert(outputPortArea)
|
||||
const outputPortX = outputPortArea.x + outputPortArea.width / 2.0
|
||||
const outputPortY = outputPortArea.y + outputPortArea.height - 2.0
|
||||
await page.mouse.click(outputPortX, outputPortY)
|
||||
const outputPort = await locate.outputPortCoordinates(locate.graphNodeByBinding(page, 'final'))
|
||||
await page.mouse.click(outputPort.x, outputPort.y)
|
||||
await page.mouse.click(100, 500)
|
||||
await expectAndCancelBrowser(page, 'final.')
|
||||
// Double-clicking port
|
||||
// TODO[ao] Without timeout, even the first click would be treated as double due to previous
|
||||
// event. Probably we need a better way to simulate double clicks.
|
||||
await page.waitForTimeout(600)
|
||||
await page.mouse.click(outputPortX, outputPortY)
|
||||
await page.mouse.click(outputPortX, outputPortY)
|
||||
await page.mouse.click(outputPort.x, outputPort.y)
|
||||
await page.mouse.click(outputPort.x, outputPort.y)
|
||||
await expectAndCancelBrowser(page, 'final.')
|
||||
})
|
||||
|
||||
@ -108,14 +100,8 @@ test('Graph Editor pans to Component Browser', async ({ page }) => {
|
||||
await page.mouse.move(100, 80)
|
||||
await page.mouse.up({ button: 'middle' })
|
||||
await expect(locate.graphNodeByBinding(page, 'five')).toBeInViewport()
|
||||
const outputPortArea = await locate
|
||||
.graphNodeByBinding(page, 'final')
|
||||
.locator('.outputPortHoverArea')
|
||||
.boundingBox()
|
||||
assert(outputPortArea)
|
||||
const outputPortX = outputPortArea.x + outputPortArea.width / 2.0
|
||||
const outputPortY = outputPortArea.y + outputPortArea.height - 2.0
|
||||
await page.mouse.click(outputPortX, outputPortY)
|
||||
const outputPort = await locate.outputPortCoordinates(locate.graphNodeByBinding(page, 'final'))
|
||||
await page.mouse.click(outputPort.x, outputPort.y)
|
||||
await page.mouse.click(100, 1550)
|
||||
await expect(locate.graphNodeByBinding(page, 'five')).not.toBeInViewport()
|
||||
await expectAndCancelBrowser(page, 'final.')
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { test, type Page } from '@playwright/test'
|
||||
import * as actions from './actions'
|
||||
import { edgesToNodeWithBinding, graphNodeByBinding } from './locate'
|
||||
import { expect } from './customExpect'
|
||||
import * as locate from './locate'
|
||||
import { edgesToNodeWithBinding, graphNodeByBinding, outputPortCoordinates } from './locate'
|
||||
|
||||
/**
|
||||
* Prepare the graph for the tests. We drag the `ten` node to the right of the `sum` node for better access
|
||||
@ -53,3 +55,43 @@ test('Connect an node to a port via dragging the edge', async ({ page }) => {
|
||||
|
||||
await expect(graphNodeByBinding(page, 'prod')).toContainText('ten')
|
||||
})
|
||||
|
||||
test('Conditional ports: Disabled', async ({ page }) => {
|
||||
await actions.goToGraph(page)
|
||||
const node = graphNodeByBinding(page, 'filtered')
|
||||
const conditionalPort = node.locator('.WidgetPort').filter({ hasText: /^filter$/ })
|
||||
|
||||
// Check that the `enabled` CSS class is not set on disabled `WidgetPort`s.
|
||||
await expect(node.locator('.WidgetSelfIcon')).toBeVisible()
|
||||
await expect(conditionalPort).not.toHaveClass(/enabled/)
|
||||
|
||||
// When a port is disabled, it doesn't react to hovering with a disconnected edge,
|
||||
// and any attempt to connect to it should open the CB.
|
||||
const outputPort = await outputPortCoordinates(graphNodeByBinding(page, 'final'))
|
||||
await page.mouse.click(outputPort.x, outputPort.y)
|
||||
await conditionalPort.hover()
|
||||
await expect(conditionalPort).not.toHaveClass(/isTarget/)
|
||||
await conditionalPort.click()
|
||||
await expect(locate.componentBrowser(page)).toExist()
|
||||
await page.keyboard.press('Escape')
|
||||
})
|
||||
|
||||
test('Conditional ports: Enabled', async ({ page }) => {
|
||||
await actions.goToGraph(page)
|
||||
const node = graphNodeByBinding(page, 'filtered')
|
||||
const conditionalPort = node.locator('.WidgetPort').filter({ hasText: /^filter$/ })
|
||||
|
||||
await page.keyboard.down('Meta')
|
||||
await page.keyboard.down('Control')
|
||||
|
||||
await expect(conditionalPort).toHaveClass(/enabled/)
|
||||
const outputPort = await outputPortCoordinates(graphNodeByBinding(page, 'final'))
|
||||
await page.mouse.click(outputPort.x, outputPort.y)
|
||||
await conditionalPort.hover()
|
||||
await expect(conditionalPort).toHaveClass(/isTarget/)
|
||||
await conditionalPort.click()
|
||||
await expect(node.locator('.WidgetToken')).toHaveText(['final'])
|
||||
|
||||
await page.keyboard.up('Meta')
|
||||
await page.keyboard.up('Control')
|
||||
})
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { type Locator, type Page } from '@playwright/test'
|
||||
import { expect, type Locator, type Page } from '@playwright/test'
|
||||
import assert from 'assert'
|
||||
import cssEscape from 'css.escape'
|
||||
|
||||
// ==============
|
||||
@ -199,3 +200,17 @@ export async function edgesToNodeWithBinding(page: Page, binding: string) {
|
||||
const nodeId = await node.getAttribute('data-node-id')
|
||||
return page.locator(`[data-target-node-id="${nodeId}"]`)
|
||||
}
|
||||
|
||||
// === Output ports ===
|
||||
|
||||
/** Returns a location that can be clicked to activate an output port.
|
||||
* Using a `Locator` would be better, but `position` option of `click` doesn't work.
|
||||
*/
|
||||
export async function outputPortCoordinates(node: Locator) {
|
||||
const outputPortArea = await node.locator('.outputPortHoverArea').boundingBox()
|
||||
expect(outputPortArea).not.toBeNull()
|
||||
assert(outputPortArea)
|
||||
const centerX = outputPortArea.x + outputPortArea.width / 2
|
||||
const bottom = outputPortArea.y + outputPortArea.height
|
||||
return { x: centerX, y: bottom - 2.0 }
|
||||
}
|
||||
|
@ -993,6 +993,18 @@ export interface MutablePropertyAccess extends PropertyAccess, MutableAst {
|
||||
}
|
||||
applyMixins(MutablePropertyAccess, [MutableAst])
|
||||
|
||||
/** Unroll the provided chain of `PropertyAccess` nodes, returning the first non-access as `subject` and the accesses
|
||||
* from left-to-right. */
|
||||
export function accessChain(ast: Ast): { subject: Ast; accessChain: PropertyAccess[] } {
|
||||
const accessChain = new Array<PropertyAccess>()
|
||||
while (ast instanceof PropertyAccess && ast.lhs) {
|
||||
accessChain.push(ast)
|
||||
ast = ast.lhs
|
||||
}
|
||||
accessChain.reverse()
|
||||
return { subject: ast, accessChain }
|
||||
}
|
||||
|
||||
interface GenericFields {
|
||||
children: RawNodeChild[]
|
||||
}
|
||||
|
@ -6,7 +6,6 @@ export const codeEditorBindings = defineKeybinds('code-editor', {
|
||||
|
||||
export const interactionBindings = defineKeybinds('current-interaction', {
|
||||
cancel: ['Escape'],
|
||||
click: ['PointerMain'],
|
||||
})
|
||||
|
||||
export const componentBrowserBindings = defineKeybinds('component-browser', {
|
||||
|
@ -24,6 +24,7 @@ import { useStackNavigator } from '@/composables/stackNavigator'
|
||||
import { provideGraphNavigator } from '@/providers/graphNavigator'
|
||||
import { provideGraphSelection } from '@/providers/graphSelection'
|
||||
import { provideInteractionHandler } from '@/providers/interactionHandler'
|
||||
import { provideKeyboard } from '@/providers/keyboard'
|
||||
import { provideWidgetRegistry } from '@/providers/widgetRegistry'
|
||||
import { useGraphStore, type NodeId } from '@/stores/graph'
|
||||
import type { RequiredImport } from '@/stores/graph/imports'
|
||||
@ -44,8 +45,9 @@ import { type Usage } from './ComponentBrowser/input'
|
||||
const DEFAULT_NODE_SIZE = new Vec2(0, 24)
|
||||
const gapBetweenNodes = 48.0
|
||||
|
||||
const keyboard = provideKeyboard()
|
||||
const viewportNode = ref<HTMLElement>()
|
||||
const graphNavigator = provideGraphNavigator(viewportNode)
|
||||
const graphNavigator = provideGraphNavigator(viewportNode, keyboard)
|
||||
const graphStore = useGraphStore()
|
||||
const widgetRegistry = provideWidgetRegistry(graphStore.db)
|
||||
widgetRegistry.loadBuiltins()
|
||||
@ -83,15 +85,19 @@ projectStore.executionContext.on('executionFailed', (e) =>
|
||||
|
||||
// === nodes ===
|
||||
|
||||
const nodeSelection = provideGraphSelection(graphNavigator, graphStore.nodeRects, {
|
||||
onSelected(id) {
|
||||
graphStore.db.moveNodeToTop(id)
|
||||
const nodeSelection = provideGraphSelection(
|
||||
graphNavigator,
|
||||
graphStore.nodeRects,
|
||||
graphStore.isPortEnabled,
|
||||
{
|
||||
onSelected(id) {
|
||||
graphStore.db.moveNodeToTop(id)
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const interactionBindingsHandler = interactionBindings.handler({
|
||||
cancel: () => interaction.handleCancel(),
|
||||
click: (e) => (e instanceof PointerEvent ? interaction.handleClick(e, graphNavigator) : false),
|
||||
})
|
||||
|
||||
// Return the environment for the placement of a new node. The passed nodes should be the nodes that are
|
||||
@ -147,7 +153,9 @@ useEvent(window, 'keydown', (event) => {
|
||||
(!keyboardBusy() && graphBindingsHandler(event)) ||
|
||||
(!keyboardBusyExceptIn(codeEditorArea.value) && codeEditorHandler(event))
|
||||
})
|
||||
useEvent(window, 'pointerdown', interactionBindingsHandler, { capture: true })
|
||||
useEvent(window, 'pointerdown', (e) => interaction.handleClick(e, graphNavigator), {
|
||||
capture: true,
|
||||
})
|
||||
|
||||
onMounted(() => viewportNode.value?.focus())
|
||||
|
||||
|
@ -494,6 +494,7 @@ const documentation = computed<string | undefined>({
|
||||
:icon="icon"
|
||||
:connectedSelfArgumentId="connectedSelfArgumentId"
|
||||
:potentialSelfArgumentId="potentialSelfArgumentId"
|
||||
:conditionalPorts="props.node.conditionalPorts"
|
||||
:extended="isOnlyOneSelected"
|
||||
@openFullMenu="openFullMenu"
|
||||
/>
|
||||
|
@ -15,6 +15,8 @@ const props = defineProps<{
|
||||
icon: Icon
|
||||
connectedSelfArgumentId: Ast.AstId | undefined
|
||||
potentialSelfArgumentId: Ast.AstId | undefined
|
||||
/** Ports that are not targetable by default; see {@link NodeDataFromAst}. */
|
||||
conditionalPorts: Set<Ast.AstId>
|
||||
extended: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
@ -92,6 +94,7 @@ provideWidgetTree(
|
||||
toRef(props, 'icon'),
|
||||
toRef(props, 'connectedSelfArgumentId'),
|
||||
toRef(props, 'potentialSelfArgumentId'),
|
||||
toRef(props, 'conditionalPorts'),
|
||||
toRef(props, 'extended'),
|
||||
layoutTransitions.active,
|
||||
() => {
|
||||
|
@ -4,6 +4,7 @@ import { useRaf } from '@/composables/animation'
|
||||
import { useResizeObserver } from '@/composables/events'
|
||||
import { injectGraphNavigator } from '@/providers/graphNavigator'
|
||||
import { injectGraphSelection } from '@/providers/graphSelection'
|
||||
import { injectKeyboard } from '@/providers/keyboard'
|
||||
import { injectPortInfo, providePortInfo, type PortId } from '@/providers/portInfo'
|
||||
import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
|
||||
import { injectWidgetTree } from '@/providers/widgetTree'
|
||||
@ -88,10 +89,6 @@ const innerWidget = computed(() => {
|
||||
|
||||
providePortInfo(proxyRefs({ portId, connected: hasConnection }))
|
||||
|
||||
watch(nodeSize, updateRect)
|
||||
onUpdated(() => nextTick(updateRect))
|
||||
useRaf(toRef(tree, 'hasActiveAnimations'), updateRect)
|
||||
|
||||
const randSlice = randomUuid.slice(0, 4)
|
||||
|
||||
watchEffect(
|
||||
@ -104,19 +101,37 @@ watchEffect(
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
function updateRect() {
|
||||
let domNode = rootNode.value
|
||||
const keyboard = injectKeyboard()
|
||||
|
||||
const enabled = computed(() => {
|
||||
const input = props.input.value
|
||||
const isConditional = input instanceof Ast.Ast && tree.conditionalPorts.has(input.id)
|
||||
return !isConditional || keyboard.mod
|
||||
})
|
||||
|
||||
const computedRect = computed(() => {
|
||||
const domNode = rootNode.value
|
||||
const rootDomNode = domNode?.closest('.GraphNode')
|
||||
if (domNode == null || rootDomNode == null) return
|
||||
|
||||
if (!enabled.value) return
|
||||
let _nodeSizeEffect = nodeSize.value
|
||||
const exprClientRect = Rect.FromDomRect(domNode.getBoundingClientRect())
|
||||
const nodeClientRect = Rect.FromDomRect(rootDomNode.getBoundingClientRect())
|
||||
const exprSceneRect = navigator.clientToSceneRect(exprClientRect)
|
||||
const exprNodeRect = navigator.clientToSceneRect(nodeClientRect)
|
||||
const localRect = exprSceneRect.offsetBy(exprNodeRect.pos.inverse())
|
||||
if (portRect.value != null && localRect.equals(portRect.value)) return
|
||||
portRect.value = localRect
|
||||
return exprSceneRect.offsetBy(exprNodeRect.pos.inverse())
|
||||
})
|
||||
|
||||
function updateRect() {
|
||||
const newRect = computedRect.value
|
||||
if (!Rect.Equal(portRect.value, newRect)) {
|
||||
portRect.value = newRect
|
||||
}
|
||||
}
|
||||
|
||||
watch(computedRect, updateRect)
|
||||
onUpdated(() => nextTick(updateRect))
|
||||
useRaf(toRef(tree, 'hasActiveAnimations'), updateRect)
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
@ -159,6 +174,7 @@ export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
|
||||
ref="rootNode"
|
||||
class="WidgetPort"
|
||||
:class="{
|
||||
enabled,
|
||||
connected,
|
||||
isTarget,
|
||||
isSelfArgument,
|
||||
|
@ -22,7 +22,7 @@ export const widgetDefinition = defineWidget(WidgetInput.isAst, {
|
||||
|
||||
<template>
|
||||
<SvgIcon
|
||||
class="icon nodeCategoryIcon"
|
||||
class="WidgetSelfIcon icon nodeCategoryIcon"
|
||||
:name="icon"
|
||||
@click.right.stop.prevent="tree.emitOpenFullMenu()"
|
||||
/>
|
||||
|
@ -14,7 +14,8 @@ function selectionWithMockData(sceneMousePos?: Ref<Vec2>) {
|
||||
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 selection = useSelection(navigator, rects, 0)
|
||||
const allPortsEnabled = () => true
|
||||
const selection = useSelection(navigator, rects, allPortsEnabled, 0)
|
||||
selection.setSelection(new Set([1, 2]))
|
||||
return selection
|
||||
}
|
||||
|
@ -134,7 +134,7 @@ const hasWindow = typeof window !== 'undefined'
|
||||
const platform = hasWindow ? window.navigator?.platform ?? '' : ''
|
||||
export const isMacLike = /(Mac|iPhone|iPod|iPad)/i.test(platform)
|
||||
|
||||
export function modKey(e: KeyboardEvent): boolean {
|
||||
export function modKey(e: KeyboardEvent | MouseEvent): boolean {
|
||||
return isMacLike ? e.metaKey : e.ctrlKey
|
||||
}
|
||||
|
||||
|
31
app/gui2/src/composables/keyboard.ts
Normal file
31
app/gui2/src/composables/keyboard.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { isMacLike, useEvent } from '@/composables/events'
|
||||
import { proxyRefs, ref } from 'vue'
|
||||
|
||||
export type KeyboardComposable = ReturnType<typeof useKeyboard>
|
||||
export function useKeyboard() {
|
||||
const state = {
|
||||
alt: ref(false),
|
||||
shift: ref(false),
|
||||
meta: ref(false),
|
||||
ctrl: ref(false),
|
||||
}
|
||||
|
||||
const updateState = (e: MouseEvent | KeyboardEvent) => {
|
||||
state.alt.value = e.altKey
|
||||
state.shift.value = e.shiftKey
|
||||
state.meta.value = e.metaKey
|
||||
state.ctrl.value = e.ctrlKey
|
||||
return false
|
||||
}
|
||||
useEvent(window, 'keydown', updateState, { capture: true })
|
||||
useEvent(window, 'keyup', updateState, { capture: true })
|
||||
useEvent(window, 'pointerenter', updateState, { capture: true })
|
||||
|
||||
return proxyRefs({
|
||||
alt: state.alt,
|
||||
shift: state.shift,
|
||||
meta: state.meta,
|
||||
ctrl: state.ctrl,
|
||||
mod: isMacLike ? state.meta : state.ctrl,
|
||||
})
|
||||
}
|
@ -2,6 +2,7 @@
|
||||
|
||||
import { useApproach } from '@/composables/animation'
|
||||
import { PointerButtonMask, useEvent, usePointer, useResizeObserver } from '@/composables/events'
|
||||
import type { KeyboardComposable } from '@/composables/keyboard'
|
||||
import { Rect } from '@/util/data/rect'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import { computed, proxyRefs, shallowRef, type Ref } from 'vue'
|
||||
@ -19,7 +20,7 @@ function elemRect(target: Element | undefined): Rect {
|
||||
}
|
||||
|
||||
export type NavigatorComposable = ReturnType<typeof useNavigator>
|
||||
export function useNavigator(viewportNode: Ref<Element | undefined>) {
|
||||
export function useNavigator(viewportNode: Ref<Element | undefined>, keyboard: KeyboardComposable) {
|
||||
const size = useResizeObserver(viewportNode)
|
||||
const targetCenter = shallowRef<Vec2>(Vec2.Zero)
|
||||
const targetX = computed(() => targetCenter.value.x)
|
||||
@ -211,26 +212,6 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
|
||||
{ capture: true },
|
||||
)
|
||||
|
||||
let ctrlPressed = false
|
||||
useEvent(
|
||||
window,
|
||||
'keydown',
|
||||
(event) => {
|
||||
if (event.key === 'Control') ctrlPressed = true
|
||||
return false
|
||||
},
|
||||
{ capture: true },
|
||||
)
|
||||
useEvent(
|
||||
window,
|
||||
'keyup',
|
||||
(event) => {
|
||||
if (event.key === 'Control') ctrlPressed = false
|
||||
return false
|
||||
},
|
||||
{ capture: true },
|
||||
)
|
||||
|
||||
/** Clamp the value to the given bounds, except if it is already outside the bounds allow the new value to be less
|
||||
* outside the bounds. */
|
||||
function directedClamp(oldValue: number, newValue: number, [min, max]: ScaleRange): number {
|
||||
@ -257,11 +238,6 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Update `ctrlPressed` from the event; this helps catch if we missed the `keyup` while focus was elsewhere. */
|
||||
function updateCtrlState(e: KeyboardEvent | MouseEvent | PointerEvent) {
|
||||
ctrlPressed = e.ctrlKey
|
||||
}
|
||||
|
||||
return proxyRefs({
|
||||
events: {
|
||||
dragover(e: DragEvent) {
|
||||
@ -270,14 +246,10 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
|
||||
dragleave() {
|
||||
eventMousePos.value = null
|
||||
},
|
||||
pointerenter(e: PointerEvent) {
|
||||
updateCtrlState(e)
|
||||
},
|
||||
pointermove(e: PointerEvent) {
|
||||
eventMousePos.value = eventScreenPos(e)
|
||||
panPointer.events.pointermove(e)
|
||||
zoomPointer.events.pointermove(e)
|
||||
updateCtrlState(e)
|
||||
},
|
||||
pointerleave() {
|
||||
eventMousePos.value = null
|
||||
@ -297,7 +269,7 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
|
||||
if (e.ctrlKey) {
|
||||
// A pinch gesture is represented by setting `e.ctrlKey`. It can be distinguished from an actual Ctrl+wheel
|
||||
// combination because the real Ctrl key emits keyup/keydown events.
|
||||
const isGesture = !ctrlPressed
|
||||
const isGesture = !keyboard.ctrl
|
||||
if (isGesture) {
|
||||
// OS X trackpad events provide usable rate-of-change information.
|
||||
updateScale(
|
||||
@ -309,7 +281,6 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
|
||||
stepZoom(-Math.sign(e.deltaY), WHEEL_SCALE_RANGE)
|
||||
}
|
||||
} else {
|
||||
updateCtrlState(e)
|
||||
const delta = new Vec2(e.deltaX, e.deltaY)
|
||||
center.value = center.value.addScaled(delta, 1 / scale.value)
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ export type SelectionComposable<T> = ReturnType<typeof useSelection<T>>
|
||||
export function useSelection<T>(
|
||||
navigator: { sceneMousePos: Vec2 | null; scale: number },
|
||||
elementRects: Map<T, Rect>,
|
||||
isPortEnabled: (port: PortId) => boolean,
|
||||
margin: number,
|
||||
callbacks: {
|
||||
onSelected?: (element: T) => void
|
||||
@ -22,22 +23,41 @@ export function useSelection<T>(
|
||||
const initiallySelected = new Set<T>()
|
||||
const selected = shallowReactive(new Set<T>())
|
||||
const hoveredNode = ref<NodeId>()
|
||||
const hoveredPort = ref<PortId>()
|
||||
const hoveredElement = ref<Element>()
|
||||
|
||||
useEvent(document, 'pointerover', (event) => {
|
||||
if (event.target instanceof Element) {
|
||||
const widgetPort = event.target.closest('.WidgetPort')
|
||||
hoveredPort.value =
|
||||
(
|
||||
widgetPort instanceof HTMLElement &&
|
||||
'port' in widgetPort.dataset &&
|
||||
typeof widgetPort.dataset.port === 'string'
|
||||
) ?
|
||||
(widgetPort.dataset.port as PortId)
|
||||
: undefined
|
||||
}
|
||||
hoveredElement.value = event.target instanceof Element ? event.target : undefined
|
||||
})
|
||||
|
||||
const hoveredPort = computed<PortId | undefined>(() => {
|
||||
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
|
||||
}
|
||||
|
||||
function readInitiallySelected() {
|
||||
initiallySelected.clear()
|
||||
for (const id of selected) initiallySelected.add(id)
|
||||
|
@ -13,11 +13,12 @@ const { provideFn, injectFn } = createContextStore(
|
||||
(
|
||||
navigator: NavigatorComposable,
|
||||
nodeRects: Map<NodeId, Rect>,
|
||||
isPortEnabled,
|
||||
callbacks: {
|
||||
onSelected?: (id: NodeId) => void
|
||||
onDeselected?: (id: NodeId) => void
|
||||
} = {},
|
||||
) => {
|
||||
return useSelection(navigator, nodeRects, SELECTION_BRUSH_MARGIN_PX, callbacks)
|
||||
return useSelection(navigator, nodeRects, isPortEnabled, SELECTION_BRUSH_MARGIN_PX, callbacks)
|
||||
},
|
||||
)
|
||||
|
6
app/gui2/src/providers/keyboard.ts
Normal file
6
app/gui2/src/providers/keyboard.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { useKeyboard } from '@/composables/keyboard'
|
||||
import { createContextStore } from '@/providers'
|
||||
|
||||
export { injectFn as injectKeyboard, provideFn as provideKeyboard }
|
||||
|
||||
const { provideFn, injectFn } = createContextStore('Keyboard watcher', () => useKeyboard())
|
@ -32,6 +32,7 @@ const { provideFn, injectFn } = createContextStore(
|
||||
icon: Ref<Icon>,
|
||||
connectedSelfArgumentId: Ref<Ast.AstId | undefined>,
|
||||
potentialSelfArgumentId: Ref<Ast.AstId | undefined>,
|
||||
conditionalPorts: Ref<Set<Ast.AstId>>,
|
||||
extended: Ref<boolean>,
|
||||
hasActiveAnimations: Ref<boolean>,
|
||||
emitOpenFullMenu: () => void,
|
||||
@ -46,6 +47,7 @@ const { provideFn, injectFn } = createContextStore(
|
||||
icon,
|
||||
connectedSelfArgumentId,
|
||||
potentialSelfArgumentId,
|
||||
conditionalPorts,
|
||||
extended,
|
||||
nodeSpanStart,
|
||||
hasActiveAnimations,
|
||||
|
@ -12,6 +12,7 @@ import { MappedKeyMap, MappedSet } from '@/util/containers'
|
||||
import { arrayEquals, tryGetIndex } from '@/util/data/array'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import { ReactiveDb, ReactiveIndex, ReactiveMapping } from '@/util/database/reactiveDb'
|
||||
import { syncSet } from '@/util/reactivity'
|
||||
import * as random from 'lib0/random'
|
||||
import * as set from 'lib0/set'
|
||||
import { methodPointerEquals, type MethodCall, type StackItem } from 'shared/languageServerTypes'
|
||||
@ -368,6 +369,7 @@ export class GraphDb {
|
||||
primarySubject,
|
||||
prefixes,
|
||||
documentation,
|
||||
conditionalPorts,
|
||||
} = newNode
|
||||
const differentOrDirty = (a: Ast.Ast | undefined, b: Ast.Ast | undefined) =>
|
||||
a?.id !== b?.id || (a && subtreeDirty(a.id))
|
||||
@ -383,6 +385,7 @@ export class GraphDb {
|
||||
)
|
||||
)
|
||||
node.prefixes = prefixes
|
||||
syncSet(node.conditionalPorts, conditionalPorts)
|
||||
// Ensure new fields can't be added to `NodeAstData` without this code being updated.
|
||||
const _allFieldsHandled = {
|
||||
outerExprId,
|
||||
@ -392,6 +395,7 @@ export class GraphDb {
|
||||
primarySubject,
|
||||
prefixes,
|
||||
documentation,
|
||||
conditionalPorts,
|
||||
} satisfies NodeDataFromAst
|
||||
}
|
||||
}
|
||||
@ -496,6 +500,8 @@ export interface NodeDataFromAst {
|
||||
/** A child AST in a syntactic position to be a self-argument input to the node. */
|
||||
primarySubject: Ast.AstId | undefined
|
||||
documentation: string | undefined
|
||||
/** Ports that are not targetable by default; they can be targeted while holding the modifier key. */
|
||||
conditionalPorts: Set<Ast.AstId>
|
||||
}
|
||||
|
||||
export interface NodeDataFromMetadata {
|
||||
@ -511,6 +517,7 @@ const baseMockNode = {
|
||||
prefixes: { enableRecording: undefined },
|
||||
primarySubject: undefined,
|
||||
documentation: undefined,
|
||||
conditionalPorts: new Set(),
|
||||
} satisfies Partial<Node>
|
||||
|
||||
/** This should only be used for supplying as initial props when testing.
|
||||
|
@ -472,6 +472,10 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
return getPortPrimaryInstance(id)?.rect.value
|
||||
}
|
||||
|
||||
function isPortEnabled(id: PortId): boolean {
|
||||
return getPortRelativeRect(id) != null
|
||||
}
|
||||
|
||||
function getPortNodeId(id: PortId): NodeId | undefined {
|
||||
return db.getExpressionNodeId(id as string as Ast.AstId) ?? getPortPrimaryInstance(id)?.nodeId
|
||||
}
|
||||
@ -673,6 +677,7 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
removePortInstance,
|
||||
getPortRelativeRect,
|
||||
getPortNodeId,
|
||||
isPortEnabled,
|
||||
updatePortValue,
|
||||
setEditedNode,
|
||||
updateState,
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { LazySyncEffectSet } from '@/util/reactivity'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
import { LazySyncEffectSet, syncSet } from '@/util/reactivity'
|
||||
import { fc, test } from '@fast-check/vitest'
|
||||
import { expect, vi } from 'vitest'
|
||||
import { nextTick, reactive, ref } from 'vue'
|
||||
|
||||
test('LazySyncEffectSet', async () => {
|
||||
@ -143,3 +144,13 @@ test('LazySyncEffectSet', async () => {
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test.prop({
|
||||
oldValues: fc.array(fc.integer()),
|
||||
newValues: fc.array(fc.integer()),
|
||||
})('syncSet', ({ oldValues, newValues }) => {
|
||||
const newState = new Set<number>(newValues)
|
||||
const target = new Set<number>(oldValues)
|
||||
syncSet(target, newState)
|
||||
expect([...target].sort()).toEqual([...newState].sort())
|
||||
})
|
||||
|
@ -949,3 +949,33 @@ test('Adding comments', () => {
|
||||
expr.update((expr) => Ast.Documented.new('Calculate five', expr))
|
||||
expect(expr.module.root()?.code()).toBe('## Calculate five\n2 + 2')
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ code: 'operator1', expected: { subject: 'operator1', accesses: [] } },
|
||||
{ code: 'operator1 foo bar', expected: { subject: 'operator1 foo bar', accesses: [] } },
|
||||
{ code: 'operator1.parse_json', expected: { subject: 'operator1', accesses: ['parse_json'] } },
|
||||
{
|
||||
code: 'operator1.parse_json operator2.to_json',
|
||||
expected: { subject: 'operator1.parse_json operator2.to_json', accesses: [] },
|
||||
},
|
||||
{
|
||||
code: 'operator1.parse_json foo bar',
|
||||
expected: { subject: 'operator1.parse_json foo bar', accesses: [] },
|
||||
},
|
||||
{
|
||||
code: 'operator1.parse_json.length',
|
||||
expected: { subject: 'operator1', accesses: ['parse_json', 'length'] },
|
||||
},
|
||||
{
|
||||
code: 'operator1.parse_json.length foo bar',
|
||||
expected: { subject: 'operator1.parse_json.length foo bar', accesses: [] },
|
||||
},
|
||||
{ code: 'operator1 + operator2', expected: { subject: 'operator1 + operator2', accesses: [] } },
|
||||
])('Access chain in $code', ({ code, expected }) => {
|
||||
const ast = Ast.parse(code)
|
||||
const { subject, accessChain } = Ast.accessChain(ast)
|
||||
expect({
|
||||
subject: subject.code(),
|
||||
accesses: accessChain.map((ast) => ast.rhs.code()),
|
||||
}).toEqual(expected)
|
||||
})
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { assert } from '@/util/assert'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { initializePrefixes, nodeFromAst } from '@/util/ast/node'
|
||||
import { initializePrefixes, nodeFromAst, primaryApplicationSubject } from '@/util/ast/node'
|
||||
import { initializeFFI } from 'shared/ast/ffi'
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
@ -27,3 +28,39 @@ test.each(['## Documentation only'])("'%s' should not be a node", (line) => {
|
||||
const node = nodeFromAst(ast)
|
||||
expect(node).toBeUndefined()
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ code: 'operator1', expected: undefined },
|
||||
{ code: 'operator1 foo bar', expected: undefined },
|
||||
{ code: 'operator1.parse_json', expected: { subject: 'operator1', accesses: ['parse_json'] } },
|
||||
{
|
||||
code: 'operator1.parse_json operator2.to_json',
|
||||
expected: { subject: 'operator1', accesses: ['parse_json'] },
|
||||
},
|
||||
{
|
||||
code: 'operator1.parse_json foo bar',
|
||||
expected: { subject: 'operator1', accesses: ['parse_json'] },
|
||||
},
|
||||
{
|
||||
code: 'operator1.parse_json.length',
|
||||
expected: { subject: 'operator1', accesses: ['parse_json', 'length'] },
|
||||
},
|
||||
{
|
||||
code: 'operator1.parse_json.length foo bar',
|
||||
expected: { subject: 'operator1', accesses: ['parse_json', 'length'] },
|
||||
},
|
||||
{ code: 'operator1 + operator2', expected: undefined },
|
||||
])('Primary application subject of $code', ({ code, expected }) => {
|
||||
const ast = Ast.Ast.parse(code)
|
||||
const module = ast.module
|
||||
const primaryApplication = primaryApplicationSubject(ast)
|
||||
const analyzed = primaryApplication && {
|
||||
subject: module.get(primaryApplication.subject).code(),
|
||||
accesses: primaryApplication.accessChain.map((id) => {
|
||||
const ast = module.get(id)
|
||||
assert(ast instanceof Ast.PropertyAccess)
|
||||
return ast.rhs.code()
|
||||
}),
|
||||
}
|
||||
expect(analyzed).toEqual(expected)
|
||||
})
|
||||
|
@ -25,27 +25,31 @@ export function nodeFromAst(ast: Ast.Ast): NodeDataFromAst | undefined {
|
||||
const pattern = nodeCode instanceof Ast.Assignment ? nodeCode.pattern : undefined
|
||||
const rootExpr = nodeCode instanceof Ast.Assignment ? nodeCode.expression : nodeCode
|
||||
const { innerExpr, matches } = prefixes.extractMatches(rootExpr)
|
||||
const primaryApplication = primaryApplicationSubject(innerExpr)
|
||||
return {
|
||||
outerExprId: ast.id,
|
||||
pattern,
|
||||
rootExpr,
|
||||
innerExpr,
|
||||
prefixes: matches,
|
||||
primarySubject: primaryApplicationSubject(innerExpr),
|
||||
primarySubject: primaryApplication?.subject,
|
||||
documentation,
|
||||
conditionalPorts: new Set(primaryApplication?.accessChain ?? []),
|
||||
}
|
||||
}
|
||||
|
||||
/** Given a node root, find a child AST that is the root of the access chain that is the subject of the primary
|
||||
* application.
|
||||
*/
|
||||
export function primaryApplicationSubject(ast: Ast.Ast): Ast.AstId | undefined {
|
||||
export function primaryApplicationSubject(
|
||||
ast: Ast.Ast,
|
||||
): { subject: Ast.AstId; accessChain: Ast.AstId[] } | undefined {
|
||||
// Descend into LHS of any sequence of applications.
|
||||
while (ast instanceof Ast.App) ast = ast.function
|
||||
// Require a sequence of at least one property access; descend into LHS.
|
||||
if (!(ast instanceof Ast.PropertyAccess)) return
|
||||
while (ast instanceof Ast.PropertyAccess && ast.lhs) ast = ast.lhs
|
||||
const { subject, accessChain } = Ast.accessChain(ast)
|
||||
// Require at least one property access.
|
||||
if (accessChain.length === 0) return
|
||||
// The leftmost element must be an identifier.
|
||||
if (!(ast instanceof Ast.Ident)) return
|
||||
return ast.id
|
||||
if (!(subject instanceof Ast.Ident)) return
|
||||
return { subject: subject.id, accessChain: accessChain.map((ast) => ast.id) }
|
||||
}
|
||||
|
@ -23,7 +23,9 @@ export class Rect {
|
||||
return new Rect(center.addScaled(size, -0.5), size)
|
||||
}
|
||||
|
||||
static FromDomRect(domRect: DOMRect): Rect {
|
||||
static FromDomRect(
|
||||
domRect: Readonly<{ x: number; y: number; width: number; height: number }>,
|
||||
): Rect {
|
||||
return new Rect(Vec2.FromXY(domRect), Vec2.FromSize(domRect))
|
||||
}
|
||||
|
||||
@ -41,6 +43,15 @@ export class Rect {
|
||||
return this.FromBounds(left, top, right, bottom)
|
||||
}
|
||||
|
||||
static Equal(a: Rect, b: Rect): boolean
|
||||
static Equal(a: Rect | null, b: Rect | null): boolean
|
||||
static Equal(a: Rect | undefined, b: Rect | undefined): boolean
|
||||
static Equal(a: Rect | null | undefined, b: Rect | null | undefined): boolean {
|
||||
if (!a && !b) return true
|
||||
if (!a || !b) return false
|
||||
return a.equals(b)
|
||||
}
|
||||
|
||||
offsetBy(offset: Vec2): Rect {
|
||||
return new Rect(this.pos.add(offset), this.size)
|
||||
}
|
||||
|
@ -150,3 +150,9 @@ export function debouncedGetter<T>(
|
||||
})
|
||||
return valueRef
|
||||
}
|
||||
|
||||
/** Update `target` to have the same entries as `newState`. */
|
||||
export function syncSet<T>(target: Set<T>, newState: Set<T>) {
|
||||
for (const oldKey of target) if (!newState.has(oldKey)) target.delete(oldKey)
|
||||
for (const newKey of newState) if (!target.has(newKey)) target.add(newKey)
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import GraphNode from '@/components/GraphEditor/GraphNode.vue'
|
||||
import { useKeyboard } from '@/composables/keyboard'
|
||||
import { useNavigator } from '@/composables/navigator'
|
||||
import { provideGraphSelection } from '@/providers/graphSelection'
|
||||
import type { Node } from '@/stores/graph'
|
||||
@ -7,8 +8,8 @@ import { Ast } from '@/util/ast'
|
||||
import { Rect } from '@/util/data/rect'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import { logEvent } from 'histoire/client'
|
||||
import { type SourceRange } from 'shared/yjsModel'
|
||||
import { computed, reactive, ref, watchEffect } from 'vue'
|
||||
import { type SourceRange } from '../shared/yjsModel'
|
||||
import { createSetupComponent } from './histoire/utils'
|
||||
|
||||
const nodeBinding = ref('binding')
|
||||
@ -43,6 +44,7 @@ const node = computed((): Node => {
|
||||
primarySubject: undefined,
|
||||
vis: undefined,
|
||||
documentation: undefined,
|
||||
conditionalPorts: new Set(),
|
||||
}
|
||||
})
|
||||
|
||||
@ -56,9 +58,11 @@ watchEffect((onCleanup) => {
|
||||
})
|
||||
})
|
||||
|
||||
const navigator = useNavigator(ref())
|
||||
const keyboard = useKeyboard()
|
||||
const navigator = useNavigator(ref(), keyboard)
|
||||
const allPortsEnabled = () => true
|
||||
const SetupStory = createSetupComponent((app) => {
|
||||
const selection = provideGraphSelection._mock([navigator, mockRects], app)
|
||||
const selection = provideGraphSelection._mock([navigator, mockRects, allPortsEnabled], app)
|
||||
watchEffect(() => {
|
||||
if (selected.value) {
|
||||
selection.selectAll()
|
||||
|
Loading…
Reference in New Issue
Block a user