浏览代码

feat: animate canvas camera movement (#7583)

feat: animate canvas camera
Peng Xiao 2 年之前
父节点
当前提交
1893dde335

+ 1 - 0
tldraw/apps/tldraw-logseq/src/components/ZoomMenu/ZoomMenu.tsx

@@ -36,6 +36,7 @@ export const ZoomMenu = observer(function ZoomMenu(): JSX.Element {
           className="tl-menu-item"
           onSelect={preventEvent}
           onClick={app.api.zoomToSelection}
+          disabled={app.selectedShapesArray.length === 0}
         >
           Zoom to selection
           <div className="tl-menu-right-slot">

+ 4 - 1
tldraw/apps/tldraw-logseq/src/hooks/useQuickAdd.ts

@@ -4,6 +4,9 @@ import type { Shape } from '../lib'
 
 export function useQuickAdd() {
   return React.useCallback<TLReactCallbacks<Shape>['onCanvasDBClick']>(async app => {
-    app.selectTool('logseq-portal').selectedTool.transition('creating')
+    // Give a timeout so that the quick add input will not be blurred too soon
+    setTimeout(() => {
+      app.selectTool('logseq-portal').selectedTool.transition('creating')
+    }, 100)
   }, [])
 }

+ 6 - 0
tldraw/apps/tldraw-logseq/src/lib/shapes/LogseqPortalShape.tsx

@@ -390,6 +390,12 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
       }
     }, [isEditing, this.props.collapsed])
 
+    React.useEffect(() => {
+      if (isCreating) {
+        app.viewport.zoomToBounds({ ...this.bounds, minY: this.bounds.maxY + 25 })
+      }
+    }, [app.viewport.currentView.height])
+
     const onPageNameChanged = React.useCallback((id: string) => {
       this.initialHeightCalculated = false
       const blockType = validUUID(id) ? 'B' : 'P'

+ 0 - 3
tldraw/apps/tldraw-logseq/src/lib/tools/LogseqPortalTool/states/CreatingState.tsx

@@ -32,9 +32,6 @@ export class CreatingState extends TLToolState<
       this.app.currentPage.addShapes(shape)
       this.app.setEditingShape(shape)
       this.app.setSelectedShapes([shape])
-      if (this.app.viewport.camera.zoom < 0.8 || this.app.viewport.camera.zoom > 1.2) {
-        this.app.api.resetZoomToCursor()
-      }
     })
   }
 

+ 4 - 0
tldraw/apps/tldraw-logseq/src/styles.css

@@ -95,6 +95,10 @@ html[data-theme='light'] {
   .tl-menu-icon {
     @apply absolute left-4 text-base opacity-50;
   }
+
+  &[data-disabled] {
+    @apply opacity-50 pointer-events-none;
+  }
 }
 
 #tl-zoom {

+ 1 - 1
tldraw/packages/core/src/lib/TLApi/TLApi.ts

@@ -153,7 +153,7 @@ export class TLApi<S extends TLShape = TLShape, K extends TLEventMap = TLEventMa
 
   resetZoomToCursor = (): this => {
     const viewport = this.app.viewport
-    viewport.update({
+    viewport.animateCamera({
       zoom: 1,
       point: Vec.sub(this.app.inputs.originScreenPoint, this.app.inputs.originPoint),
     })

+ 78 - 23
tldraw/packages/core/src/lib/TLViewport.ts

@@ -3,6 +3,14 @@ import { action, computed, makeObservable, observable } from 'mobx'
 import { FIT_TO_SCREEN_PADDING, ZOOM_UPDATE_FACTOR } from '../constants'
 import type { TLBounds } from '../types'
 
+const ease = (x: number) => {
+  return -(Math.cos(Math.PI * x) - 1) / 2
+}
+
+const elapsedProgress = (t: number, duration = 100) => {
+  return ease(Vec.clamp(t / duration, 0, 1))
+}
+
 export class TLViewport {
   constructor() {
     makeObservable(this)
@@ -73,54 +81,99 @@ export class TLViewport {
     return Vec.mul(Vec.add(point, camera.point), camera.zoom)
   }
 
-  onZoom = (point: number[], zoom: number): this => {
-    return this.pinchZoom(point, [0, 0], zoom)
+  onZoom = (point: number[], zoom: number, animate = false): this => {
+    return this.pinchZoom(point, [0, 0], zoom, animate)
   }
 
   /**
    * Pinch to a new zoom level, possibly together with a pan.
    *
-   * @param point The current point under the cursor.
-   * @param delta The movement delta.
+   * @param point The current point under the cursor in the screen space. Zoom will be transformed
+   *   around this point.
+   * @param delta The movement delta in the screen space
    * @param zoom The new zoom level
    */
-  pinchZoom = (point: number[], delta: number[], zoom: number): this => {
+  pinchZoom = (point: number[], delta: number[], zoom: number, animate = false): this => {
     const { camera } = this
+
     const nextPoint = Vec.sub(camera.point, Vec.div(delta, camera.zoom))
     zoom = Vec.clamp(zoom, TLViewport.minZoom, TLViewport.maxZoom)
-    const p0 = Vec.sub(Vec.div(point, camera.zoom), nextPoint)
-    const p1 = Vec.sub(Vec.div(point, zoom), nextPoint)
-    return this.update({ point: Vec.toFixed(Vec.add(nextPoint, Vec.sub(p1, p0))), zoom })
+    const p0 = Vec.div(point, camera.zoom)
+    const p1 = Vec.div(point, zoom)
+
+    const newPoint = Vec.toFixed(Vec.add(nextPoint, Vec.sub(p1, p0)))
+
+    if (animate) {
+      this.animateCamera({ point: newPoint, zoom })
+    } else {
+      this.update({ point: newPoint, zoom })
+    }
+
+    return this
   }
 
-  setZoom = (zoom: number) => {
+  setZoom = (zoom: number, animate = false) => {
     const { bounds } = this
     const center = [bounds.width / 2, bounds.height / 2]
-    this.onZoom(center, zoom)
+    this.onZoom(center, zoom, animate)
   }
 
   zoomIn = () => {
     const { camera } = this
-    this.setZoom(camera.zoom / ZOOM_UPDATE_FACTOR)
+    this.setZoom(camera.zoom / ZOOM_UPDATE_FACTOR, true)
   }
 
   zoomOut = () => {
-    const { camera, bounds } = this
-    this.setZoom(camera.zoom * ZOOM_UPDATE_FACTOR)
+    const { camera } = this
+    this.setZoom(camera.zoom * ZOOM_UPDATE_FACTOR, true)
   }
 
   resetZoom = (): this => {
-    const {
-      bounds,
-      camera: { zoom, point },
-    } = this
-    const center = [bounds.width / 2, bounds.height / 2]
-    const p0 = Vec.sub(Vec.div(center, zoom), point)
-    const p1 = Vec.sub(Vec.div(center, 1), point)
-    return this.update({ point: Vec.toFixed(Vec.add(point, Vec.sub(p1, p0))), zoom: 1 })
+    this.setZoom(1, true)
+    return this
+  }
+
+  /**
+   * Animate the camera to the given position
+   */
+  animateCamera = ({ point, zoom }: { point: number[]; zoom: number }) => {
+    return this.animateToViewport({
+      minX: -point[0],
+      minY: -point[1],
+      maxX: this.bounds.width / zoom - point[0],
+      maxY: this.bounds.height / zoom - point[1],
+      width: this.bounds.width / zoom,
+      height: this.bounds.height / zoom,
+    })
   }
 
-  zoomToBounds = ({ width, height, minX, minY }: TLBounds): this => {
+  animateToViewport = (view: TLBounds) => {
+    const startTime = performance.now()
+    const oldView = { ...this.currentView }
+
+    const step = () => {
+      const elapsed = performance.now() - startTime
+      const progress = elapsedProgress(elapsed) // 0 ~ 1
+      const next = {
+        minX: oldView.minX + (view.minX - oldView.minX) * progress,
+        minY: oldView.minY + (view.minY - oldView.minY) * progress,
+        maxX: oldView.maxX + (view.maxX - oldView.maxX) * progress,
+        maxY: oldView.maxY + (view.maxY - oldView.maxY) * progress,
+      }
+
+      const point = [-next.minX, -next.minY]
+      const zoom = this.bounds.width / (next.maxX - next.minX)
+
+      this.update({ point, zoom })
+      if (progress < 1) {
+        requestAnimationFrame(step)
+      }
+    }
+
+    step()
+  }
+
+  zoomToBounds = ({ width, height, minX, minY }: TLBounds) => {
     const { bounds, camera } = this
     let zoom = Math.min(
       (bounds.width - FIT_TO_SCREEN_PADDING) / width,
@@ -137,6 +190,8 @@ export class TLViewport {
       (bounds.width - width * zoom) / 2 / zoom,
       (bounds.height - height * zoom) / 2 / zoom,
     ]
-    return this.update({ point: Vec.add([-minX, -minY], delta), zoom })
+
+    const point = Vec.add([-minX, -minY], delta)
+    this.animateCamera({ point, zoom })
   }
 }