diff --git a/CHANGELOG.md b/CHANGELOG.md index 39715b401ed0..f95b2fae317d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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] diff --git a/app/gui2/e2e/graphNavigator.spec.ts b/app/gui2/e2e/graphNavigator.spec.ts new file mode 100644 index 000000000000..7305ce94f699 --- /dev/null +++ b/app/gui2/e2e/graphNavigator.spec.ts @@ -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), + }), + ), + ) +}) diff --git a/app/gui2/e2e/selectingNodes.spec.ts b/app/gui2/e2e/selectingNodes.spec.ts index e669170edf61..1ec489862968 100644 --- a/app/gui2/e2e/selectingNodes.spec.ts +++ b/app/gui2/e2e/selectingNodes.spec.ts @@ -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) +}) diff --git a/app/gui2/src/components/GraphEditor.vue b/app/gui2/src/components/GraphEditor.vue index cb6d4c7f9681..7fda47d21dbe 100644 --- a/app/gui2/src/components/GraphEditor.vue +++ b/app/gui2/src/components/GraphEditor.vue @@ -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() 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 diff --git a/app/gui2/src/components/GraphEditor/GraphNodes.vue b/app/gui2/src/components/GraphEditor/GraphNodes.vue index 9fae9dfb5a65..2e7073732d56 100644 --- a/app/gui2/src/components/GraphEditor/GraphNodes.vue +++ b/app/gui2/src/components/GraphEditor/GraphNodes.vue @@ -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]) => diff --git a/app/gui2/src/composables/__tests__/events.test.ts b/app/gui2/src/composables/__tests__/events.test.ts new file mode 100644 index 000000000000..3ecbfeda8bc3 --- /dev/null +++ b/app/gui2/src/composables/__tests__/events.test.ts @@ -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], + ]) + }) +}) diff --git a/app/gui2/src/composables/events.ts b/app/gui2/src/composables/events.ts index d122281dd964..36bf8a519dfe 100644 --- a/app/gui2/src/composables/events.ts +++ b/app/gui2/src/composables/events.ts @@ -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 +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 = 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 } +} diff --git a/app/gui2/src/composables/navigator.ts b/app/gui2/src/composables/navigator.ts index 63ac39972a46..3b202c701b14 100644 --- a/app/gui2/src/composables/navigator.ts +++ b/app/gui2/src/composables/navigator.ts @@ -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 -export function useNavigator(viewportNode: Ref, keyboard: KeyboardComposable) { +export function useNavigator( + viewportNode: Ref, + keyboard: KeyboardComposable, + options: NavigatorOptions = {}, +) { + const predicate = options.predicate ?? ((_) => true) const size = useResizeObserver(viewportNode) const targetCenter = shallowRef(Vec2.Zero) const center = useApproachVec(targetCenter, 100, 0.02) @@ -34,15 +50,18 @@ export function useNavigator(viewportNode: Ref, 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, 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, keyboard: K } return proxyRefs({ - events: { + pointerEvents: { dragover(e: DragEvent) { eventMousePos.value = eventScreenPos(e) }, @@ -304,6 +323,7 @@ export function useNavigator(viewportNode: Ref, keyboard: K e.preventDefault() }, }, + keyboardEvents: panArrows.events, translate, targetCenter: readonly(targetCenter), targetScale: readonly(targetScale), diff --git a/app/gui2/stories/SelectionBrushWrapper.vue b/app/gui2/stories/SelectionBrushWrapper.vue index 98d9bcaa92cd..93f328367a1f 100644 --- a/app/gui2/stories/SelectionBrushWrapper.vue +++ b/app/gui2/stories/SelectionBrushWrapper.vue @@ -27,7 +27,7 @@ const scaledSelectionAnchor = computed(() => selectionAnchor.value?.scale(naviga