Moving nodes or camera with arrows (#10179)

Fixes #10029

[Screencast from 2024-06-05 11-40-50.webm](https://github.com/enso-org/enso/assets/3919101/8dcb9099-5489-488c-86dc-560325e84f52)
This commit is contained in:
Adam Obuchowicz 2024-06-06 18:47:02 +02:00 committed by GitHub
parent 01d292af30
commit 291db8aa07
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 427 additions and 13 deletions

View File

@ -1,5 +1,12 @@
# Next Release
#### Enso IDE
- [Arrows navigation][10179] selected nodes may be moved around, or entire scene
if no node is selected.
[10179]: https://github.com/enso-org/enso/pull/10179
#### Enso Standard Library
- [Added Statistic.Product][10122]

View File

@ -0,0 +1,32 @@
import { test } from '@playwright/test'
import assert from 'assert'
import * as actions from './actions'
import { expect } from './customExpect'
import * as locate from './locate'
test('Navigating with arrows', async ({ page }) => {
await actions.goToGraph(page)
// Make sure nothing else is focused right now.
await locate.graphEditor(page).click({ position: { x: 400, y: 400 } })
const allNodes = await locate.graphNode(page).all()
const receiveBBoxes = () =>
Promise.all(
Array.from(allNodes, (node) =>
node.boundingBox().then((bbox) => {
assert(bbox != null)
return bbox
}),
),
)
const initialBBoxes = await receiveBBoxes()
await page.keyboard.press('ArrowLeft', { delay: 500 })
const newBBoxes = await receiveBBoxes()
expect(newBBoxes).toEqual(
Array.from(initialBBoxes, (bbox) =>
expect.objectContaining({
x: expect.not.closeTo(bbox.x),
y: expect.closeTo(bbox.y),
}),
),
)
})

View File

@ -87,3 +87,25 @@ test('Deleting selected node with delete key', async ({ page }) => {
await page.keyboard.press('Delete')
await expect(locate.graphNode(page)).toHaveCount(nodesCount - 1)
})
test('Moving selected nodes', async ({ page }) => {
await actions.goToGraph(page)
const movedNode = locate.graphNodeByBinding(page, 'final')
const notMovedNode = locate.graphNodeByBinding(page, 'sum')
await locate.graphNodeIcon(movedNode).click()
// Selection may affect bounding box: wait until it's actually selected.
await expect(movedNode).toBeSelected()
const initialBBox = await movedNode.boundingBox()
const initialNotMovedBBox = await notMovedNode.boundingBox()
assert(initialBBox)
assert(initialNotMovedBBox)
await page.keyboard.press('ArrowLeft', { delay: 500 })
const bbox = await movedNode.boundingBox()
const notMovedBBox = await notMovedNode.boundingBox()
assert(bbox)
assert(notMovedBBox)
await expect(bbox.x).not.toBeCloseTo(initialBBox.x)
await expect(bbox.y).toBeCloseTo(initialBBox.y)
await expect(notMovedBBox.x).toBeCloseTo(initialNotMovedBBox.x)
await expect(notMovedBBox.y).toBeCloseTo(initialNotMovedBBox.y)
})

View File

@ -36,7 +36,7 @@ import { groupColorVar } from '@/composables/nodeColors'
import type { PlacementStrategy } from '@/composables/nodeCreation'
import { useStackNavigator } from '@/composables/stackNavigator'
import { useSyncLocalStorage } from '@/composables/syncLocalStorage'
import { provideGraphNavigator } from '@/providers/graphNavigator'
import { provideGraphNavigator, type GraphNavigator } from '@/providers/graphNavigator'
import { provideNodeColors } from '@/providers/graphNodeColors'
import { provideNodeCreation } from '@/providers/graphNodeCreation'
import { provideGraphSelection } from '@/providers/graphSelection'
@ -86,7 +86,9 @@ onUnmounted(() => {
const viewportNode = ref<HTMLElement>()
onMounted(() => viewportNode.value?.focus())
const graphNavigator = provideGraphNavigator(viewportNode, keyboard)
const graphNavigator: GraphNavigator = provideGraphNavigator(viewportNode, keyboard, {
predicate: (e) => (e instanceof KeyboardEvent ? nodeSelection.selected.size === 0 : true),
})
// === Client saved state ===
@ -252,8 +254,10 @@ useEvent(window, 'keydown', (event) => {
(!keyboardBusyExceptIn(documentationEditorArea.value) && undoBindingsHandler(event)) ||
(!keyboardBusy() && graphBindingsHandler(event)) ||
(!keyboardBusyExceptIn(codeEditorArea.value) && codeEditorHandler(event)) ||
(!keyboardBusyExceptIn(documentationEditorArea.value) && documentationEditorHandler(event))
(!keyboardBusyExceptIn(documentationEditorArea.value) && documentationEditorHandler(event)) ||
(!keyboardBusy() && graphNavigator.keyboardEvents.keydown(event))
})
useEvent(
window,
'pointerdown',
@ -649,7 +653,7 @@ const groupColors = computed(() => {
class="GraphEditor viewport"
:class="{ draggingEdge: graphStore.mouseEditedEdge != null }"
:style="groupColors"
v-on.="graphNavigator.events"
v-on.="graphNavigator.pointerEvents"
v-on..="nodeSelection.events"
@click="handleClick"
@dragover.prevent

View File

@ -3,12 +3,15 @@ import GraphNode from '@/components/GraphEditor/GraphNode.vue'
import UploadingFile from '@/components/GraphEditor/UploadingFile.vue'
import { useDragging } from '@/components/GraphEditor/dragging'
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
import { useArrows, useEvent } from '@/composables/events'
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'
import type { AstId } from '@/util/ast/abstract'
import type { Vec2 } from '@/util/data/vec2'
import { set } from 'lib0'
import { stackItemsEqual } from 'shared/languageServerTypes'
import { computed, toRaw } from 'vue'
@ -24,6 +27,7 @@ const emit = defineEmits<{
}>()
const projectStore = useProjectStore()
const selection = injectGraphSelection()
const graphStore = useGraphStore()
const dragging = useDragging()
const navigator = injectGraphNavigator(true)
@ -33,6 +37,18 @@ function nodeIsDragged(movedId: NodeId, offset: Vec2) {
dragging.startOrUpdate(movedId, scaledOffset)
}
const displacingWithArrows = useArrows(
(pos, type) => {
const oneOfMoved = set.first(selection.selected)
if (!oneOfMoved) return false
dragging.startOrUpdate(oneOfMoved, pos.relative)
if (type === 'stop') dragging.finishDrag()
},
{ predicate: (_) => selection.selected.size > 0 },
)
useEvent(window, 'keydown', displacingWithArrows.events.keydown)
const uploadingFiles = computed<[FileName, File][]>(() => {
const currentStackItem = projectStore.executionContext.getStackTop()
return [...projectStore.awareness.allUploads()].filter(([, file]) =>

View File

@ -0,0 +1,182 @@
import { Vec2 } from '@/util/data/vec2'
import { afterEach, beforeEach, expect, test, vi, type Mock, type MockInstance } from 'vitest'
import { effectScope, nextTick } from 'vue'
import { useArrows } from '../events'
let rafSpy: MockInstance
const rafCallbacks: FrameRequestCallback[] = []
beforeEach(() => {
rafCallbacks.length = 0
rafSpy = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((cb) => rafCallbacks.push(cb))
})
afterEach(() => {
if (rafCallbacks.length > 0) {
runFrame(Infinity)
expect(rafCallbacks, 'Some RAF callbacks leaked from test').toEqual([])
}
rafSpy.mockRestore()
})
function runFrame(t: number) {
const callbacks = rafCallbacks.splice(0, rafCallbacks.length)
for (const cb of callbacks) {
cb(t)
}
}
function vecMatcher([x, y]: [number, number]) {
return expect.objectContaining({
x: expect.closeTo(x),
y: expect.closeTo(y),
})
}
function keyEvent(type: 'keydown' | 'keyup', options: KeyboardEventInit & { timeStamp?: number }) {
const event = new KeyboardEvent(type, options)
if (options.timeStamp != null) {
vi.spyOn(event, 'timeStamp', 'get').mockReturnValue(options.timeStamp)
}
return event
}
type CbSequenceStep = [string, [number, number], [number, number], KeyboardEvent | undefined]
function checkCbSequence(cb: Mock, steps: CbSequenceStep[]) {
let i = 1
for (const [type, offset, delta, event] of steps) {
expect(cb).toHaveBeenNthCalledWith(
i++,
{
initial: Vec2.Zero,
absolute: vecMatcher(offset),
relative: vecMatcher(offset),
delta: vecMatcher(delta),
},
type,
event,
)
}
}
test.each`
pressedKeys | velocity | t0 | t | offset | delta
${['ArrowRight']} | ${10} | ${2} | ${[2, 3, 1002, 1003]} | ${[[0.0, 0.0], [0.01, 0], [10, 0], [10.01, 0]]} | ${[[0.0, 0.0], [0.01, 0], [9.99, 0], [0.01, 0]]}
${['ArrowLeft']} | ${10} | ${2} | ${[2, 1002]} | ${[[0.0, 0.0], [-10, 0]]} | ${[[0.0, 0.0], [-10, 0]]}
${['ArrowUp']} | ${10} | ${2} | ${[2, 1002]} | ${[[0.0, 0.0], [0, -10]]} | ${[[0.0, 0.0], [0, -10]]}
${['ArrowDown']} | ${20} | ${2} | ${[2, 3, 1002]} | ${[[0.0, 0.0], [0, 0.02], [0, 20]]} | ${[[0.0, 0.0], [0, 0.02], [0, 19.98]]}
${['ArrowRight', 'ArrowDown']} | ${10} | ${1000} | ${[2000, 3000]} | ${[[10, 10], [20, 20]]} | ${[[10, 10], [10, 10]]}
${['ArrowUp', 'ArrowLeft']} | ${10} | ${1000} | ${[2000, 3000]} | ${[[-10, -10], [-20, -20]]} | ${[[-10, -10], [-10, -10]]}
`(
'useArrows with $pressedKeys keys and $velocity velocity',
async ({ pressedKeys, velocity, t0, t, offset, delta }) => {
await effectScope().run(async () => {
const cb = vi.fn()
const expectedSequence: CbSequenceStep[] = []
const arrows = useArrows(cb, { velocity })
expect(arrows.moving.value).toBeFalsy()
const keydownEvents = Array.from(pressedKeys, (key) =>
keyEvent('keydown', { key, timeStamp: t0 }),
)
for (const event of keydownEvents) {
arrows.events.keydown(event)
}
await nextTick()
expectedSequence.push(['start', [0, 0], [0, 0], keydownEvents[0]])
expect(arrows.moving.value).toBeTruthy
for (let i = 0; i < t.length - 1; ++i) {
runFrame(t[i])
await nextTick()
expectedSequence.push(['move', offset[i], delta[i], undefined])
}
const keyupEvents = Array.from(pressedKeys, (key) =>
keyEvent('keyup', { key, timeStamp: t[t.length - 1] }),
)
for (const event of keyupEvents) {
window.dispatchEvent(event)
}
await nextTick()
expectedSequence.push([
'stop',
offset[offset.length - 1],
delta[delta.length - 1],
keyupEvents[keyupEvents.length - 1],
])
expect(arrows.moving.value).toBeFalsy()
checkCbSequence(cb, expectedSequence)
})
},
)
test('useArrow with non-overlaping keystrokes', async () => {
await effectScope().run(async () => {
const cb = vi.fn()
const arrows = useArrows(cb, { velocity: 10 })
const rightDown = keyEvent('keydown', { key: 'ArrowRight', timeStamp: 0 })
const rightUp = keyEvent('keyup', { key: 'ArrowRight', timeStamp: 1000 })
const downDown = keyEvent('keydown', { key: 'ArrowDown', timeStamp: 2000 })
const downUp = keyEvent('keyup', { key: 'ArrowDown', timeStamp: 3000 })
arrows.events.keydown(rightDown)
await nextTick()
runFrame(500)
await nextTick()
window.dispatchEvent(rightUp)
await nextTick()
runFrame(1500)
await nextTick()
arrows.events.keydown(downDown)
await nextTick()
runFrame(2500)
await nextTick()
window.dispatchEvent(downUp)
await nextTick()
runFrame(3500)
await nextTick()
checkCbSequence(cb, [
['start', [0, 0], [0, 0], rightDown],
['move', [5, 0], [5, 0], undefined],
['stop', [10, 0], [5, 0], rightUp],
['start', [0, 0], [0, 0], downDown],
['move', [0, 5], [0, 5], undefined],
['stop', [0, 10], [0, 5], downUp],
])
})
})
test('useArrow with overlaping keystrokes', async () => {
await effectScope().run(async () => {
const cb = vi.fn()
const arrows = useArrows(cb, { velocity: 10 })
const rightDown = keyEvent('keydown', { key: 'ArrowRight', timeStamp: 0 })
const rightUp = keyEvent('keyup', { key: 'ArrowRight', timeStamp: 2000 })
const downDown = keyEvent('keydown', { key: 'ArrowDown', timeStamp: 1000 })
const downUp = keyEvent('keyup', { key: 'ArrowDown', timeStamp: 3000 })
arrows.events.keydown(rightDown)
await nextTick()
runFrame(500)
await nextTick()
arrows.events.keydown(downDown)
await nextTick()
runFrame(1500)
await nextTick()
window.dispatchEvent(rightUp)
await nextTick()
runFrame(2500)
await nextTick()
window.dispatchEvent(downUp)
await nextTick()
runFrame(3500)
await nextTick()
checkCbSequence(cb, [
['start', [0, 0], [0, 0], rightDown],
['move', [5, 0], [5, 0], undefined],
['move', [15, 5], [10, 5], undefined],
['move', [20, 15], [5, 10], undefined],
['stop', [20, 20], [0, 5], downUp],
])
})
})

View File

@ -16,6 +16,7 @@ import {
type ShallowRef,
type WatchSource,
} from 'vue'
import { useRaf } from './animation'
export function isTriggeredByKeyboard(e: MouseEvent | PointerEvent) {
if (e instanceof PointerEvent) return e.pointerType !== 'mouse'
@ -397,3 +398,133 @@ function computePosition(event: PointerEvent, initial: Vec2, last: Vec2): EventP
delta: new Vec2(event.clientX - last.x, event.clientY - last.y),
}
}
type ArrowKey = 'ArrowLeft' | 'ArrowUp' | 'ArrowRight' | 'ArrowDown'
type PressedKeys = Record<ArrowKey, boolean>
function isArrowKey(key: string): key is ArrowKey {
return key === 'ArrowLeft' || key === 'ArrowUp' || key === 'ArrowRight' || key === 'ArrowDown'
}
/**
* Options for `useArrows` composable.
*/
export interface UseArrowsOptions {
/** The velocity expressed in pixels per second. Defaults to 200. */
velocity?: number
/** Additional condition for move. */
predicate?: (e: KeyboardEvent) => boolean
}
/**
* Register for arrows navigating events.
*
* For simplicity, the handler API is very similar to `usePointer`, but the initial position will
* always be Vec2.Zero (and thus, the absolute and relative positions will be equal).
*
* The "drag" starts on first arrow keypress and ends with last arrow key release.
*
* @param handler callback on any event. The 'move' event is fired on every frame, and thus does
* not have any event associated (`event` parameter will be undefined).
* @param options
* @returns
*/
export function useArrows(
handler: (
pos: EventPosition,
eventType: PointerEventType,
event?: KeyboardEvent,
) => void | boolean,
options: UseArrowsOptions = {},
) {
const velocity = options.velocity ?? 200.0
const predicate = options.predicate ?? ((_) => true)
const clearedKeys: PressedKeys = {
ArrowLeft: false,
ArrowUp: false,
ArrowRight: false,
ArrowDown: false,
}
const pressedKeys: Ref<PressedKeys> = ref({ ...clearedKeys })
const moving = computed(
() =>
pressedKeys.value.ArrowLeft ||
pressedKeys.value.ArrowUp ||
pressedKeys.value.ArrowRight ||
pressedKeys.value.ArrowDown,
)
const v = computed(
() =>
new Vec2(
(pressedKeys.value.ArrowLeft ? -velocity : 0) +
(pressedKeys.value.ArrowRight ? velocity : 0),
(pressedKeys.value.ArrowUp ? -velocity : 0) + (pressedKeys.value.ArrowDown ? velocity : 0),
),
)
const referencePoint = ref({
t: 0,
position: Vec2.Zero,
})
const lastPosition = ref(Vec2.Zero)
const positionAt = (t: number) =>
referencePoint.value.position.add(v.value.scale((t - referencePoint.value.t) / 1000.0))
const callHandler = (
t: number,
eventType: PointerEventType,
event?: KeyboardEvent,
offset: Vec2 = positionAt(t),
) => {
const delta = offset.sub(lastPosition.value)
lastPosition.value = offset
const positions = {
initial: Vec2.Zero,
absolute: offset,
relative: offset,
delta,
}
if (handler(positions, eventType, event) !== false && event) {
event.stopImmediatePropagation()
event.preventDefault()
}
}
useRaf(moving, (t, _) => callHandler(t, 'move'))
const events = {
keydown(e: KeyboardEvent) {
const starting = !moving.value
if (e.repeat || !isArrowKey(e.key) || (starting && !predicate(e))) return
referencePoint.value = {
position: starting ? Vec2.Zero : positionAt(e.timeStamp),
t: e.timeStamp,
}
pressedKeys.value[e.key] = true
if (starting) {
lastPosition.value = Vec2.Zero
callHandler(e.timeStamp, 'start', e, referencePoint.value.position)
}
},
focusout() {
// Each focus change may make us miss some events, so it's safer to just cancel the movement.
pressedKeys.value = { ...clearedKeys }
},
}
useEvent(
window,
'keyup',
(e) => {
if (e.repeat) return
if (!moving.value) return
if (!isArrowKey(e.key)) return
referencePoint.value = {
position: positionAt(e.timeStamp),
t: e.timeStamp,
}
pressedKeys.value[e.key] = false
if (!moving.value) callHandler(e.timeStamp, 'stop', e, referencePoint.value.position)
},
{ capture: true },
)
return { events, moving }
}

View File

@ -1,7 +1,13 @@
/** @file A Vue composable for panning and zooming a DOM element. */
import { useApproach, useApproachVec } from '@/composables/animation'
import { PointerButtonMask, useEvent, usePointer, useResizeObserver } from '@/composables/events'
import {
PointerButtonMask,
useArrows,
useEvent,
usePointer,
useResizeObserver,
} from '@/composables/events'
import type { KeyboardComposable } from '@/composables/keyboard'
import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2'
@ -25,8 +31,18 @@ function elemRect(target: Element | undefined): Rect {
return Rect.Zero
}
export interface NavigatorOptions {
/* A predicate deciding if given event should initialize navigation */
predicate?: (e: PointerEvent | KeyboardEvent) => boolean
}
export type NavigatorComposable = ReturnType<typeof useNavigator>
export function useNavigator(viewportNode: Ref<Element | undefined>, keyboard: KeyboardComposable) {
export function useNavigator(
viewportNode: Ref<Element | undefined>,
keyboard: KeyboardComposable,
options: NavigatorOptions = {},
) {
const predicate = options.predicate ?? ((_) => true)
const size = useResizeObserver(viewportNode)
const targetCenter = shallowRef<Vec2>(Vec2.Zero)
const center = useApproachVec(targetCenter, 100, 0.02)
@ -34,15 +50,18 @@ export function useNavigator(viewportNode: Ref<Element | undefined>, keyboard: K
const targetScale = shallowRef(1)
const scale = useApproach(targetScale)
const panPointer = usePointer(
(pos) => {
scrollTo(center.value.addScaled(pos.delta, -1 / scale.value))
},
(pos) => scrollTo(center.value.addScaled(pos.delta, -1 / scale.value)),
{
requiredButtonMask: PointerButtonMask.Auxiliary,
predicate: (e) => e.target === e.currentTarget,
predicate: (e) => e.target === e.currentTarget && predicate(e),
},
)
const panArrows = useArrows(
(pos) => scrollTo(center.value.addScaled(pos.delta, 1 / scale.value)),
{ predicate, velocity: 1000 },
)
function eventScreenPos(e: { clientX: number; clientY: number }): Vec2 {
return new Vec2(e.clientX, e.clientY)
}
@ -141,7 +160,7 @@ export function useNavigator(viewportNode: Ref<Element | undefined>, keyboard: K
},
{
requiredButtonMask: PointerButtonMask.Secondary,
predicate: (e) => e.target === e.currentTarget,
predicate: (e) => e.target === e.currentTarget && predicate(e),
},
)
@ -257,7 +276,7 @@ export function useNavigator(viewportNode: Ref<Element | undefined>, keyboard: K
}
return proxyRefs({
events: {
pointerEvents: {
dragover(e: DragEvent) {
eventMousePos.value = eventScreenPos(e)
},
@ -304,6 +323,7 @@ export function useNavigator(viewportNode: Ref<Element | undefined>, keyboard: K
e.preventDefault()
},
},
keyboardEvents: panArrows.events,
translate,
targetCenter: readonly(targetCenter),
targetScale: readonly(targetScale),

View File

@ -27,7 +27,7 @@ const scaledSelectionAnchor = computed(() => selectionAnchor.value?.scale(naviga
<div
ref="viewportNode"
style="cursor: none; height: 100%"
v-on.="navigator.events"
v-on.="navigator.pointerEvents"
v-on..="selection.events"
>
<slot