Add new magnet axes (#8506)

- Closes #8460
- Add new magnet axes 32px below the bottom of the previous node, and 32px above the top of the next node

- Fix a bug where magnet alignment and "zoom to all" take into account nodes that no longer exist
- Fix a bug where "zoom to all" breaks completely right after deleting a node

# Important Notes
- Snapping with bounded cross axes were discussed during refinement, but are out of scope of this PR.
This commit is contained in:
somebody1234 2023-12-12 03:55:37 +10:00 committed by GitHub
parent b89d1b692a
commit 0974d5ff1e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 48 additions and 26 deletions

View File

@ -149,6 +149,7 @@ const graphBindingsHandler = graphBindings.handler({
for (const node of nodeSelection.selected) { for (const node of nodeSelection.selected) {
graphStore.deleteNode(node) graphStore.deleteNode(node)
} }
nodeSelection.selected.clear()
}) })
}, },
zoomToSelected() { zoomToSelected() {
@ -158,7 +159,7 @@ const graphBindingsHandler = graphBindings.handler({
let right = -Infinity let right = -Infinity
let bottom = -Infinity let bottom = -Infinity
const nodesToCenter = const nodesToCenter =
nodeSelection.selected.size === 0 ? graphStore.nodeRects.keys() : nodeSelection.selected nodeSelection.selected.size === 0 ? graphStore.currentNodeIds : nodeSelection.selected
for (const id of nodesToCenter) { for (const id of nodesToCenter) {
const rect = graphStore.nodeRects.get(id) const rect = graphStore.nodeRects.get(id)
if (!rect) continue if (!rect) continue

View File

@ -96,8 +96,8 @@ test.each`
} }
const grid = new SnapGrid(computed(() => rects)) const grid = new SnapGrid(computed(() => rects))
const xSnapped = new Rect(new Vec2(snappedRectPosition, 0.0), new Vec2(10.0, 10.0)) const xSnapped = new Rect(new Vec2(snappedRectPosition, 0.0), new Vec2(10.0, 10.0))
expect(grid.snap(xSnapped, 15.0)[0]).toBeCloseTo(expectedSnap) expect(grid.snap(xSnapped, 16.0).x).toBeCloseTo(expectedSnap)
const ySnapped = new Rect(new Vec2(0.0, snappedRectPosition), new Vec2(10.0, 10.0)) const ySnapped = new Rect(new Vec2(0.0, snappedRectPosition), new Vec2(10.0, 10.0))
expect(grid.snap(ySnapped, 15.0)[1]).toBeCloseTo(expectedSnap) expect(grid.snap(ySnapped, 16.0).y).toBeCloseTo(expectedSnap)
}, },
) )

View File

@ -4,13 +4,19 @@ import { useApproach } from '@/util/animation'
import { partitionPoint } from '@/util/array' import { partitionPoint } from '@/util/array'
import type { Opt } from '@/util/opt' import type { Opt } from '@/util/opt'
import { Rect } from '@/util/rect' import { Rect } from '@/util/rect'
import theme from '@/util/theme.json'
import { Vec2 } from '@/util/vec2' import { Vec2 } from '@/util/vec2'
import { iteratorFilter } from 'lib0/iterator' import { iteratorFilter } from 'lib0/iterator'
import { abs } from 'lib0/math'
import type { ExprId } from 'shared/yjsModel' import type { ExprId } from 'shared/yjsModel'
import { computed, markRaw, ref, watchEffect, type ComputedRef, type WatchStopHandle } from 'vue' import { computed, markRaw, ref, watchEffect, type ComputedRef, type WatchStopHandle } from 'vue'
const DRAG_SNAP_THRESHOLD = 15 const DRAG_SNAP_THRESHOLD = 16
const VERTICAL_GAP = theme.node.vertical_gap
interface PartialVec2 {
x: number | null
y: number | null
}
export class SnapGrid { export class SnapGrid {
leftAxes: ComputedRef<number[]> leftAxes: ComputedRef<number[]>
@ -26,29 +32,35 @@ export class SnapGrid {
this.rightAxes = computed(() => this.rightAxes = computed(() =>
Array.from(rects.value, (rect) => rect.right).sort((a, b) => a - b), Array.from(rects.value, (rect) => rect.right).sort((a, b) => a - b),
) )
this.topAxes = computed(() => Array.from(rects.value, (rect) => rect.top).sort((a, b) => a - b)) this.topAxes = computed(() =>
Array.from(rects.value, (rect) => rect.top)
.concat(Array.from(rects.value, (rect) => rect.bottom + VERTICAL_GAP))
.sort((a, b) => a - b),
)
this.bottomAxes = computed(() => this.bottomAxes = computed(() =>
Array.from(rects.value, (rect) => rect.bottom).sort((a, b) => a - b), Array.from(rects.value, (rect) => rect.bottom)
.concat(Array.from(rects.value, (rect) => rect.top - VERTICAL_GAP))
.sort((a, b) => a - b),
) )
} }
snappedMany(rects: Rect[], threshold: number): Vec2 { snappedMany(rects: Rect[], threshold: number): Vec2 {
const minSnap = rects.reduce<[x: number | null, y: number | null]>( const minSnap = rects.reduce<PartialVec2>(
(minSnap, rect) => { (minSnap, rect) => {
const [xSnap, ySnap] = this.snap(rect, threshold) const snap = this.snap(rect, threshold)
return [SnapGrid.minSnap(minSnap[0], xSnap), SnapGrid.minSnap(minSnap[1], ySnap)] return { x: SnapGrid.minSnap(minSnap.x, snap.x), y: SnapGrid.minSnap(minSnap.y, snap.y) }
}, },
[null, null], { x: null, y: null },
) )
return new Vec2(minSnap[0] ?? 0.0, minSnap[1] ?? 0.0) return new Vec2(minSnap.x ?? 0.0, minSnap.y ?? 0.0)
} }
snap(rect: Rect, threshold: number): [x: number | null, y: number | null] { snap(rect: Rect, threshold: number): PartialVec2 {
const leftSnap = SnapGrid.boundSnap(rect.left, this.leftAxes.value, threshold) const leftSnap = SnapGrid.boundSnap(rect.left, this.leftAxes.value, threshold)
const rightSnap = SnapGrid.boundSnap(rect.right, this.rightAxes.value, threshold) const rightSnap = SnapGrid.boundSnap(rect.right, this.rightAxes.value, threshold)
const topSnap = SnapGrid.boundSnap(rect.top, this.topAxes.value, threshold) const topSnap = SnapGrid.boundSnap(rect.top, this.topAxes.value, threshold)
const bottomSnap = SnapGrid.boundSnap(rect.bottom, this.bottomAxes.value, threshold) const bottomSnap = SnapGrid.boundSnap(rect.bottom, this.bottomAxes.value, threshold)
return [SnapGrid.minSnap(leftSnap, rightSnap), SnapGrid.minSnap(topSnap, bottomSnap)] return { x: SnapGrid.minSnap(leftSnap, rightSnap), y: SnapGrid.minSnap(topSnap, bottomSnap) }
} }
private static boundSnap(value: number, axes: number[], threshold: number): number | null { private static boundSnap(value: number, axes: number[], threshold: number): number | null {
@ -58,11 +70,11 @@ export class SnapGrid {
const notLowerNearest = axes[firstNotLower] const notLowerNearest = axes[firstNotLower]
const snapToHigher = notLowerNearest != null ? notLowerNearest - value : null const snapToHigher = notLowerNearest != null ? notLowerNearest - value : null
const snap = SnapGrid.minSnap(snapToLower, snapToHigher) const snap = SnapGrid.minSnap(snapToLower, snapToHigher)
return snap != null && abs(snap) <= threshold ? snap : null return snap != null && Math.abs(snap) <= threshold ? snap : null
} }
private static minSnap(a: Opt<number>, b: Opt<number>): number | null { private static minSnap(a: Opt<number>, b: Opt<number>): number | null {
if (a != null && b != null) return abs(a) < abs(b) ? a : b if (a != null && b != null) return Math.abs(a) < Math.abs(b) ? a : b
else return a ?? b ?? null else return a ?? b ?? null
} }
} }
@ -135,10 +147,10 @@ export function useDragging() {
const newSnappedOffsetTarget = snappedOffsetTarget.value const newSnappedOffsetTarget = snappedOffsetTarget.value
// Skip animation if target offset does not change significantly, to avoid shivering // Skip animation if target offset does not change significantly, to avoid shivering
// when node is snapped. // when node is snapped.
if (abs(newSnappedOffsetTarget.x - oldSnappedOffset.x) < 2.0) { if (Math.abs(newSnappedOffsetTarget.x - oldSnappedOffset.x) < 2.0) {
snapX.skip() snapX.skip()
} }
if (abs(newSnappedOffsetTarget.y - oldSnappedOffset.y) < 2.0) { if (Math.abs(newSnappedOffsetTarget.y - oldSnappedOffset.y) < 2.0) {
snapY.skip() snapY.skip()
} }
} }
@ -151,10 +163,10 @@ export function useDragging() {
createSnapGrid() { createSnapGrid() {
const nonDraggedRects = computed(() => { const nonDraggedRects = computed(() => {
const nonDraggedNodes = iteratorFilter( const nonDraggedNodes = iteratorFilter(
graphStore.nodeRects.entries(), graphStore.currentNodeIds.values(),
([id]) => !this.draggedNodes.has(id), (id) => !this.draggedNodes.has(id),
) )
return Array.from(nonDraggedNodes, ([, rect]) => rect) return Array.from(nonDraggedNodes, (id) => graphStore.nodeRects.get(id)!)
}) })
return new SnapGrid(nonDraggedRects) return new SnapGrid(nonDraggedRects)
} }

View File

@ -290,10 +290,11 @@ export class GraphDb {
} }
const functionAst = functionAst_.astExtended const functionAst = functionAst_.astExtended
if (!functionAst) return if (functionAst?.isTree(RawAst.Tree.Type.Function)) {
if (!functionAst.isTree(RawAst.Tree.Type.Function)) return
this.bindings.readFunctionAst(functionAst) this.bindings.readFunctionAst(functionAst)
} }
return currentNodeIds
}
assignUpdatedMetadata(node: Node, meta: NodeMetadata) { assignUpdatedMetadata(node: Node, meta: NodeMetadata) {
const newPosition = new Vec2(meta.x, -meta.y) const newPosition = new Vec2(meta.x, -meta.y)

View File

@ -54,6 +54,7 @@ export const useGraphStore = defineStore('graph', () => {
const editedNodeInfo = ref<NodeEditInfo>() const editedNodeInfo = ref<NodeEditInfo>()
const imports = ref<{ import: Import; span: ContentRange }[]>([]) const imports = ref<{ import: Import; span: ContentRange }[]>([])
const methodAst = ref<Ast.Function>() const methodAst = ref<Ast.Function>()
const currentNodeIds = ref(new Set<ExprId>())
const unconnectedEdge = ref<UnconnectedEdge>() const unconnectedEdge = ref<UnconnectedEdge>()
@ -109,7 +110,7 @@ export const useGraphStore = defineStore('graph', () => {
methodAst.value = getExecutedMethodAst(newRoot, proj.executionContext.getStackTop(), db) methodAst.value = getExecutedMethodAst(newRoot, proj.executionContext.getStackTop(), db)
if (methodAst.value) { if (methodAst.value) {
db.readFunctionAst(methodAst.value, (id) => meta.get(id)) currentNodeIds.value = db.readFunctionAst(methodAst.value, (id) => meta.get(id))
} }
}) })
} }
@ -215,6 +216,8 @@ export const useGraphStore = defineStore('graph', () => {
const node = db.nodeIdToNode.get(id) const node = db.nodeIdToNode.get(id)
if (!node) return if (!node) return
proj.module?.deleteExpression(node.outerExprId) proj.module?.deleteExpression(node.outerExprId)
nodeRects.delete(id)
node.pattern?.visitRecursive((ast) => exprRects.delete(ast.astId))
} }
function setNodeContent(id: ExprId, content: string) { function setNodeContent(id: ExprId, content: string) {
@ -331,6 +334,7 @@ export const useGraphStore = defineStore('graph', () => {
editedNodeInfo, editedNodeInfo,
unconnectedEdge, unconnectedEdge,
edges, edges,
currentNodeIds,
nodeRects, nodeRects,
vizRects, vizRects,
exprRects, exprRects,

View File

@ -84,7 +84,11 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
viewportNode.value.clientWidth / rect.width, viewportNode.value.clientWidth / rect.width,
), ),
) )
targetCenter.value = new Vec2(rect.left + rect.width / 2, rect.top + rect.height / 2) const centerX =
!Number.isFinite(rect.left) && !Number.isFinite(rect.width) ? 0 : rect.left + rect.width / 2
const centerY =
!Number.isFinite(rect.top) && !Number.isFinite(rect.height) ? 0 : rect.top + rect.height / 2
targetCenter.value = new Vec2(centerX, centerY)
} }
let zoomPivot = Vec2.Zero let zoomPivot = Vec2.Zero