mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 17:11:31 +03:00
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:
parent
01d292af30
commit
291db8aa07
@ -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]
|
||||
|
32
app/gui2/e2e/graphNavigator.spec.ts
Normal file
32
app/gui2/e2e/graphNavigator.spec.ts
Normal 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),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
@ -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)
|
||||
})
|
||||
|
@ -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
|
||||
|
@ -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]) =>
|
||||
|
182
app/gui2/src/composables/__tests__/events.test.ts
Normal file
182
app/gui2/src/composables/__tests__/events.test.ts
Normal 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],
|
||||
])
|
||||
})
|
||||
})
|
@ -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 }
|
||||
}
|
||||
|
@ -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),
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user