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

Moving nodes or camera with arrows #10179

Merged
merged 12 commits into from
Jun 6, 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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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]
Expand Down
32 changes: 32 additions & 0 deletions app/gui2/e2e/graphNavigator.spec.ts
Original file line number Diff line number Diff line change
@@ -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),
}),
),
)
})
22 changes: 22 additions & 0 deletions app/gui2/e2e/selectingNodes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
12 changes: 8 additions & 4 deletions app/gui2/src/components/GraphEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 ===

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions app/gui2/src/components/GraphEditor/GraphNodes.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -24,6 +27,7 @@ const emit = defineEmits<{
}>()

const projectStore = useProjectStore()
const selection = injectGraphSelection()
const graphStore = useGraphStore()
const dragging = useDragging()
const navigator = injectGraphNavigator(true)
Expand All @@ -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]) =>
Expand Down
182 changes: 182 additions & 0 deletions app/gui2/src/composables/__tests__/events.test.ts
Original file line number Diff line number Diff line change
@@ -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],
])
})
})
Loading
Loading