From 663e166b4ff999f8f4b56cd9d35e3e94732b4eee Mon Sep 17 00:00:00 2001 From: Grant Forrest Date: Tue, 4 Jun 2024 21:58:26 -0400 Subject: [PATCH] [sc] detangle canvas from object positioning --- .../web/src/components/canvas/Canvas.ts | 207 +----------------- .../src/components/canvas/CanvasObject.tsx | 81 ++++--- .../src/components/canvas/CanvasWallpaper.tsx | 2 +- .../web/src/components/canvas/ObjectBounds.ts | 33 ++- .../web/src/components/canvas/README.md | 17 ++ .../web/src/components/canvas/Selections.ts | 1 - .../src/components/canvas/viewportHooks.ts | 1 - .../src/components/project/ConnectionMenu.tsx | 2 - .../components/project/ConnectionSource.tsx | 39 +++- .../web/src/components/project/TaskNode.tsx | 6 +- .../web/src/components/project/state.ts | 5 + pnpm-lock.yaml | 2 + 12 files changed, 141 insertions(+), 255 deletions(-) create mode 100644 apps/star-chart/web/src/components/canvas/README.md create mode 100644 apps/star-chart/web/src/components/project/state.ts diff --git a/apps/star-chart/web/src/components/canvas/Canvas.ts b/apps/star-chart/web/src/components/canvas/Canvas.ts index b6793f85..cc85bb65 100644 --- a/apps/star-chart/web/src/components/canvas/Canvas.ts +++ b/apps/star-chart/web/src/components/canvas/Canvas.ts @@ -1,15 +1,14 @@ import { EventSubscriber } from '@a-type/utils'; -import { proxy, subscribe } from 'valtio'; -import { addVectors, snap, vectorDistance } from './math.js'; -import { Vector2 } from './types.js'; -import { Viewport } from './Viewport.js'; +import { SpringValue, to } from '@react-spring/web'; +import { snap } from './math.js'; import { ObjectBounds } from './ObjectBounds.js'; import { ObjectPositions } from './ObjectPositions.js'; -import { SpringValue, to } from '@react-spring/web'; import { Selections } from './Selections.js'; +import { Vector2 } from './types.js'; +import { Viewport } from './Viewport.js'; type ActiveGestureState = { - objectId: string | null; + targetObjectId: string | null; position: Vector2 | null; startPosition: Vector2 | null; }; @@ -44,37 +43,19 @@ export type CanvasEvents = { * for both CanvasContext and SizingContext */ export class Canvas extends EventSubscriber { - private activeGesture = proxy({ - objectId: null, - position: null, - startPosition: null, - }); - readonly bounds = new ObjectBounds(); - readonly positions = new ObjectPositions(); readonly selections = new Selections(); readonly objectElements = new Map(); readonly objectMetadata = new Map(); - private positionObservers: Record void>> = - {}; - - private unsubscribeActiveGesture: () => void; - private _positionSnapIncrement = 1; - private _gestureActive = false; - constructor( private viewport: Viewport, options?: CanvasOptions, ) { super(); - this.unsubscribeActiveGesture = subscribe( - this.activeGesture, - this.handleActiveGestureChange, - ); // @ts-ignore for debugging... window.canvas = this; this._positionSnapIncrement = options?.positionSnapIncrement ?? 1; @@ -84,117 +65,11 @@ export class Canvas extends EventSubscriber { return this._positionSnapIncrement; } - private onGestureStart() { - this._gestureActive = true; - this.emitGestureChange(); - this.viewport.element.style.setProperty('cursor', 'grabbing'); - } - - private onGestureEnd() { - this._gestureActive = true; - this.emitGestureChange(); - this.viewport.element.style.removeProperty('cursor'); - } - - private onGestureMove() { - this.emitGestureChange(); - } - - private emitGestureChange = () => { - if (!this.activeGesture.objectId || !this.activeGesture.position) return; - this.positions.update( - this.activeGesture.objectId, - this.activeGesture.position, - { source: 'gesture' }, - ); - this.emit( - `objectDrag:${this.activeGesture.objectId}`, - this.activeGesture.position, - ); - }; - - get isGestureActive() { - return this._gestureActive; - } - - get gestureDistance() { - const { position, startPosition } = this.activeGesture; - if (!position || !startPosition) { - return 0; - } - return vectorDistance(position, startPosition); - } - - private commitGesture = ( - objectId: string, - position: Vector2, - info: { source: 'gesture' | 'external' }, - ) => { - this.positions.update(objectId, position, info); - return this.emit(`objectDrop:${objectId}`, position, info); - }; - - /** - * Commits the gesture data from the active gesture store to - * the backend - */ - private commitActiveGesture = () => { - const { objectId, position } = this.activeGesture; - if (!objectId || !position) return; - this.commitGesture(objectId, position, { source: 'gesture' }); - }; - - // private throttledCommitActiveGesture = throttle(this.commitActiveGesture, MOVE_THROTTLE_PERIOD, { trailing: false }); - - // subscribe to changes in active object position and forward them to the - // correct object - private handleActiveGestureChange = () => { - if (this.activeGesture.objectId) { - if (this.activeGesture.position) { - const position = this.activeGesture.position; - this.positionObservers[this.activeGesture.objectId]?.forEach((cb) => - cb(position), - ); - } - } - }; - - private clearActiveGesture = () => { - this.activeGesture.objectId = null; - this.activeGesture.position = null; - this.activeGesture.startPosition = null; - }; - - private snapPosition = (position: Vector2) => ({ + snapPosition = (position: Vector2) => ({ x: snap(position.x, this._positionSnapIncrement), y: snap(position.y, this._positionSnapIncrement), }); - onObjectDragStart = (screenPosition: Vector2, objectId: string) => { - const worldPosition = this.viewport.viewportToWorld(screenPosition, true); - this.activeGesture.objectId = objectId; - this.activeGesture.position = worldPosition; - this.activeGesture.startPosition = worldPosition; - this.onGestureStart(); - }; - - onObjectDrag = (screenPosition: Vector2, objectId: string) => { - const worldPosition = this.viewport.viewportToWorld(screenPosition, true); - this.activeGesture.objectId = objectId; - this.activeGesture.position = worldPosition; - this.onGestureMove(); - }; - - onObjectDragEnd = async (screenPosition: Vector2, objectId: string) => { - const worldPosition = this.viewport.viewportToWorld(screenPosition, true); - // capture the final position before committing - this.activeGesture.objectId = objectId; - this.activeGesture.position = worldPosition; - this.onGestureEnd(); - this.commitActiveGesture(); - this.clearActiveGesture(); - }; - onCanvasTap = (screenPosition: Vector2, info: CanvasGestureInfo) => { const worldPosition = this.viewport.viewportToWorld(screenPosition); this.emit('canvasTap', worldPosition, info); @@ -215,29 +90,17 @@ export class Canvas extends EventSubscriber { this.emit('canvasDragEnd', worldPosition, info); }; - /** - * Directly sets the world position of an object, applying - * clamping and snapping behaviors. - */ - setPosition = (objectId: string, worldPosition: Vector2) => { - this.commitGesture( - objectId, - this.viewport.clampToWorld(this.snapPosition(worldPosition)), - { source: 'external' }, - ); - }; - /** * Gets the instantaneous position of an object. */ getPosition = (objectId: string): Vector2 | null => { - const pos = this.positions.maybeGet(objectId); + const pos = this.getLivePosition(objectId); if (!pos) return null; return { x: pos.x.get(), y: pos.y.get() }; }; getCenter = (objectId: string): Vector2 | null => { - const pos = this.positions.maybeGet(objectId); + const pos = this.getLivePosition(objectId); if (!pos) return null; const bounds = this.bounds.getSize(objectId); if (!bounds) { @@ -279,55 +142,6 @@ export class Canvas extends EventSubscriber { return this.viewport.worldToViewport(worldPosition); }; - /** - * Moves an object relatively from its current position in world coordinates, applying - * clamping and snapping behaviors. - */ - movePositionRelative = ( - currentPosition: Vector2, - movement: Vector2, - objectId: string, - ) => { - this.commitGesture( - objectId, - this.viewport.clampToWorld( - this.snapPosition(addVectors(currentPosition, movement)), - ), - { source: 'external' }, - ); - }; - - /** - * Returns which object ID the world point intersects with - */ - hitTest = (worldPosition: Vector2): string | null => { - // TODO: faster - for (const [id, position] of this.positions.all()) { - const bounds = this.bounds.getSize(id); - if (!bounds) continue; - const x = position.x.get(); - const y = position.y.get(); - if ( - worldPosition.x >= x && - worldPosition.x <= x + bounds.width.get() && - worldPosition.y >= y && - worldPosition.y <= y + bounds.height.get() - ) { - return id; - } - } - - // TODO: - // const intersections = this.bounds.getIntersections({ - // x: worldPosition.x, - // y: worldPosition.y, - // width: 0, - // height: 0 - // }, 1) - - return null; - }; - registerElement = ( objectId: string, element: Element | null, @@ -338,7 +152,6 @@ export class Canvas extends EventSubscriber { this.bounds.observe(objectId, element); this.objectMetadata.set(objectId, metadata); } else { - console.info('unregistered', objectId); this.objectMetadata.delete(objectId); const el = this.objectElements.get(objectId); if (el) { @@ -348,7 +161,5 @@ export class Canvas extends EventSubscriber { } }; - dispose = () => { - this.unsubscribeActiveGesture(); - }; + dispose = () => {}; } diff --git a/apps/star-chart/web/src/components/canvas/CanvasObject.tsx b/apps/star-chart/web/src/components/canvas/CanvasObject.tsx index cdd949d8..058632e3 100644 --- a/apps/star-chart/web/src/components/canvas/CanvasObject.tsx +++ b/apps/star-chart/web/src/components/canvas/CanvasObject.tsx @@ -95,20 +95,25 @@ export function useCanvasObject({ objectId, zIndex = 0, onDrop, + onDrag, metadata, }: { initialPosition: Vector2; objectId: string; - onDrop: (pos: Vector2) => any; + onDrop?: (pos: Vector2) => any; + onDrag?: (pos: Vector2) => any; zIndex?: number; metadata?: any; }) { const canvas = useCanvas(); - const { pickupSpring, isGrabbing, bindDragHandle } = useDrag({ - initialPosition, - objectId, - }); + const { pickupSpring, isGrabbing, bindDragHandle, dragSpring, dragStyle } = + useDrag({ + initialPosition, + objectId, + onDrag, + onDragEnd: onDrop, + }); /** * ONLY MOVES THE VISUAL NODE. @@ -117,29 +122,21 @@ export function useCanvasObject({ */ const moveTo = useCallback( (position: Vector2) => { - canvas.setPosition(objectId, { + dragSpring.start({ x: position.x, y: position.y, }); }, - [canvas, objectId], + [objectId, dragSpring], ); - useEffect(() => { - if (onDrop) { - return canvas.subscribe(`objectDrop:${objectId}`, onDrop); - } - }, [canvas, onDrop]); - // FIXME: find a better place to do this? useEffect( - () => - canvas.bounds.registerOrigin(objectId, canvas.positions.get(objectId)), + () => canvas.bounds.registerOrigin(objectId, dragStyle), [canvas, objectId], ); const canvasObject: CanvasObject = useMemo(() => { - const position = canvas.positions.get(objectId); const rootProps = { style: { /** @@ -148,7 +145,7 @@ export function useCanvasObject({ * up or dropped. */ transform: to( - [position.x, position.y, pickupSpring.value], + [dragStyle.x, dragStyle.y, pickupSpring.value], (xv, yv, grabEffect) => `translate(${xv}px, ${yv}px) scale(${1 + 0.05 * grabEffect})`, ), @@ -175,20 +172,20 @@ function useDrag({ objectId, onDragStart, onDragEnd, + onDrag, }: { initialPosition: Vector2; objectId: string; - onDragStart?: () => void; - onDragEnd?: () => void; + onDragStart?: (pos: Vector2) => void; + onDragEnd?: (pos: Vector2) => void; + onDrag?: (pos: Vector2) => any; }) { const canvas = useCanvas(); const viewport = useViewport(); const [isGrabbing, setIsGrabbing] = useState(false); - useEffectOnce(() => { - canvas.setPosition(objectId, initialPosition); - }); + const [dragStyle, dragSpring] = useSpring(() => initialPosition); const pickupSpring = useSpring({ value: isGrabbing ? 1 : 0, @@ -215,8 +212,10 @@ function useDrag({ // all we have to do to move the object as the screen auto-pans is re-trigger a // move event with the same cursor position - since the view itself has moved 'below' us, // the same cursor position produces the new world position. - const finalPosition = displace(cursorPosition); - canvas.onObjectDrag(finalPosition, objectId); + const finalPosition = viewport.viewportToWorld( + displace(cursorPosition), + ); + dragSpring.set(finalPosition); }, ); }, [autoPan, viewport, canvas, objectId, displace]); @@ -257,8 +256,13 @@ function useDrag({ const screenPosition = { x: state.xy[0], y: state.xy[1] }; autoPan.update(screenPosition); + // TODO: DELETE - canvas no longer controls object positions. // send to canvas to be interpreted into movement - canvas.onObjectDrag(displace(screenPosition), objectId); + // canvas.onObjectDrag(displace(screenPosition), objectId); + + const position = viewport.viewportToWorld(displace(screenPosition)); + dragSpring.set(position); + onDrag?.(position); }, onDragStart: (state) => { if ( @@ -285,8 +289,12 @@ function useDrag({ grabDisplacementRef.current.y = displacement.y; } // apply displacement and begin drag - canvas.onObjectDragStart(displace(screenPosition), objectId); - onDragStart?.(); + // TODO: DELETE - canvas no longer controls object positions. + // canvas.onObjectDragStart(displace(screenPosition), objectId); + setIsGrabbing(true); + const position = viewport.viewportToWorld(displace(screenPosition)); + dragSpring.start(position); + onDragStart?.(position); }, onDragEnd: (state) => { if ( @@ -298,7 +306,15 @@ function useDrag({ } const screenPosition = { x: state.xy[0], y: state.xy[1] }; - canvas.onObjectDragEnd(displace(screenPosition), objectId); + // TODO: DELETE - canvas no longer controls object positions. + // canvas.onObjectDragEnd(displace(screenPosition), objectId); + + // animate to final position, rounded by canvas + const position = canvas.snapPosition( + viewport.viewportToWorld(displace(screenPosition)), + ); + dragSpring.start(position); + // we leave this flag on for a few ms - the "drag" gesture // basically has a fade-out effect where it continues to // block gestures internal to the drag handle for a bit even @@ -311,11 +327,10 @@ function useDrag({ // invoke tap handler if provided. not sure how to type this.. if (state.tap) { - console.log('tap'); - state.args?.[0]?.onTap?.(); - } else { - onDragEnd?.(); + state.args?.[0]?.onTap?.(position); } + + onDragEnd?.(position); }, }); @@ -324,5 +339,7 @@ function useDrag({ pickupSpring, isGrabbing, moveTo, + dragSpring, + dragStyle, }; } diff --git a/apps/star-chart/web/src/components/canvas/CanvasWallpaper.tsx b/apps/star-chart/web/src/components/canvas/CanvasWallpaper.tsx index 8b8d5f51..0d12fe9f 100644 --- a/apps/star-chart/web/src/components/canvas/CanvasWallpaper.tsx +++ b/apps/star-chart/web/src/components/canvas/CanvasWallpaper.tsx @@ -33,7 +33,7 @@ export const CanvasWallpaper: React.FC = ({
diff --git a/apps/star-chart/web/src/components/canvas/ObjectBounds.ts b/apps/star-chart/web/src/components/canvas/ObjectBounds.ts index 550b9abe..9b952082 100644 --- a/apps/star-chart/web/src/components/canvas/ObjectBounds.ts +++ b/apps/star-chart/web/src/components/canvas/ObjectBounds.ts @@ -1,6 +1,6 @@ import { EventSubscriber } from '@a-type/utils'; import { SpringValue } from '@react-spring/web'; -import { Box, LiveVector2 } from './types.js'; +import { Box, LiveVector2, Vector2 } from './types.js'; export interface Bounds { width: SpringValue; @@ -110,6 +110,17 @@ export class ObjectBounds extends EventSubscriber<{ ); }; + hitTest = (point: Vector2) => { + return this.getIntersections( + { + ...point, + width: 0, + height: 0, + }, + 0, + ); + }; + intersects = (objectId: string, box: Box, threshold: number) => { const objectOrigin = this.getOrigin(objectId); const objectSize = this.getSize(objectId); @@ -121,21 +132,21 @@ export class ObjectBounds extends EventSubscriber<{ const objectWidth = objectSize.width.get(); const objectHeight = objectSize.height.get(); - if (objectWidth === 0 && objectHeight === 0) { - // this becomes a point containment check and always passes if true - return ( - objectX >= box.x && - objectX <= box.x + box.width && - objectY >= box.y && - objectY <= box.y + box.height - ); - } - const objectBottomRight = { x: objectX + objectWidth, y: objectY + objectHeight, }; + if (box.width === 0 && box.height === 0) { + // this becomes a point containment check and always passes if true + return ( + box.x >= objectX && + box.x <= objectBottomRight.x && + box.y >= objectY && + box.y <= objectBottomRight.y + ); + } + const boxTopLeft = { x: box.x, y: box.y, diff --git a/apps/star-chart/web/src/components/canvas/README.md b/apps/star-chart/web/src/components/canvas/README.md new file mode 100644 index 00000000..a7437234 --- /dev/null +++ b/apps/star-chart/web/src/components/canvas/README.md @@ -0,0 +1,17 @@ +# Canvas + +- Central repository for live positions and bounds of contained items +- Config for things like snap +- Handles gestures on the canvas itself + +# Viewport + +- Controls visible bounds +- Projects screen points to world +- ❌ Configures canvas size / infinite + +# Canvas Object + +- Handles local gestures +- Controls own visual position +- Reports position and bounds to Canvas diff --git a/apps/star-chart/web/src/components/canvas/Selections.ts b/apps/star-chart/web/src/components/canvas/Selections.ts index 53cbbf2e..ba851b3c 100644 --- a/apps/star-chart/web/src/components/canvas/Selections.ts +++ b/apps/star-chart/web/src/components/canvas/Selections.ts @@ -36,7 +36,6 @@ export class Selections extends EventSubscriber<{ clear = () => { this.set([]); - console.log('clear'); }; toggle = (objectId: string) => { diff --git a/apps/star-chart/web/src/components/canvas/viewportHooks.ts b/apps/star-chart/web/src/components/canvas/viewportHooks.ts index cf7ef95d..0c54b133 100644 --- a/apps/star-chart/web/src/components/canvas/viewportHooks.ts +++ b/apps/star-chart/web/src/components/canvas/viewportHooks.ts @@ -234,7 +234,6 @@ export function useViewportGestureControls( (isCanvasDrag(gestureDetails.current) || isTouch(gestureDetails.current)) ) { - console.log('tap'); canvas.onCanvasTap({ x: xy[0], y: xy[1] }, info); } diff --git a/apps/star-chart/web/src/components/project/ConnectionMenu.tsx b/apps/star-chart/web/src/components/project/ConnectionMenu.tsx index b5299ff7..a509da82 100644 --- a/apps/star-chart/web/src/components/project/ConnectionMenu.tsx +++ b/apps/star-chart/web/src/components/project/ConnectionMenu.tsx @@ -24,8 +24,6 @@ export function ConnectionMenu({ connection }: ConnectionMenuProps) { canvas.selections.set([]); }; - console.log(sourceCenter.x); - return ( { + const objectIds = canvas.bounds.getIntersections( + { + x: worldPosition.x, + y: worldPosition.y, + width: 0, + height: 0, + }, + 0, + ); + const taskId = objectIds.find( + (id) => canvas.objectMetadata.get(id)?.type === 'task', + ); + return taskId ?? null; + }, + [canvas], + ); + const bind = useGesture({ onDragStart: ({ xy: [x, y], event }) => { event?.stopPropagation(); @@ -56,6 +77,8 @@ export function ConnectionSource({ y: worldPosition.y, immediate: true, }); + const taskId = hitTestTasks(worldPosition); + projectState.activeConnectionTarget = taskId; }, onDragEnd: async ({ xy: [x, y], event }) => { event?.stopPropagation(); @@ -66,23 +89,22 @@ export function ConnectionSource({ immediate: true, }); - // see if we're over a target - const objectId = canvas.hitTest(worldPosition); - if (objectId) { + const taskId = projectState.activeConnectionTarget; + if (taskId) { // don't link to self - if (objectId === sourceNodeId) { + if (taskId === sourceNodeId) { setActive(false); return; } - // verify it's a task node - const task = await client.tasks.get(objectId).resolved; + // double check it's a task + const task = await client.tasks.get(taskId).resolved; if (!task) { setActive(false); return; } - onConnection(objectId); + onConnection(taskId); } else { // no target - create a new task at this position and link const task = await client.tasks.put({ @@ -95,6 +117,7 @@ export function ConnectionSource({ onConnection(task.get('id')); } setActive(false); + projectState.activeConnectionTarget = null; }, }); diff --git a/apps/star-chart/web/src/components/project/TaskNode.tsx b/apps/star-chart/web/src/components/project/TaskNode.tsx index 4eda76ba..6fa83cdc 100644 --- a/apps/star-chart/web/src/components/project/TaskNode.tsx +++ b/apps/star-chart/web/src/components/project/TaskNode.tsx @@ -16,6 +16,8 @@ import { useCanvas } from '../canvas/CanvasProvider.jsx'; import { ConnectionSource } from './ConnectionSource.jsx'; import { useDownstreamCount, useUpstreamCount } from './hooks.js'; import { TaskMenu } from './TaskMenu.jsx'; +import { useSnapshot } from 'valtio'; +import { projectState } from './state.js'; export interface TaskNodeProps { task: Task; @@ -53,6 +55,8 @@ export function TaskNode({ task }: TaskNodeProps) { useDownstreamCount(id); const { uncompleted: upstreams } = useUpstreamCount(id); + const { activeConnectionTarget } = useSnapshot(projectState); + return ( @@ -105,7 +110,6 @@ function TaskFullContent({ const client = hooks.useClient(); const createConnectionTo = useCallback( (targetTaskId: string) => { - console.log('connecting', id, targetTaskId); client.connections.put({ id: `connection-${id}-${targetTaskId}`, projectId, diff --git a/apps/star-chart/web/src/components/project/state.ts b/apps/star-chart/web/src/components/project/state.ts new file mode 100644 index 00000000..76c02a26 --- /dev/null +++ b/apps/star-chart/web/src/components/project/state.ts @@ -0,0 +1,5 @@ +import { proxy } from 'valtio'; + +export const projectState = proxy({ + activeConnectionTarget: null as string | null, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 472067ca..3fe84710 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -178,6 +178,8 @@ importers: specifier: ^18.2.0 version: 18.2.0 + apps/gnocchi/verdant/dist/esm/client: {} + apps/gnocchi/web: dependencies: '@a-type/ui':