Skip to content

Commit

Permalink
Scrollbars (#9310)
Browse files Browse the repository at this point in the history
Scrollbars
  • Loading branch information
kazcw authored Mar 12, 2024
1 parent 9d988e9 commit c61e397
Show file tree
Hide file tree
Showing 9 changed files with 284 additions and 25 deletions.
1 change: 1 addition & 0 deletions app/gui2/mock/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const graphNavigator: GraphNavigator = {
clientToSceneRect: () => Rect.Zero,
panAndZoomTo: () => {},
panTo: () => {},
scrollTo: () => {},
stepZoom: () => {},
transform: '',
prescaledTransform: '',
Expand Down
22 changes: 8 additions & 14 deletions app/gui2/src/components/GraphEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { performCollapse, prepareCollapsedInfo } from '@/components/GraphEditor/
import { Uploader, uploadedExpression } from '@/components/GraphEditor/upload'
import GraphMouse from '@/components/GraphMouse.vue'
import PlusButton from '@/components/PlusButton.vue'
import SceneScroller from '@/components/SceneScroller.vue'
import TopBar from '@/components/TopBar.vue'
import { useDoubleClick } from '@/composables/doubleClick'
import { keyboardBusy, keyboardBusyExceptIn, useEvent } from '@/composables/events'
Expand Down Expand Up @@ -190,25 +191,14 @@ onMounted(() => viewportNode.value?.focus())
function zoomToSelected() {
if (!viewportNode.value) return
let left = Infinity
let top = Infinity
let right = -Infinity
let bottom = -Infinity
const nodesToCenter =
nodeSelection.selected.size === 0 ? graphStore.db.nodeIdToNode.keys() : nodeSelection.selected
let bounds = Rect.Bounding()
for (const id of nodesToCenter) {
const rect = graphStore.vizRects.get(id) ?? graphStore.nodeRects.get(id)
if (!rect) continue
left = Math.min(left, rect.left)
right = Math.max(right, rect.right)
top = Math.min(top, rect.top)
bottom = Math.max(bottom, rect.bottom)
if (rect) bounds = Rect.Bounding(bounds, rect)
}
graphNavigator.panAndZoomTo(
Rect.FromBounds(left, top, right, bottom),
0.1,
Math.max(1, graphNavigator.scale),
)
graphNavigator.panAndZoomTo(bounds, 0.1, Math.max(1, graphNavigator.scale))
}
const graphBindingsHandler = graphBindings.handler({
Expand Down Expand Up @@ -651,6 +641,10 @@ function handleEdgeDrop(source: AstId, position: Vec2) {
<CodeEditor v-if="showCodeEditor" />
</Suspense>
</Transition>
<SceneScroller
:navigator="graphNavigator"
:scrollableArea="Rect.Bounding(...graphStore.visibleNodeAreas)"
/>
<GraphMouse />
</div>
</template>
Expand Down
2 changes: 1 addition & 1 deletion app/gui2/src/components/GraphEditor/GraphEdge.vue
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ function lengthTo(path: SVGPathElement, pos: Vec2): number {
let best: number | undefined
let bestDist: number | undefined
const tryPos = (len: number) => {
const dist = pos.distanceSquared(Vec2.FromDomPoint(path.getPointAtLength(len)))
const dist = pos.distanceSquared(Vec2.FromXY(path.getPointAtLength(len)))
if (bestDist == null || dist < bestDist) {
best = len
bestDist = dist
Expand Down
65 changes: 65 additions & 0 deletions app/gui2/src/components/SceneScroller.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<script setup lang="ts">
import ScrollControl, { type ScrollbarEvent } from '@/components/ScrollBar.vue'
import type { GraphNavigator } from '@/providers/graphNavigator'
import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2'
import { computed, ref } from 'vue'
const props = defineProps<{
navigator: GraphNavigator
scrollableArea: Rect
}>()
const scrollbarState = computed(() => scrollingState.value ?? scrollInputs.value)
const scrollInputs = computed(() => ({
scale: props.navigator.scale,
range: props.scrollableArea.size.scale(props.navigator.scale),
pos: props.navigator.viewport.pos.sub(props.scrollableArea.pos).scale(props.navigator.scale),
}))
const scrollingState = ref<{
/** Current scrollbar position, as offset in client units from the origin of the scrollable area. */
pos: Vec2
/** Zoom factor when scrolling started. */
readonly scale: number
/** Scrollbar range, in client units, when scrolling started. */
readonly range: Vec2
/** `pos` when scrolling started. */
readonly scrollStartPos: Vec2
/** Viewport center, in scene coordinates, when scrolling started. */
readonly scrollOrigin: Vec2
}>()
function scroll(event: ScrollbarEvent) {
switch (event.type) {
case 'start': {
const scrollStartPos = scrollInputs.value.pos
const scrollOrigin = props.navigator.viewport.center()
scrollingState.value = { ...scrollInputs.value, scrollStartPos, scrollOrigin }
break
}
case 'move': {
if (!scrollingState.value) return
scrollingState.value.pos = scrollingState.value.scrollStartPos.add(event.startOffset)
props.navigator.scrollTo(
scrollingState.value.scrollOrigin.addScaled(
event.startOffset,
1 / scrollingState.value.scale,
),
)
break
}
case 'stop': {
scrollingState.value = undefined
break
}
}
}
</script>

<template>
<ScrollControl
:size="scrollbarState.range"
:position="scrollbarState.pos"
@scroll="scroll($event)"
/>
</template>
163 changes: 163 additions & 0 deletions app/gui2/src/components/ScrollBar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<script setup lang="ts">
import { usePointer, useResizeObserver } from '@/composables/events'
import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2'
import { computed, ref, watchEffect } from 'vue'
const element = ref<HTMLElement>()
const props = defineProps<{
/** The size of the scrollable area, in client pixels. */
size: Vec2
/** The scrollbar's offset from the top-left of the scrollable area, in client pixels. */
position: Vec2
}>()
const emit = defineEmits<{
scroll: [event: ScrollbarEvent]
}>()
const BAR_END_MARGIN = 2
const BAR_WIDTH = 6
const viewportSize = useResizeObserver(element)
const range = computed(
() =>
Rect.Bounding(new Rect(Vec2.Zero, props.size), new Rect(props.position, viewportSize.value))
.size,
)
const xStart = ref('')
const yStart = ref('')
const xLength = ref('')
const yLength = ref('')
const xFull = ref(false)
const yFull = ref(false)
watchEffect(() => {
const viewportFraction = Vec2.ElementwiseProduct(viewportSize.value, range.value.reciprocal())
const barStartFraction = Vec2.ElementwiseProduct(props.position, range.value.reciprocal())
const trackLength = viewportSize.value.sub(
new Vec2(BAR_END_MARGIN * 2 + BAR_WIDTH, BAR_END_MARGIN * 2 + BAR_WIDTH),
)
const barStart = Vec2.ElementwiseProduct(trackLength, barStartFraction).max(Vec2.Zero)
const barLength = Vec2.ElementwiseProduct(trackLength, viewportFraction)
const barEnd = barStart.add(barLength).min(trackLength)
xStart.value = `${barStart.x}px`
xLength.value = `${barEnd.x - barStart.x}px`
yStart.value = `${barStart.y}px`
yLength.value = `${barEnd.y - barStart.y}px`
xFull.value = range.value.x === viewportSize.value.x
yFull.value = range.value.y === viewportSize.value.y
})
const dragging = ref<Vec2>()
function dragEventsHandler(axis: 'x' | 'y') {
return usePointer((pos, _event, eventType) => {
switch (eventType) {
case 'start': {
const factor = Vec2.ElementwiseProduct(range.value, viewportSize.value.reciprocal())
const speed = new Vec2(axis === 'x' ? factor.x : 0, axis === 'y' ? factor.y : 0)
if (speed.isZero()) return
dragging.value = speed
emit('scroll', { type: 'start' })
break
}
case 'move': {
if (!dragging.value) return
const startOffset = Vec2.ElementwiseProduct(pos.relative, dragging.value)
emit('scroll', { type: 'move', startOffset })
break
}
case 'stop': {
if (!dragging.value) return
dragging.value = undefined
emit('scroll', { type: 'stop' })
break
}
}
})
}
const xDrag = dragEventsHandler('x')
const yDrag = dragEventsHandler('y')
</script>
<script lang="ts">
export type ScrollbarEvent =
| {
type: 'start'
}
| {
type: 'move'
startOffset: Vec2
}
| {
type: 'stop'
}
</script>
<template>
<div ref="element" class="ScrollBar" @click.stop @pointerdown.stop @pointerup.stop>
<div class="bar vertical" :class="{ full: yFull }" v-on="yDrag.events" />
<div class="bar horizontal" :class="{ full: xFull }" v-on="xDrag.events" />
</div>
</template>
<style scoped>
.ScrollBar {
position: absolute;
width: 100%;
height: 100%;
pointer-events: none;
}
.vertical {
position: absolute;
top: v-bind('yStart');
height: v-bind('yLength');
width: v-bind('`${BAR_WIDTH}px`');
right: 2px;
margin-top: v-bind('`${BAR_END_MARGIN}px`');
margin-bottom: v-bind('`${BAR_WIDTH + BAR_END_MARGIN}px`');
}
.horizontal {
position: absolute;
left: v-bind('xStart');
width: v-bind('xLength');
height: v-bind('`${BAR_WIDTH}px`');
bottom: 2px;
margin-left: v-bind('`${BAR_END_MARGIN}px`');
margin-right: v-bind('`${BAR_WIDTH + BAR_END_MARGIN}px`');
}
.bar {
border-radius: v-bind('`${BAR_WIDTH / 2}px`');
pointer-events: all;
background-color: rgba(170 170 170 / 50%);
transition: background-color 0.2s ease-in;
&:hover {
transition: background-color 0.2s ease-in;
background-color: rgba(150 150 150 / 75%);
}
&:active {
transition: none;
background-color: rgba(130 130 130 / 100%);
}
}
.full {
transition: opacity 0.2s ease-in;
opacity: 0;
&:hover {
transition: opacity 0.2s ease-in;
opacity: 1;
}
&:active {
transition: none;
opacity: 1;
}
}
</style>
12 changes: 8 additions & 4 deletions app/gui2/src/composables/navigator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,8 @@ const PAN_AND_ZOOM_DEFAULT_SCALE_RANGE: ScaleRange = [0.1, 1]
const ZOOM_STEP_DEFAULT_SCALE_RANGE: ScaleRange = [0.1, 10]

function elemRect(target: Element | undefined): Rect {
if (target != null && target instanceof Element) {
const domRect = target.getBoundingClientRect()
return new Rect(new Vec2(domRect.x, domRect.y), new Vec2(domRect.width, domRect.height))
}
if (target != null && target instanceof Element)
return Rect.FromDomRect(target.getBoundingClientRect())
return Rect.Zero
}

Expand Down Expand Up @@ -118,6 +116,11 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
targetCenter.value = target.center()
}

/** Pan immediately to center the viewport at the given point, in scene coordinates. */
function scrollTo(newCenter: Vec2) {
center.value = newCenter
}

let zoomPivot = Vec2.Zero
const zoomPointer = usePointer((pos, _event, ty) => {
if (ty === 'start') {
Expand Down Expand Up @@ -329,5 +332,6 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
panTo,
viewport,
stepZoom,
scrollTo,
})
}
2 changes: 1 addition & 1 deletion app/gui2/src/util/data/__tests__/rect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ test.prop({
x: fc.nat(),
y: fc.nat(),
})('offsetToInclude', ({ rectX, rectY, width, height, x, y }) => {
const rect = new Rect(new Vec2(rectX, rectY), new Vec2(width, height))
const rect = Rect.XYWH(rectX, rectY, width, height)
const point = new Vec2(x, y)
const offsetRect = rect.offsetToInclude(point)
expect(
Expand Down
16 changes: 15 additions & 1 deletion app/gui2/src/util/data/rect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,21 @@ export class Rect {
}

static FromDomRect(domRect: DOMRect): Rect {
return new Rect(new Vec2(domRect.x, domRect.y), new Vec2(domRect.width, domRect.height))
return new Rect(Vec2.FromXY(domRect), Vec2.FromSize(domRect))
}

static Bounding(...rects: Rect[]): Rect {
let left = NaN
let top = NaN
let right = NaN
let bottom = NaN
for (const rect of rects) {
if (!(rect.left >= left)) left = rect.left
if (!(rect.top >= top)) top = rect.top
if (!(rect.right <= right)) right = rect.right
if (!(rect.bottom <= bottom)) bottom = rect.bottom
}
return this.FromBounds(left, top, right, bottom)
}

offsetBy(offset: Vec2): Rect {
Expand Down
Loading

0 comments on commit c61e397

Please sign in to comment.