From 4d440ad3a12f12c38adc2101ba1e970c05cdcdac Mon Sep 17 00:00:00 2001 From: Grant Forrest Date: Wed, 5 Jun 2024 10:55:22 -0400 Subject: [PATCH 1/2] [sc] reorganize canvas and viewport --- .../web/src/components/canvas/Canvas.ts | 61 ++++++--- .../src/components/canvas/CanvasProvider.tsx | 37 +----- .../src/components/canvas/CanvasSvgLayer.tsx | 17 +-- .../src/components/canvas/CanvasWallpaper.tsx | 10 +- .../web/src/components/canvas/Viewport.ts | 68 +++------- .../components/canvas/ViewportProvider.tsx | 48 +------ .../web/src/components/canvas/canvasHooks.ts | 11 ++ .../src/components/canvas/viewportHooks.ts | 13 -- .../src/components/project/ProjectCanvas.tsx | 119 +++++++++--------- 9 files changed, 150 insertions(+), 234 deletions(-) diff --git a/apps/star-chart/web/src/components/canvas/Canvas.ts b/apps/star-chart/web/src/components/canvas/Canvas.ts index cc85bb65..d2307a89 100644 --- a/apps/star-chart/web/src/components/canvas/Canvas.ts +++ b/apps/star-chart/web/src/components/canvas/Canvas.ts @@ -1,21 +1,17 @@ import { EventSubscriber } from '@a-type/utils'; import { SpringValue, to } from '@react-spring/web'; -import { snap } from './math.js'; +import { clampVector, snap } from './math.js'; import { ObjectBounds } from './ObjectBounds.js'; import { ObjectPositions } from './ObjectPositions.js'; import { Selections } from './Selections.js'; -import { Vector2 } from './types.js'; -import { Viewport } from './Viewport.js'; - -type ActiveGestureState = { - targetObjectId: string | null; - position: Vector2 | null; - startPosition: Vector2 | null; -}; +import { RectLimits, Vector2 } from './types.js'; +import { Viewport, ViewportConfig } from './Viewport.js'; export interface CanvasOptions { /** Snaps items to a world-unit grid after dropping them - defaults to 1. */ positionSnapIncrement?: number; + limits?: RectLimits; + viewportConfig?: Omit; } export interface CanvasGestureInfo { @@ -24,6 +20,11 @@ export interface CanvasGestureInfo { ctrlOrMeta: boolean; } +const DEFAULT_LIMITS: RectLimits = { + max: { x: 1_000_000, y: 1_000_000 }, + min: { x: -1_000_000, y: -1_000_000 }, +}; + export type CanvasEvents = { [k: `objectDrop:${string}`]: ( newPosition: Vector2, @@ -34,15 +35,13 @@ export type CanvasEvents = { canvasDragStart: (position: Vector2, info: CanvasGestureInfo) => void; canvasDrag: (position: Vector2, info: CanvasGestureInfo) => void; canvasDragEnd: (position: Vector2, info: CanvasGestureInfo) => void; + resize: (size: RectLimits) => void; }; -/** - * This class encapsulates the logic which powers the movement and - * sizing of objects within a Room Canvas - the 2d space that makes - * up the standard With Room. It implements the required functionality - * for both CanvasContext and SizingContext - */ export class Canvas extends EventSubscriber { + readonly viewport: Viewport; + readonly limits: RectLimits; + readonly bounds = new ObjectBounds(); readonly selections = new Selections(); @@ -51,11 +50,10 @@ export class Canvas extends EventSubscriber { private _positionSnapIncrement = 1; - constructor( - private viewport: Viewport, - options?: CanvasOptions, - ) { + constructor(options?: CanvasOptions) { super(); + this.viewport = new Viewport({ ...options?.viewportConfig, canvas: this }); + this.limits = options?.limits ?? DEFAULT_LIMITS; // @ts-ignore for debugging... window.canvas = this; this._positionSnapIncrement = options?.positionSnapIncrement ?? 1; @@ -65,11 +63,36 @@ export class Canvas extends EventSubscriber { return this._positionSnapIncrement; } + get boundary() { + return { + x: this.limits.min.x, + y: this.limits.min.y, + width: this.limits.max.x - this.limits.min.x, + height: this.limits.max.y - this.limits.min.y, + }; + } + + get center() { + return { + x: (this.limits.max.x + this.limits.min.x) / 2, + y: (this.limits.max.y + this.limits.min.y) / 2, + }; + } + snapPosition = (position: Vector2) => ({ x: snap(position.x, this._positionSnapIncrement), y: snap(position.y, this._positionSnapIncrement), }); + clampPosition = (position: Vector2) => + clampVector(position, this.limits.min, this.limits.max); + + resize = (size: RectLimits) => { + this.limits.min = size.min; + this.limits.max = size.max; + this.emit('resize', size); + }; + onCanvasTap = (screenPosition: Vector2, info: CanvasGestureInfo) => { const worldPosition = this.viewport.viewportToWorld(screenPosition); this.emit('canvasTap', worldPosition, info); diff --git a/apps/star-chart/web/src/components/canvas/CanvasProvider.tsx b/apps/star-chart/web/src/components/canvas/CanvasProvider.tsx index 8aded058..7f192b73 100644 --- a/apps/star-chart/web/src/components/canvas/CanvasProvider.tsx +++ b/apps/star-chart/web/src/components/canvas/CanvasProvider.tsx @@ -1,41 +1,16 @@ -import { - createContext, - ReactNode, - useContext, - useEffect, - useState, -} from 'react'; -import { Canvas, CanvasGestureInfo, CanvasOptions } from './Canvas.js'; -import { Viewport } from './Viewport.js'; -import { useViewport } from './ViewportProvider.jsx'; -import { Vector2 } from './types.js'; +import { createContext, ReactNode, useContext, useState } from 'react'; +import { Canvas, CanvasOptions } from './Canvas.js'; import { useCanvasGestures } from './canvasHooks.js'; // A 'default' implementation of CanvasContext which essentially does nothing, // might assist in easier isolated rendering of canvas-dependent components -const dummyCanvas = new Canvas(new Viewport({})); +const dummyCanvas = new Canvas({}); export const CanvasContext = createContext(dummyCanvas); -/** - * Abstractly, a CanvasProvider provides a way of handling changes to object positions, - * including the act of moving an object and that of releasing it to a final location. - */ -export const CanvasProvider = ({ - children, - options, -}: { - children: ReactNode; - options?: CanvasOptions; -}) => { - const viewport = useViewport(); - const [canvas] = useState(() => { - return new Canvas(viewport, options); - }); - return ( - {children} - ); -}; +export function useCreateCanvas(options?: CanvasOptions) { + return useState(() => new Canvas(options))[0]; +} export const useCanvas = () => useContext(CanvasContext); diff --git a/apps/star-chart/web/src/components/canvas/CanvasSvgLayer.tsx b/apps/star-chart/web/src/components/canvas/CanvasSvgLayer.tsx index 29724f3f..316fb6e5 100644 --- a/apps/star-chart/web/src/components/canvas/CanvasSvgLayer.tsx +++ b/apps/star-chart/web/src/components/canvas/CanvasSvgLayer.tsx @@ -1,7 +1,8 @@ import { ReactNode, useMemo } from 'react'; -import { useViewport } from './ViewportProvider.jsx'; import { clsx } from '@a-type/ui'; import { createPortal } from 'react-dom'; +import { useCanvasRect } from './canvasHooks.js'; +import { useCanvas } from './CanvasProvider.jsx'; export interface CanvasSvgLayerProps { children: ReactNode; @@ -14,17 +15,17 @@ export function CanvasSvgLayer({ className, id, }: CanvasSvgLayerProps) { - const viewport = useViewport(); - const canvasRect = viewport.canvasRect; + const canvas = useCanvas(); + const canvasRect = useCanvasRect(); const style = useMemo(() => { return { - width: viewport.canvasRect.width, - height: viewport.canvasRect.height, - left: viewport.canvasRect.x, - top: viewport.canvasRect.y, + width: canvas.boundary.width, + height: canvas.boundary.height, + left: canvas.boundary.x, + top: canvas.boundary.y, }; - }, [viewport]); + }, [canvas]); return ( = ({ backgroundImage: imageUrl ? `url(${imageUrl})` : undefined, width: canvasRect.width, height: canvasRect.height, - left: canvasRect.x, - top: canvasRect.y, + left: 0, + top: 0, + transform: `translate(-50%, -50%)`, }; }, [imageUrl, canvasRect]); @@ -33,7 +33,7 @@ export const CanvasWallpaper: React.FC = ({
diff --git a/apps/star-chart/web/src/components/canvas/Viewport.ts b/apps/star-chart/web/src/components/canvas/Viewport.ts index bf7b83be..b0bdfeba 100644 --- a/apps/star-chart/web/src/components/canvas/Viewport.ts +++ b/apps/star-chart/web/src/components/canvas/Viewport.ts @@ -7,6 +7,7 @@ import { multiplyVector, subtractVectors, } from './math.js'; +import { Canvas } from './Canvas.js'; // for some calculations we need to assume a real size for an infinite // canvas... we use this value for infinite extents. FIXME: can we @@ -18,8 +19,6 @@ export interface ViewportConfig { defaultZoom?: number; /** Supply a starting center position. Default 0,0 */ defaultCenter?: Vector2; - /** Restrict world positions to certain boundaries. Default unlimited. */ - canvasLimits?: RectLimits; /** Restrict pan movement to certain boundaries. Default is canvasLimits if those exist, * otherwise unbounded. */ @@ -41,12 +40,14 @@ export interface ViewportConfig { * can be set later using bindElement. Defaults to window. */ boundElement?: HTMLElement; + + canvas: Canvas; } // removes some optional annotations as they are filled by defaults. type InternalViewportConfig = Omit< ViewportConfig, - 'zoomLimits' | 'boundElement' | 'defaultZoom' + 'zoomLimits' | 'boundElement' | 'defaultZoom' | 'canvas' > & { defaultZoom: number; zoomLimits: { min: number; max: number }; @@ -88,6 +89,7 @@ export type ViewportEvents = { * when the camera properties change. */ export class Viewport extends EventSubscriber { + private canvas: Canvas; private _center: Vector2 = { x: 0, y: 0 }; private _zoom = 1; _config: InternalViewportConfig; @@ -107,8 +109,9 @@ export class Viewport extends EventSubscriber { this.handleBoundElementResize, ); - constructor({ boundElement, ...config }: ViewportConfig) { + constructor({ boundElement, canvas, ...config }: ViewportConfig) { super(); + this.canvas = canvas; if (config.defaultCenter) { this._center = config.defaultCenter; @@ -121,10 +124,10 @@ export class Viewport extends EventSubscriber { this._config = { defaultZoom: 1, zoomLimits: { min: 0.25, max: 2 }, - panLimits: config.canvasLimits + panLimits: canvas.limits ? { - max: multiplyVector(config.canvasLimits.max, 1.5), - min: multiplyVector(config.canvasLimits.min, 1.5), + max: multiplyVector(canvas.limits.max, 1.5), + min: multiplyVector(canvas.limits.min, 1.5), } : undefined, ...config, @@ -231,32 +234,6 @@ export class Viewport extends EventSubscriber { return this._boundElement; } - /** - * The rectangle bounds describing the total world canvas area. - * For infinite canvases this will be a large but finite space. - */ - get canvasRect() { - const canvasMin = this.config.canvasLimits?.min ?? { - x: -INFINITE_LOGICAL_SIZE / 2, - y: -INFINITE_LOGICAL_SIZE / 2, - }; - const canvasMax = this.config.canvasLimits?.max ?? { - x: INFINITE_LOGICAL_SIZE / 2, - y: INFINITE_LOGICAL_SIZE / 2, - }; - return { - x: canvasMin.x, - y: canvasMin.y, - width: canvasMax.x - canvasMin.x, - height: canvasMax.y - canvasMin.y, - }; - } - - get worldCenter() { - const rect = this.canvasRect; - return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; - } - /** Convenience getters for internal calculation */ private get halfViewportWidth() { @@ -314,25 +291,13 @@ export class Viewport extends EventSubscriber { this.center.y, }; - if (clamp && !!this.config.canvasLimits) { - return this.clampToWorld(transformedPoint); + if (clamp) { + return this.canvas.clampPosition(transformedPoint); } return transformedPoint; }; - /** - * Restricts a position to one inside the world canvas - */ - clampToWorld = (worldPosition: Vector2) => { - if (!this.config.canvasLimits) return worldPosition; - return clampVector( - worldPosition, - this.config.canvasLimits.min, - this.config.canvasLimits.max, - ); - }; - /** * Converts a world point to a viewport (screen, pixel) point. The point * will be relative to the viewport element. @@ -383,8 +348,8 @@ export class Viewport extends EventSubscriber { const worldViewportHalfHeight = this.halfViewportHeight / this.zoom; const worldViewportWidth = this._boundElementSize.width / this.zoom; const worldViewportHeight = this._boundElementSize.height / this.zoom; - const canvasRect = this.canvasRect; - const worldCenter = this.worldCenter; + const canvasRect = this.canvas.boundary; + const worldCenter = this.canvas.center; // there are different rules depending on if the viewport is visually larger // than the canvas, or vice versa. when the viewport is larger than the canvas @@ -528,9 +493,4 @@ export class Viewport extends EventSubscriber { this.emit('centerChanged', this.center, origin); this.emit('zoomChanged', this.zoom, origin); }; - - resizeCanvas = (size: RectLimits) => { - this.config.canvasLimits = size; - this.emit('canvasChanged', size); - }; } diff --git a/apps/star-chart/web/src/components/canvas/ViewportProvider.tsx b/apps/star-chart/web/src/components/canvas/ViewportProvider.tsx index 699204ef..12426490 100644 --- a/apps/star-chart/web/src/components/canvas/ViewportProvider.tsx +++ b/apps/star-chart/web/src/components/canvas/ViewportProvider.tsx @@ -1,18 +1,16 @@ -import { createContext, useCallback, useRef, useState } from 'react'; +import { clsx } from '@a-type/ui'; +import { createContext, useCallback, useRef } from 'react'; +import { useCanvas } from './CanvasProvider.jsx'; +import { Size } from './types.js'; import { Viewport } from './Viewport.js'; -import { useContext } from 'react'; import { useKeyboardControls, useViewportGestureControls, } from './viewportHooks.js'; -import { clsx } from '@a-type/ui'; -import { Size } from './types.js'; export function useViewport() { - const ctx = useContext(ViewportContext); - if (!ctx) - throw new Error('useViewport must be called inside a ViewportProvider'); - return ctx; + const canvas = useCanvas(); + return canvas.viewport; } export const ViewportContext = createContext(null); @@ -25,40 +23,6 @@ export interface ViewportProviderProps { canvasSize?: Size | null; } -export const ViewportProvider = ({ - children, - minZoom = 1 / 4, - maxZoom = 2, - defaultZoom = 1, - canvasSize = { width: 2000, height: 2000 }, -}: ViewportProviderProps) => { - const [viewport] = useState(() => { - const viewport = new Viewport({ - panLimitMode: 'viewport', - defaultZoom, - zoomLimits: { - min: minZoom, - max: maxZoom, - }, - canvasLimits: canvasSize - ? { - max: { x: canvasSize.width / 2, y: canvasSize.height / 2 }, - min: { x: -canvasSize.width / 2, y: -canvasSize.height / 2 }, - } - : undefined, - }); - // @ts-ignore for debugging! - window.viewport = viewport; - return viewport; - }); - - return ( - - {children} - - ); -}; - export const ViewportRoot = ({ children, className, diff --git a/apps/star-chart/web/src/components/canvas/canvasHooks.ts b/apps/star-chart/web/src/components/canvas/canvasHooks.ts index 4587d359..7a62f3be 100644 --- a/apps/star-chart/web/src/components/canvas/canvasHooks.ts +++ b/apps/star-chart/web/src/components/canvas/canvasHooks.ts @@ -174,3 +174,14 @@ export function useSelectedObjectIds() { return selectedIds; } + +export function useCanvasRect() { + const canvas = useCanvas(); + + const [rect, setRect] = useState(() => canvas.boundary); + useEffect(() => { + return canvas.subscribe('resize', () => setRect(canvas.boundary)); + }, [canvas]); + + return rect; +} diff --git a/apps/star-chart/web/src/components/canvas/viewportHooks.ts b/apps/star-chart/web/src/components/canvas/viewportHooks.ts index 0c54b133..4b12df7a 100644 --- a/apps/star-chart/web/src/components/canvas/viewportHooks.ts +++ b/apps/star-chart/web/src/components/canvas/viewportHooks.ts @@ -388,16 +388,3 @@ function isCanvasDrag({ function isTouch({ touches }: { touches: number; buttons: number }) { return touches > 0; } - -export function useCanvasRect() { - const viewport = useViewport(); - - const [rect, setRect] = useState(() => viewport.canvasRect); - useEffect(() => { - return viewport.subscribe('canvasChanged', () => - setRect(viewport.canvasRect), - ); - }, [viewport]); - - return rect; -} diff --git a/apps/star-chart/web/src/components/project/ProjectCanvas.tsx b/apps/star-chart/web/src/components/project/ProjectCanvas.tsx index a235c269..4df5e897 100644 --- a/apps/star-chart/web/src/components/project/ProjectCanvas.tsx +++ b/apps/star-chart/web/src/components/project/ProjectCanvas.tsx @@ -3,32 +3,26 @@ import { Project, Task } from '@star-chart.biscuits/verdant'; import { Suspense, useCallback, useEffect, useState } from 'react'; import { BoxSelect } from '../canvas/BoxSelect.jsx'; import { + CanvasContext, CanvasGestures, - CanvasProvider, - useCanvas, + useCreateCanvas, } from '../canvas/CanvasProvider.jsx'; import { CanvasRenderer } from '../canvas/CanvasRenderer.jsx'; import { CanvasSvgLayer } from '../canvas/CanvasSvgLayer.jsx'; import { CanvasWallpaper } from '../canvas/CanvasWallpaper.jsx'; import { snapVector } from '../canvas/math.js'; -import { Minimap } from '../canvas/Minimap.jsx'; import { Vector2 } from '../canvas/types.js'; -import { - useViewport, - ViewportProvider, - ViewportRoot, -} from '../canvas/ViewportProvider.jsx'; +import { useViewport, ViewportRoot } from '../canvas/ViewportProvider.jsx'; import { AnalysisContext } from './AnalysisContext.jsx'; import { ArrowMarkers } from './ArrowMarkers.jsx'; import { CameraControls } from './CameraControls.jsx'; import { ConnectionWire } from './ConnectionWire.jsx'; +import { HomeButton } from './HomeButton.jsx'; import { useProjectData } from './hooks.js'; -import { TaskNode } from './TaskNode.jsx'; -import { SelectionMenu } from './SelectionMenu.jsx'; -import { Reticule } from './Reticule.jsx'; import { ProjectTitle } from './ProjectTitle.jsx'; -import { HomeButton } from './HomeButton.jsx'; -import { renderMinimapItem } from './minimap.jsx'; +import { Reticule } from './Reticule.jsx'; +import { SelectionMenu } from './SelectionMenu.jsx'; +import { TaskNode } from './TaskNode.jsx'; export interface ProjectCanvasProps { project: Project; @@ -41,62 +35,63 @@ export function ProjectCanvas({ project }: ProjectCanvasProps) { const addTask = useAddTask(projectId); + const canvas = useCreateCanvas({ + viewportConfig: { + zoomLimits: { + max: 1.25, + min: 0.25, + }, + defaultCenter: { x: 0, y: 0 }, + defaultZoom: 1, + }, + positionSnapIncrement: 24, + }); + return ( - - + + - { + if (ctx.canvas.selections.selectedIds.size === 0) { + const task = await addTask(position); + ctx.canvas.selections.set([task.get('id')]); + } else { + ctx.canvas.selections.set([]); + } }} - > - { - if (ctx.canvas.selections.selectedIds.size === 0) { - const task = await addTask(position); - ctx.canvas.selections.set([task.get('id')]); - } else { - ctx.canvas.selections.set([]); - } - }} - /> - - + /> + + - - - - - - - - {connections.map((connection) => ( - - - - ))} - {tasks.map((task) => ( - - - - ))} - - - {/* + + + + + + + {connections.map((connection) => ( + + + + ))} + {tasks.map((task) => ( + + + + ))} + + + {/* */} - - - - - - + + + + + ); } From 95df4cbf4e64517b7fc9fc09d30d1a9b16c396a7 Mon Sep 17 00:00:00 2001 From: Grant Forrest Date: Wed, 5 Jun 2024 16:58:50 -0400 Subject: [PATCH 2/2] [sc] box select and move. major canvas gesture refactor. --- .../web/src/components/canvas/BoxRegion.tsx | 34 ++- .../web/src/components/canvas/BoxSelect.tsx | 4 +- .../web/src/components/canvas/Canvas.ts | 72 +++-- .../src/components/canvas/CanvasObject.tsx | 263 +++++------------- .../canvas/CanvasObjectDragHandle.tsx | 177 +++++++++++- .../src/components/canvas/CanvasRenderer.tsx | 2 +- .../web/src/components/canvas/Minimap.tsx | 4 +- ...{ViewportProvider.tsx => ViewportRoot.tsx} | 0 .../web/src/components/canvas/Wire.tsx | 2 +- .../web/src/components/canvas/canvasHooks.ts | 85 +++--- .../web/src/components/canvas/gestureUtils.ts | 23 ++ .../web/src/components/canvas/math.ts | 14 +- .../src/components/canvas/viewportHooks.ts | 81 ++---- .../src/components/project/CameraControls.tsx | 2 +- .../components/project/ConnectionSource.tsx | 2 +- .../src/components/project/ProjectCanvas.tsx | 12 +- .../src/components/project/SelectionMenu.tsx | 3 + .../web/src/components/project/TouchTools.tsx | 0 18 files changed, 434 insertions(+), 346 deletions(-) rename apps/star-chart/web/src/components/canvas/{ViewportProvider.tsx => ViewportRoot.tsx} (100%) create mode 100644 apps/star-chart/web/src/components/canvas/gestureUtils.ts delete mode 100644 apps/star-chart/web/src/components/project/TouchTools.tsx diff --git a/apps/star-chart/web/src/components/canvas/BoxRegion.tsx b/apps/star-chart/web/src/components/canvas/BoxRegion.tsx index 76a8312f..9cfc9d8a 100644 --- a/apps/star-chart/web/src/components/canvas/BoxRegion.tsx +++ b/apps/star-chart/web/src/components/canvas/BoxRegion.tsx @@ -3,14 +3,11 @@ import { useCanvasGestures } from './canvasHooks.js'; import { useRef, useState } from 'react'; import { Vector2 } from './types.js'; import { CanvasGestureInfo } from './Canvas.js'; +import { useCanvas } from './CanvasProvider.jsx'; export interface BoxRegionProps { onPending?: (objectIds: string[], info: CanvasGestureInfo) => void; - onEnd?: ( - objectIds: string[], - endPosition: Vector2, - info: CanvasGestureInfo, - ) => void; + onEnd?: (objectIds: string[], info: CanvasGestureInfo) => void; tolerance?: number; className?: string; } @@ -31,18 +28,25 @@ export function BoxRegion({ const previousPending = useRef([]); + const canvas = useCanvas(); + useCanvasGestures({ - onDragStart: (pos) => { + onDragStart: (info) => { previousPending.current = []; - originRef.current = pos; - spring.set({ x: pos.x, y: pos.y, width: 0, height: 0 }); + originRef.current = info.worldPosition; + spring.set({ + x: info.worldPosition.x, + y: info.worldPosition.y, + width: 0, + height: 0, + }); }, - onDrag: (pos, { canvas, info }) => { + onDrag: (info) => { const rect = { - x: Math.min(pos.x, originRef.current.x), - y: Math.min(pos.y, originRef.current.y), - width: Math.abs(pos.x - originRef.current.x), - height: Math.abs(pos.y - originRef.current.y), + x: Math.min(info.worldPosition.x, originRef.current.x), + y: Math.min(info.worldPosition.y, originRef.current.y), + width: Math.abs(info.worldPosition.x - originRef.current.x), + height: Math.abs(info.worldPosition.y - originRef.current.y), }; spring.set(rect); const objectIds = canvas.bounds.getIntersections(rect, tolerance); @@ -65,7 +69,7 @@ export function BoxRegion({ previousPending.current = objectIds; }, - onDragEnd: (pos, { canvas, info }) => { + onDragEnd: (info) => { const objectIds = canvas.bounds.getIntersections( { x: x.get(), @@ -77,7 +81,7 @@ export function BoxRegion({ ); onPending?.([], info); - onCommit?.(objectIds, pos, info); + onCommit?.(objectIds, info); spring.set({ x: 0, y: 0, width: 0, height: 0 }); originRef.current.x = 0; diff --git a/apps/star-chart/web/src/components/canvas/BoxSelect.tsx b/apps/star-chart/web/src/components/canvas/BoxSelect.tsx index a8ca8b03..3388a1f0 100644 --- a/apps/star-chart/web/src/components/canvas/BoxSelect.tsx +++ b/apps/star-chart/web/src/components/canvas/BoxSelect.tsx @@ -15,13 +15,13 @@ export function BoxSelect({ className, onCommit }: BoxSelectProps) { onPending={(objectIds, info) => { canvas.selections.setPending(objectIds); }} - onEnd={(objectIds, endPosition, info) => { + onEnd={(objectIds, info) => { if (info.shift) { canvas.selections.addAll(objectIds); } else { canvas.selections.set(objectIds); } - onCommit?.(objectIds, endPosition); + onCommit?.(objectIds, info.worldPosition); }} className={className} /> diff --git a/apps/star-chart/web/src/components/canvas/Canvas.ts b/apps/star-chart/web/src/components/canvas/Canvas.ts index d2307a89..cfa67a81 100644 --- a/apps/star-chart/web/src/components/canvas/Canvas.ts +++ b/apps/star-chart/web/src/components/canvas/Canvas.ts @@ -2,7 +2,6 @@ import { EventSubscriber } from '@a-type/utils'; import { SpringValue, to } from '@react-spring/web'; import { clampVector, snap } from './math.js'; import { ObjectBounds } from './ObjectBounds.js'; -import { ObjectPositions } from './ObjectPositions.js'; import { Selections } from './Selections.js'; import { RectLimits, Vector2 } from './types.js'; import { Viewport, ViewportConfig } from './Viewport.js'; @@ -18,6 +17,15 @@ export interface CanvasGestureInfo { shift: boolean; alt: boolean; ctrlOrMeta: boolean; + intentional: boolean; + delta: Vector2; + worldPosition: Vector2; + targetId?: string; +} + +export interface CanvasGestureInput + extends Omit { + screenPosition: Vector2; } const DEFAULT_LIMITS: RectLimits = { @@ -26,15 +34,13 @@ const DEFAULT_LIMITS: RectLimits = { }; export type CanvasEvents = { - [k: `objectDrop:${string}`]: ( - newPosition: Vector2, - info: { source: 'gesture' | 'external' }, - ) => void; - [k: `objectDrag:${string}`]: (newPosition: Vector2) => void; - canvasTap: (position: Vector2, info: CanvasGestureInfo) => void; - canvasDragStart: (position: Vector2, info: CanvasGestureInfo) => void; - canvasDrag: (position: Vector2, info: CanvasGestureInfo) => void; - canvasDragEnd: (position: Vector2, info: CanvasGestureInfo) => void; + objectDragStart: (info: CanvasGestureInfo) => void; + objectDrag: (info: CanvasGestureInfo) => void; + objectDragEnd: (info: CanvasGestureInfo) => void; + canvasTap: (info: CanvasGestureInfo) => void; + canvasDragStart: (info: CanvasGestureInfo) => void; + canvasDrag: (info: CanvasGestureInfo) => void; + canvasDragEnd: (info: CanvasGestureInfo) => void; resize: (size: RectLimits) => void; }; @@ -93,24 +99,46 @@ export class Canvas extends EventSubscriber { this.emit('resize', size); }; - onCanvasTap = (screenPosition: Vector2, info: CanvasGestureInfo) => { - const worldPosition = this.viewport.viewportToWorld(screenPosition); - this.emit('canvasTap', worldPosition, info); + private transformGesture = ( + { screenPosition, delta, ...rest }: CanvasGestureInput, + snap?: boolean, + ): CanvasGestureInfo => { + let pos = this.viewport.viewportToWorld(screenPosition); + if (snap) { + pos = this.snapPosition(pos); + } + return Object.assign(rest, { + worldPosition: pos, + delta: this.viewport.viewportDeltaToWorld(delta), + }); + }; + + onCanvasTap = (info: CanvasGestureInput) => { + this.emit('canvasTap', this.transformGesture(info)); + }; + + onCanvasDragStart = (info: CanvasGestureInput) => { + this.emit('canvasDragStart', this.transformGesture(info)); + }; + + onCanvasDrag = (info: CanvasGestureInput) => { + this.emit('canvasDrag', this.transformGesture(info)); + }; + + onCanvasDragEnd = (info: CanvasGestureInput) => { + this.emit('canvasDragEnd', this.transformGesture(info)); }; - onCanvasDragStart = (screenPosition: Vector2, info: CanvasGestureInfo) => { - const worldPosition = this.viewport.viewportToWorld(screenPosition); - this.emit('canvasDragStart', worldPosition, info); + onObjectDragStart = (info: CanvasGestureInput) => { + this.emit('objectDragStart', this.transformGesture(info)); }; - onCanvasDrag = (screenPosition: Vector2, info: CanvasGestureInfo) => { - const worldPosition = this.viewport.viewportToWorld(screenPosition); - this.emit('canvasDrag', worldPosition, info); + onObjectDrag = (info: CanvasGestureInput) => { + this.emit('objectDrag', this.transformGesture(info)); }; - onCanvasDragEnd = (screenPosition: Vector2, info: CanvasGestureInfo) => { - const worldPosition = this.viewport.viewportToWorld(screenPosition); - this.emit('canvasDragEnd', worldPosition, info); + onObjectDragEnd = (info: CanvasGestureInput) => { + this.emit('objectDragEnd', this.transformGesture(info)); }; /** diff --git a/apps/star-chart/web/src/components/canvas/CanvasObject.tsx b/apps/star-chart/web/src/components/canvas/CanvasObject.tsx index 058632e3..b4e6c446 100644 --- a/apps/star-chart/web/src/components/canvas/CanvasObject.tsx +++ b/apps/star-chart/web/src/components/canvas/CanvasObject.tsx @@ -9,11 +9,16 @@ import { useState, } from 'react'; import { useCanvas } from './CanvasProvider.jsx'; -import { useViewport } from './ViewportProvider.jsx'; +import { useViewport } from './ViewportRoot.jsx'; import { to, useSpring, animated } from '@react-spring/web'; import { Vector2 } from './types.js'; import { SPRINGS } from './constants.js'; -import { addVectors, roundVector, subtractVectors } from './math.js'; +import { + addVectors, + roundVector, + snapshotLiveVector, + subtractVectors, +} from './math.js'; import { AutoPan } from './AutoPan.js'; import { useGesture } from '@use-gesture/react'; import { @@ -25,7 +30,11 @@ import { import { clsx } from '@a-type/ui'; import { useRerasterize } from './rerasterizeSignal.js'; import { useEffectOnce, useMergedRef } from '@biscuits/client'; -import { useRegister } from './canvasHooks.js'; +import { + useIsSelected, + useObjectGestures, + useRegister, +} from './canvasHooks.js'; export interface CanvasObjectRootProps { children: ReactNode; @@ -70,8 +79,7 @@ export function CanvasObjectRoot({ } export interface CanvasObject { - bindDragHandle: (args?: { onTap?: () => void }) => any; - isGrabbing: boolean; + isDragging: boolean; rootProps: any; moveTo: (position: Vector2, interpolate?: boolean) => void; id: string; @@ -107,13 +115,13 @@ export function useCanvasObject({ }) { const canvas = useCanvas(); - const { pickupSpring, isGrabbing, bindDragHandle, dragSpring, dragStyle } = - useDrag({ - initialPosition, - objectId, - onDrag, - onDragEnd: onDrop, - }); + const [isDragging, setIsDragging] = useState(false); + const [positionStyle, positionSpring] = useSpring(() => initialPosition); + + const pickupSpring = useSpring({ + value: isDragging ? 1 : 0, + config: SPRINGS.WOBBLY, + }); /** * ONLY MOVES THE VISUAL NODE. @@ -122,20 +130,59 @@ export function useCanvasObject({ */ const moveTo = useCallback( (position: Vector2) => { - dragSpring.start({ + positionSpring.start({ x: position.x, y: position.y, }); }, - [objectId, dragSpring], + [objectId, positionSpring], ); // FIXME: find a better place to do this? useEffect( - () => canvas.bounds.registerOrigin(objectId, dragStyle), + () => canvas.bounds.registerOrigin(objectId, positionStyle), [canvas, objectId], ); + const { selected } = useIsSelected(objectId); + + useObjectGestures({ + onDragStart: (info) => { + if (!selected && info.targetId !== objectId) return; + positionSpring.set( + addVectors(snapshotLiveVector(positionStyle), info.delta), + ); + if (info.intentional) { + setIsDragging(true); + } + }, + onDrag: (info) => { + if (!selected && info.targetId !== objectId) return; + onDrag?.(info.worldPosition); + positionSpring.set( + addVectors(snapshotLiveVector(positionStyle), info.delta), + ); + if (info.intentional) { + setIsDragging(true); + } + }, + onDragEnd: async (info) => { + if (!selected && info.targetId !== objectId) return; + onDrop?.(info.worldPosition); + // animate to final position + positionSpring.start( + canvas.snapPosition( + addVectors(snapshotLiveVector(positionStyle), info.delta), + ), + ); + // 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 + // after releasing + setTimeout(setIsDragging, 100, false); + }, + }); + const canvasObject: CanvasObject = useMemo(() => { const rootProps = { style: { @@ -145,201 +192,23 @@ export function useCanvasObject({ * up or dropped. */ transform: to( - [dragStyle.x, dragStyle.y, pickupSpring.value], + [positionStyle.x, positionStyle.y, pickupSpring.value], (xv, yv, grabEffect) => `translate(${xv}px, ${yv}px) scale(${1 + 0.05 * grabEffect})`, ), zIndex, - cursor: isGrabbing ? 'grab' : 'inherit', + cursor: isDragging ? 'grab' : 'inherit', }, }; return { - bindDragHandle, - isGrabbing, + isDragging, rootProps, moveTo, id: objectId, metadata, }; - }, [canvas, pickupSpring, zIndex, isGrabbing, bindDragHandle, objectId]); + }, [canvas, pickupSpring, zIndex, isDragging, objectId]); return canvasObject; } - -function useDrag({ - initialPosition, - objectId, - onDragStart, - onDragEnd, - onDrag, -}: { - initialPosition: Vector2; - objectId: string; - onDragStart?: (pos: Vector2) => void; - onDragEnd?: (pos: Vector2) => void; - onDrag?: (pos: Vector2) => any; -}) { - const canvas = useCanvas(); - const viewport = useViewport(); - - const [isGrabbing, setIsGrabbing] = useState(false); - - const [dragStyle, dragSpring] = useSpring(() => initialPosition); - - const pickupSpring = useSpring({ - value: isGrabbing ? 1 : 0, - config: SPRINGS.WOBBLY, - }); - - // stores the displacement between the user's grab point and the position - // of the object, in screen pixels - const grabDisplacementRef = useRef({ x: 0, y: 0 }); - const displace = useCallback((screenPosition: Vector2) => { - return roundVector(addVectors(screenPosition, grabDisplacementRef.current)); - }, []); - - // create a private instance of AutoPan to control the automatic panning behavior - // that occurs as the user drags an item near the edge of the screen. - const autoPan = useMemo(() => new AutoPan(viewport), [viewport]); - // we subscribe to auto-pan events so we can update the position - // of the object as the viewport moves - useEffect(() => { - return autoPan.subscribe( - 'pan', - ({ cursorPosition }: { cursorPosition: Vector2 | null }) => { - if (!cursorPosition) return; - // 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 = viewport.viewportToWorld( - displace(cursorPosition), - ); - dragSpring.set(finalPosition); - }, - ); - }, [autoPan, viewport, canvas, objectId, displace]); - - // binds drag controls to the underlying element - const bindDragHandle = useGesture({ - onDrag: (state) => { - if ( - 'button' in state.event && - (isRightClick(state.event) || isMiddleClick(state.event)) - ) { - state.cancel(); - return; - } - - if (state.event?.target) { - const element = state.event?.target as HTMLElement; - // look up the element tree for a hidden or no-drag element to see if dragging is allowed - // here. - const dragPrevented = - element.getAttribute('aria-hidden') === 'true' || - element.getAttribute('data-no-drag') === 'true' || - !!element.closest('[data-no-drag="true"], [aria-hidden="true"]'); - // BUGFIX: a patch which is intended to prevent a bug where opening a menu - // or other popover from within a draggable allows dragging by clicking anywhere - // on the screen, since the whole screen is covered by a click-blocker element - // ignore drag events which target an aria-hidden element - if (dragPrevented) { - state.cancel(); - return; - } - } - - if (state.distance.length > 10) { - setIsGrabbing(true); - } - - 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); - - const position = viewport.viewportToWorld(displace(screenPosition)); - dragSpring.set(position); - onDrag?.(position); - }, - onDragStart: (state) => { - if ( - 'button' in state.event && - (isRightClick(state.event) || isMiddleClick(state.event)) - ) { - state.cancel(); - return; - } - - // begin auto-pan using cursor viewport position - const screenPosition = { x: state.xy[0], y: state.xy[1] }; - autoPan.start(screenPosition); - - // capture the initial displacement between the cursor and the - // object's center to add to each subsequent position - const currentObjectPosition = canvas.getViewportPosition(objectId); - if (currentObjectPosition) { - const displacement = subtractVectors( - currentObjectPosition, - screenPosition, - ); - grabDisplacementRef.current.x = displacement.x; - grabDisplacementRef.current.y = displacement.y; - } - // apply displacement and begin drag - // 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 ( - 'button' in state.event && - (isRightClick(state.event) || isMiddleClick(state.event)) - ) { - state.cancel(); - return; - } - - const screenPosition = { x: state.xy[0], y: state.xy[1] }; - // 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 - // after releasing - setTimeout(() => { - setIsGrabbing(false); - }, 100); - autoPan.stop(); - grabDisplacementRef.current = { x: 0, y: 0 }; - - // invoke tap handler if provided. not sure how to type this.. - if (state.tap) { - state.args?.[0]?.onTap?.(position); - } - - onDragEnd?.(position); - }, - }); - - return { - bindDragHandle, - pickupSpring, - isGrabbing, - moveTo, - dragSpring, - dragStyle, - }; -} diff --git a/apps/star-chart/web/src/components/canvas/CanvasObjectDragHandle.tsx b/apps/star-chart/web/src/components/canvas/CanvasObjectDragHandle.tsx index 891679c6..ccdf347e 100644 --- a/apps/star-chart/web/src/components/canvas/CanvasObjectDragHandle.tsx +++ b/apps/star-chart/web/src/components/canvas/CanvasObjectDragHandle.tsx @@ -1,7 +1,19 @@ -import { ReactNode, useCallback, useRef } from 'react'; +import { ReactNode, useCallback, useEffect, useMemo, useRef } from 'react'; import { useCanvasObjectContext } from './CanvasObject.jsx'; import { clsx } from '@a-type/ui'; -import { stopPropagation } from '@a-type/utils'; +import { isMiddleClick, isRightClick, stopPropagation } from '@a-type/utils'; +import { + addVectors, + roundVector, + subtractVectors, + vectorLength, +} from './math.js'; +import { useCanvas } from './CanvasProvider.jsx'; +import { Vector2 } from './types.js'; +import { AutoPan } from './AutoPan.js'; +import { useGesture } from '@use-gesture/react'; +import { CanvasGestureInfo, CanvasGestureInput } from './Canvas.js'; +import { applyGestureState } from './gestureUtils.js'; export interface CanvasObjectDragHandleProps { children: ReactNode; @@ -17,7 +29,8 @@ export function CanvasObjectDragHandle({ onTap: providedOnTap, ...rest }: CanvasObjectDragHandleProps) { - const { bindDragHandle, isGrabbing } = useCanvasObjectContext(); + const { isDragging: isGrabbing } = useCanvasObjectContext(); + const bindDragHandle = useDragHandle(); /** * This handler prevents click events from firing within the draggable handle @@ -73,3 +86,161 @@ export const disableDragProps = { onMouseMove: stopPropagation, onMouseUp: stopPropagation, }; + +function useDragHandle() { + const canvas = useCanvas(); + const viewport = canvas.viewport; + const canvasObject = useCanvasObjectContext(); + + // stores the displacement between the user's grab point and the position + // of the object, in screen pixels + const grabDisplacementRef = useRef({ x: 0, y: 0 }); + const displace = useCallback((screenPosition: Vector2) => { + return roundVector(addVectors(screenPosition, grabDisplacementRef.current)); + }, []); + + const gestureInputRef = useRef({ + alt: false, + shift: false, + ctrlOrMeta: false, + intentional: false, + screenPosition: { x: 0, y: 0 }, + delta: { x: 0, y: 0 }, + targetId: canvasObject.id, + }); + + // create a private instance of AutoPan to control the automatic panning behavior + // that occurs as the user drags an item near the edge of the screen. + const autoPan = useMemo(() => new AutoPan(viewport), [viewport]); + // we subscribe to auto-pan events so we can update the position + // of the object as the viewport moves + useEffect(() => { + return autoPan.subscribe( + 'pan', + ({ cursorPosition }: { cursorPosition: Vector2 | null }) => { + if (!cursorPosition) return; + gestureInputRef.current.screenPosition = displace(cursorPosition); + // 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. + canvas.onObjectDrag(gestureInputRef.current); + }, + ); + }, [autoPan, viewport, canvas, canvasObject, displace]); + + // binds drag controls to the underlying element + const bindDragHandle = useGesture({ + onDrag: (state) => { + if ( + 'button' in state.event && + (isRightClick(state.event) || isMiddleClick(state.event)) + ) { + state.cancel(); + return; + } + + if (state.event?.target) { + const element = state.event?.target as HTMLElement; + // look up the element tree for a hidden or no-drag element to see if dragging is allowed + // here. + const dragPrevented = + element.getAttribute('aria-hidden') === 'true' || + element.getAttribute('data-no-drag') === 'true' || + !!element.closest('[data-no-drag="true"], [aria-hidden="true"]'); + // BUGFIX: a patch which is intended to prevent a bug where opening a menu + // or other popover from within a draggable allows dragging by clicking anywhere + // on the screen, since the whole screen is covered by a click-blocker element + // ignore drag events which target an aria-hidden element + if (dragPrevented) { + state.cancel(); + return; + } + } + + // update gesture info + Object.assign(gestureInputRef.current, { + alt: state.event.altKey, + shift: state.event.shiftKey, + ctrlOrMeta: state.event.ctrlKey || state.event.metaKey, + distance: vectorLength(state.distance), + }); + + const screenPosition = { x: state.xy[0], y: state.xy[1] }; + autoPan.update(screenPosition); + + applyGestureState(gestureInputRef.current, state); + gestureInputRef.current.screenPosition = displace(screenPosition); + canvas.onObjectDrag(gestureInputRef.current); + }, + onDragStart: (state) => { + if ( + 'button' in state.event && + (isRightClick(state.event) || isMiddleClick(state.event)) + ) { + state.cancel(); + return; + } + + // update/ reset gesture info + Object.assign(gestureInputRef.current, { + alt: state.event.altKey, + shift: state.event.shiftKey, + ctrlOrMeta: state.event.ctrlKey || state.event.metaKey, + distance: vectorLength(state.distance), + }); + + // begin auto-pan using cursor viewport position + const screenPosition = { x: state.xy[0], y: state.xy[1] }; + autoPan.start(screenPosition); + + // capture the initial displacement between the cursor and the + // object's center to add to each subsequent position + const currentObjectPosition = canvas.getViewportPosition(canvasObject.id); + if (currentObjectPosition) { + const displacement = subtractVectors( + currentObjectPosition, + screenPosition, + ); + grabDisplacementRef.current.x = displacement.x; + grabDisplacementRef.current.y = displacement.y; + } + + applyGestureState(gestureInputRef.current, state); + gestureInputRef.current.screenPosition = displace(screenPosition); + // apply displacement and begin drag + canvas.onObjectDragStart(gestureInputRef.current); + }, + onDragEnd: (state) => { + if ( + 'button' in state.event && + (isRightClick(state.event) || isMiddleClick(state.event)) + ) { + state.cancel(); + return; + } + + // update gesture info + Object.assign(gestureInputRef.current, { + alt: state.event.altKey, + shift: state.event.shiftKey, + ctrlOrMeta: state.event.ctrlKey || state.event.metaKey, + distance: vectorLength(state.distance), + }); + + const screenPosition = { x: state.xy[0], y: state.xy[1] }; + + // invoke tap handler if provided. not sure how to type this.. + if (state.tap) { + state.args?.[0]?.onTap?.(); + } + + applyGestureState(gestureInputRef.current, state); + gestureInputRef.current.screenPosition = displace(screenPosition); + canvas.onObjectDragEnd(gestureInputRef.current); + autoPan.stop(); + grabDisplacementRef.current = { x: 0, y: 0 }; + }, + }); + + return bindDragHandle; +} diff --git a/apps/star-chart/web/src/components/canvas/CanvasRenderer.tsx b/apps/star-chart/web/src/components/canvas/CanvasRenderer.tsx index d80a42ee..f9ba5f2e 100644 --- a/apps/star-chart/web/src/components/canvas/CanvasRenderer.tsx +++ b/apps/star-chart/web/src/components/canvas/CanvasRenderer.tsx @@ -2,7 +2,7 @@ import { animated, to, useSpring } from '@react-spring/web'; import { SPRINGS } from './constants.js'; import { Vector2 } from './types.js'; import { rerasterizeSignal } from './rerasterizeSignal.js'; -import { useViewport } from './ViewportProvider.jsx'; +import { useViewport } from './ViewportRoot.jsx'; import { ViewportEventOrigin } from './Viewport.js'; import { ReactNode, useEffect, useState } from 'react'; import { useGesture } from '@use-gesture/react'; diff --git a/apps/star-chart/web/src/components/canvas/Minimap.tsx b/apps/star-chart/web/src/components/canvas/Minimap.tsx index 8634691a..59760c56 100644 --- a/apps/star-chart/web/src/components/canvas/Minimap.tsx +++ b/apps/star-chart/web/src/components/canvas/Minimap.tsx @@ -2,9 +2,9 @@ import { animated, useSpring } from '@react-spring/web'; import { useGesture } from '@use-gesture/react'; import { useEffect, JSX, Fragment } from 'react'; import { useBoundsObjectIds, useOrigin, useSize } from './canvasHooks.js'; -import { useViewport } from './ViewportProvider.jsx'; +import { useViewport } from './ViewportRoot.jsx'; import { useCanvas } from './CanvasProvider.jsx'; -import { useCanvasRect } from './viewportHooks.js'; +import { useCanvasRect } from './canvasHooks.js'; export interface MinimapProps { className?: string; diff --git a/apps/star-chart/web/src/components/canvas/ViewportProvider.tsx b/apps/star-chart/web/src/components/canvas/ViewportRoot.tsx similarity index 100% rename from apps/star-chart/web/src/components/canvas/ViewportProvider.tsx rename to apps/star-chart/web/src/components/canvas/ViewportRoot.tsx diff --git a/apps/star-chart/web/src/components/canvas/Wire.tsx b/apps/star-chart/web/src/components/canvas/Wire.tsx index 250f9b21..257dc7c0 100644 --- a/apps/star-chart/web/src/components/canvas/Wire.tsx +++ b/apps/star-chart/web/src/components/canvas/Wire.tsx @@ -3,7 +3,7 @@ import { SVGProps, useEffect, useState } from 'react'; import { LiveVector2, Vector2 } from './types.js'; import { useGesture } from '@use-gesture/react'; import { getWireBezierForEndPoints } from './math.js'; -import { useViewport } from './ViewportProvider.jsx'; +import { useViewport } from './ViewportRoot.jsx'; import { clsx } from '@a-type/ui'; import { useRegister } from './canvasHooks.js'; import { useCanvas } from './CanvasProvider.jsx'; diff --git a/apps/star-chart/web/src/components/canvas/canvasHooks.ts b/apps/star-chart/web/src/components/canvas/canvasHooks.ts index 7a62f3be..74de5baa 100644 --- a/apps/star-chart/web/src/components/canvas/canvasHooks.ts +++ b/apps/star-chart/web/src/components/canvas/canvasHooks.ts @@ -72,22 +72,10 @@ export function useBoundsObjectIds() { } export function useCanvasGestures(handlers: { - onDragStart?: ( - position: Vector2, - ctx: { canvas: Canvas; info: CanvasGestureInfo }, - ) => void; - onDrag?: ( - position: Vector2, - ctx: { canvas: Canvas; info: CanvasGestureInfo }, - ) => void; - onDragEnd?: ( - position: Vector2, - ctx: { canvas: Canvas; info: CanvasGestureInfo }, - ) => void; - onTap?: ( - position: Vector2, - ctx: { canvas: Canvas; info: CanvasGestureInfo }, - ) => void; + onDragStart?: (info: CanvasGestureInfo) => void; + onDrag?: (info: CanvasGestureInfo) => void; + onDragEnd?: (info: CanvasGestureInfo) => void; + onTap?: (info: CanvasGestureInfo) => void; }) { const canvas = useCanvas(); const handlersRef = useRef(handlers); @@ -95,29 +83,17 @@ export function useCanvasGestures(handlers: { useEffect(() => { const unsubs = [ - canvas.subscribe('canvasDragStart', (position, info) => { - handlersRef.current.onDragStart?.(position, { - canvas, - info, - }); + canvas.subscribe('canvasDragStart', (info) => { + handlersRef.current.onDragStart?.(info); }), - canvas.subscribe('canvasDrag', (position, info) => { - handlersRef.current.onDrag?.(position, { - canvas, - info, - }); + canvas.subscribe('canvasDrag', (info) => { + handlersRef.current.onDrag?.(info); }), - canvas.subscribe('canvasDragEnd', (position, info) => { - handlersRef.current.onDragEnd?.(position, { - canvas, - info, - }); + canvas.subscribe('canvasDragEnd', (info) => { + handlersRef.current.onDragEnd?.(info); }), - canvas.subscribe('canvasTap', (position, info) => { - handlersRef.current.onTap?.(position, { - canvas, - info, - }); + canvas.subscribe('canvasTap', (info) => { + handlersRef.current.onTap?.(info); }), ]; @@ -127,6 +103,43 @@ export function useCanvasGestures(handlers: { }, [canvas]); } +export function useObjectGestures( + handlers: { + onDragStart?: (info: CanvasGestureInfo) => void; + onDrag?: (info: CanvasGestureInfo) => void; + onDragEnd?: (info: CanvasGestureInfo) => void; + }, + objectId?: string, +) { + const canvas = useCanvas(); + const handlersRef = useRef(handlers); + handlersRef.current = handlers; + + useEffect(() => { + const unsubs = [ + canvas.subscribe('objectDragStart', (info) => { + if (!objectId || info.targetId === objectId) { + handlersRef.current.onDragStart?.(info); + } + }), + canvas.subscribe('objectDrag', (info) => { + if (!objectId || info.targetId === objectId) { + handlersRef.current.onDrag?.(info); + } + }), + canvas.subscribe('objectDragEnd', (info) => { + if (!objectId || info.targetId === objectId) { + handlersRef.current.onDragEnd?.(info); + } + }), + ]; + + return () => { + unsubs.forEach((fn) => fn()); + }; + }, [canvas, objectId]); +} + export function useIsSelected(objectId: string) { const canvas = useCanvas(); const [selected, setSelected] = useState(() => diff --git a/apps/star-chart/web/src/components/canvas/gestureUtils.ts b/apps/star-chart/web/src/components/canvas/gestureUtils.ts new file mode 100644 index 00000000..d94524c1 --- /dev/null +++ b/apps/star-chart/web/src/components/canvas/gestureUtils.ts @@ -0,0 +1,23 @@ +import { CommonGestureState, SharedGestureState } from '@use-gesture/react'; +import { CanvasGestureInput } from './Canvas.js'; + +type GestureState = CommonGestureState & + SharedGestureState & { xy: [number, number] }; + +export function gestureStateToInput(state: GestureState): CanvasGestureInput { + return { + screenPosition: { x: state.xy[0], y: state.xy[1] }, + alt: state.altKey, + shift: state.shiftKey, + ctrlOrMeta: state.ctrlKey || state.metaKey, + intentional: state.intentional, + delta: { x: state.delta[0], y: state.delta[1] }, + }; +} + +export function applyGestureState( + input: CanvasGestureInput, + state: GestureState, +) { + Object.assign(input, gestureStateToInput(state)); +} diff --git a/apps/star-chart/web/src/components/canvas/math.ts b/apps/star-chart/web/src/components/canvas/math.ts index c5713862..0ef7112e 100644 --- a/apps/star-chart/web/src/components/canvas/math.ts +++ b/apps/star-chart/web/src/components/canvas/math.ts @@ -69,8 +69,11 @@ export function vectorDistance(v1: Vector2, v2: Vector2) { ); } -export function vectorLength(v: Vector2) { - return vectorDistance(v, { x: 0, y: 0 }); +export function vectorLength(v: Vector2 | [number, number]) { + if (Array.isArray(v)) { + return Math.sqrt(v[0] ** 2 + v[1] ** 2); + } + return Math.sqrt(v.x ** 2 + v.y ** 2); } /** @@ -328,3 +331,10 @@ export function distanceToBezier(curve: Bezier, point: Vector2) { closestPoint, }; } + +export function snapshotLiveVector(vec: LiveVector2) { + return { + x: vec.x.get(), + y: vec.y.get(), + }; +} diff --git a/apps/star-chart/web/src/components/canvas/viewportHooks.ts b/apps/star-chart/web/src/components/canvas/viewportHooks.ts index 4b12df7a..2c74685c 100644 --- a/apps/star-chart/web/src/components/canvas/viewportHooks.ts +++ b/apps/star-chart/web/src/components/canvas/viewportHooks.ts @@ -6,12 +6,12 @@ import { useCallback, useEffect, useRef, - useState, } from 'react'; import { useCanvas } from './CanvasProvider.jsx'; import { Vector2 } from './types.js'; import { Viewport } from './Viewport.js'; -import { useViewport } from './ViewportProvider.jsx'; +import { vectorLength } from './math.js'; +import { gestureStateToInput } from './gestureUtils.js'; /** * Tracks cursor position and sends updates to the socket connection @@ -156,36 +156,22 @@ export function useViewportGestureControls( }); const bindPassiveGestures = useGesture( { - onDrag: ({ - delta: [x, y], - xy, - buttons, - intentional, - last, - shiftKey, - metaKey, - ctrlKey, - altKey, - touches, - type, - }) => { - if (!intentional || last) return; - - gestureDetails.current.touches = type === 'touchmove' ? touches : 0; - gestureDetails.current.buttons = buttons; + onDrag: (state) => { + if (state.last) return; + gestureDetails.current.touches = + state.type === 'touchmove' ? state.touches : 0; + gestureDetails.current.buttons = state.buttons; + + const input = gestureStateToInput(state); if (isCanvasDrag(gestureDetails.current)) { - canvas.onCanvasDrag( - { x: xy[0], y: xy[1] }, - { - shift: shiftKey, - alt: altKey, - ctrlOrMeta: ctrlKey || metaKey, - }, - ); + canvas.onCanvasDrag(input); } else { viewport.doRelativePan( - viewport.viewportDeltaToWorld({ x: -x, y: -y }), + viewport.viewportDeltaToWorld({ + x: -state.delta[0], + y: -state.delta[1], + }), { origin: 'direct', }, @@ -195,50 +181,31 @@ export function useViewportGestureControls( onPointerMoveCapture: ({ event }) => { onCursorMove({ x: event.clientX, y: event.clientY }); }, - onDragStart: ({ - xy, - buttons, - metaKey, - shiftKey, - ctrlKey, - altKey, - touches, - type, - }) => { - gestureDetails.current.touches = type === 'touchdown' ? touches : 0; - gestureDetails.current.buttons = buttons; + onDragStart: (state) => { + gestureDetails.current.touches = + state.type === 'touchdown' ? state.touches : 0; + gestureDetails.current.buttons = state.buttons; if (isCanvasDrag(gestureDetails.current)) { - canvas.onCanvasDragStart( - { x: xy[0], y: xy[1] }, - { - shift: shiftKey, - alt: altKey, - ctrlOrMeta: ctrlKey || metaKey, - }, - ); + canvas.onCanvasDragStart(gestureStateToInput(state)); return; } }, - onDragEnd: ({ xy, tap, metaKey, shiftKey, ctrlKey, altKey, type }) => { - const info = { - shift: shiftKey, - alt: altKey, - ctrlOrMeta: ctrlKey || metaKey, - }; + onDragEnd: (state) => { + const info = gestureStateToInput(state); // tap is triggered either by left click, or on touchscreens. // tap must fire before drag end. if ( - tap && + state.tap && (isCanvasDrag(gestureDetails.current) || isTouch(gestureDetails.current)) ) { - canvas.onCanvasTap({ x: xy[0], y: xy[1] }, info); + canvas.onCanvasTap(info); } if (isCanvasDrag(gestureDetails.current)) { - canvas.onCanvasDragEnd({ x: xy[0], y: xy[1] }, info); + canvas.onCanvasDragEnd(info); } gestureDetails.current.buttons = 0; diff --git a/apps/star-chart/web/src/components/project/CameraControls.tsx b/apps/star-chart/web/src/components/project/CameraControls.tsx index 443f0cb5..90c8e532 100644 --- a/apps/star-chart/web/src/components/project/CameraControls.tsx +++ b/apps/star-chart/web/src/components/project/CameraControls.tsx @@ -1,5 +1,5 @@ import { useSyncExternalStore } from 'react'; -import { useViewport } from '../canvas/ViewportProvider.jsx'; +import { useViewport } from '../canvas/ViewportRoot.jsx'; import { Slider } from '@a-type/ui/components/slider'; import { disableDragProps } from '../canvas/CanvasObjectDragHandle.jsx'; diff --git a/apps/star-chart/web/src/components/project/ConnectionSource.tsx b/apps/star-chart/web/src/components/project/ConnectionSource.tsx index 9a99aad0..84501a1f 100644 --- a/apps/star-chart/web/src/components/project/ConnectionSource.tsx +++ b/apps/star-chart/web/src/components/project/ConnectionSource.tsx @@ -6,7 +6,7 @@ import { ReactNode, useCallback, useMemo, useState } from 'react'; import { disableDragProps } from '../canvas/CanvasObjectDragHandle.jsx'; import { useCanvas } from '../canvas/CanvasProvider.jsx'; import { SvgPortal } from '../canvas/CanvasSvgLayer.jsx'; -import { useViewport } from '../canvas/ViewportProvider.jsx'; +import { useViewport } from '../canvas/ViewportRoot.jsx'; import { Wire } from '../canvas/Wire.jsx'; import { closestLivePoint } from '../canvas/math.js'; import { Task } from '@star-chart.biscuits/verdant'; diff --git a/apps/star-chart/web/src/components/project/ProjectCanvas.tsx b/apps/star-chart/web/src/components/project/ProjectCanvas.tsx index 4df5e897..841986da 100644 --- a/apps/star-chart/web/src/components/project/ProjectCanvas.tsx +++ b/apps/star-chart/web/src/components/project/ProjectCanvas.tsx @@ -12,7 +12,7 @@ import { CanvasSvgLayer } from '../canvas/CanvasSvgLayer.jsx'; import { CanvasWallpaper } from '../canvas/CanvasWallpaper.jsx'; import { snapVector } from '../canvas/math.js'; import { Vector2 } from '../canvas/types.js'; -import { useViewport, ViewportRoot } from '../canvas/ViewportProvider.jsx'; +import { useViewport, ViewportRoot } from '../canvas/ViewportRoot.jsx'; import { AnalysisContext } from './AnalysisContext.jsx'; import { ArrowMarkers } from './ArrowMarkers.jsx'; import { CameraControls } from './CameraControls.jsx'; @@ -52,12 +52,12 @@ export function ProjectCanvas({ project }: ProjectCanvasProps) { { - if (ctx.canvas.selections.selectedIds.size === 0) { - const task = await addTask(position); - ctx.canvas.selections.set([task.get('id')]); + onTap={async (info) => { + if (canvas.selections.selectedIds.size === 0) { + const task = await addTask(info.worldPosition); + canvas.selections.set([task.get('id')]); } else { - ctx.canvas.selections.set([]); + canvas.selections.set([]); } }} /> diff --git a/apps/star-chart/web/src/components/project/SelectionMenu.tsx b/apps/star-chart/web/src/components/project/SelectionMenu.tsx index 39f698f9..f90d2d2f 100644 --- a/apps/star-chart/web/src/components/project/SelectionMenu.tsx +++ b/apps/star-chart/web/src/components/project/SelectionMenu.tsx @@ -72,6 +72,7 @@ export function SelectionMenu({ className }: SelectionMenuProps) { onClick={() => deleteSelected('task')} color="ghostDestructive" size="small" + className="flex-1 justify-center" > Delete Tasks @@ -82,6 +83,7 @@ export function SelectionMenu({ className }: SelectionMenuProps) { onClick={() => deleteSelected('connection')} color="ghostDestructive" size="small" + className="flex-1 justify-center" > Delete Connections @@ -91,6 +93,7 @@ export function SelectionMenu({ className }: SelectionMenuProps) { onClick={() => deleteSelected()} color="ghostDestructive" size="small" + className="flex-1 justify-center" > Delete All diff --git a/apps/star-chart/web/src/components/project/TouchTools.tsx b/apps/star-chart/web/src/components/project/TouchTools.tsx deleted file mode 100644 index e69de29b..00000000