From 0cfc68b004573ddf0f79bae448f114ea36235f86 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Mon, 18 Oct 2021 14:30:42 +0100 Subject: [PATCH] [feature] snapping (#168) * defer cloning * basic snapping * Improves algorithm for snap points, rendering * Improves snapping, snaplines * Enables a clone to snap to its parent * Adds overlay * Fix overlay, zoom scaling for distance and speed --- packages/core/package.json | 2 +- packages/core/scripts/dev.js | 4 +- packages/core/scripts/pre-dev.js | 4 +- .../core/src/components/bounds/bounds.tsx | 24 +- .../core/src/components/canvas/canvas.tsx | 9 +- packages/core/src/components/overlay/index.ts | 1 + .../core/src/components/overlay/overlay.tsx | 24 ++ .../core/src/components/renderer/renderer.tsx | 8 +- .../core/src/components/snap-lines/index.ts | 1 + .../src/components/snap-lines/snap-lines.tsx | 32 ++ packages/core/src/hooks/useShapeEvents.tsx | 2 + packages/core/src/hooks/useStyle.tsx | 19 + packages/core/src/types.ts | 8 + packages/core/src/utils/utils.ts | 195 ++++++++- packages/dev/package.json | 2 +- packages/intersect/package.json | 2 +- packages/tldraw/package.json | 2 +- packages/tldraw/scripts/dev.js | 4 +- packages/tldraw/scripts/pre-dev.js | 4 +- .../tldraw/src/components/tldraw/tldraw.tsx | 5 + packages/tldraw/src/shape/shape-styles.ts | 53 --- .../tldraw/src/shape/shapes/arrow/arrow.tsx | 6 +- .../src/shape/shapes/ellipse/ellipse.tsx | 4 +- .../src/shape/shapes/rectangle/rectangle.tsx | 11 +- packages/tldraw/src/state/constants.ts | 2 + .../session/sessions/arrow/arrow.session.ts | 2 +- .../session/sessions/draw/draw.session.ts | 2 +- .../session/sessions/grid/grid.session.ts | 2 +- .../session/sessions/handle/handle.session.ts | 2 +- .../session/sessions/rotate/rotate.session.ts | 8 +- .../transform-single.session.ts | 2 +- .../sessions/transform/transform.session.ts | 2 +- .../translate/translate.session.spec.ts | 10 + .../sessions/translate/translate.session.ts | 396 +++++++++++++----- packages/tldraw/src/state/tlstate.ts | 38 +- packages/tldraw/src/types.ts | 9 +- packages/vec/package.json | 2 +- yarn.lock | 118 +++++- 38 files changed, 799 insertions(+), 222 deletions(-) create mode 100644 packages/core/src/components/overlay/index.ts create mode 100644 packages/core/src/components/overlay/overlay.tsx create mode 100644 packages/core/src/components/snap-lines/index.ts create mode 100644 packages/core/src/components/snap-lines/snap-lines.tsx create mode 100644 packages/tldraw/src/state/constants.ts diff --git a/packages/core/package.json b/packages/core/package.json index fad3b75ce..5f85690f0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -41,7 +41,7 @@ "@types/react-dom": "^16.9.9", "@typescript-eslint/eslint-plugin": "^4.30.0", "@typescript-eslint/parser": "^4.30.0", - "esbuild": "^0.12.24", + "esbuild": "^0.13.8", "eslint": "^7.32.0", "lerna": "^4.0.0", "react": ">=16.8", diff --git a/packages/core/scripts/dev.js b/packages/core/scripts/dev.js index 81e55f8a7..ada638e00 100644 --- a/packages/core/scripts/dev.js +++ b/packages/core/scripts/dev.js @@ -6,10 +6,10 @@ const name = process.env.npm_package_name || '' async function main() { esbuild.build({ entryPoints: ['./src/index.ts'], - outdir: 'dist/cjs', + outdir: 'dist/esm', minify: false, bundle: true, - format: 'cjs', + format: 'esm', target: 'es6', jsxFactory: 'React.createElement', jsxFragment: 'React.Fragment', diff --git a/packages/core/scripts/pre-dev.js b/packages/core/scripts/pre-dev.js index 531792366..51a1f6b74 100644 --- a/packages/core/scripts/pre-dev.js +++ b/packages/core/scripts/pre-dev.js @@ -13,10 +13,10 @@ async function main() { esbuild.build({ entryPoints: ['./src/index.ts'], - outdir: 'dist/cjs', + outdir: 'dist/esm', minify: false, bundle: true, - format: 'cjs', + format: 'esm', target: 'es6', jsxFactory: 'React.createElement', jsxFragment: 'React.Fragment', diff --git a/packages/core/src/components/bounds/bounds.tsx b/packages/core/src/components/bounds/bounds.tsx index 03664ba3b..f37d3b7be 100644 --- a/packages/core/src/components/bounds/bounds.tsx +++ b/packages/core/src/components/bounds/bounds.tsx @@ -38,8 +38,10 @@ export const Bounds = React.memo( const smallDimension = Math.min(bounds.width, bounds.height) * zoom // If the bounds are small, don't show the rotate handle const showRotateHandle = !isHidden && !isLocked && smallDimension > 32 - // If the bounds are very small, don't show the corner handles - const showHandles = !isHidden && !isLocked && smallDimension > 16 + // If the bounds are very small, don't show the edge handles + const showEdgeHandles = !isHidden && !isLocked && smallDimension > 24 + // If the bounds are very very small, don't show the corner handles + const showCornerHandles = !isHidden && !isLocked && smallDimension > 20 return ( @@ -50,62 +52,62 @@ export const Bounds = React.memo( size={size} bounds={bounds} edge={TLBoundsEdge.Top} - isHidden={!showHandles} + isHidden={!showEdgeHandles} /> {showCloneButtons && } diff --git a/packages/core/src/components/canvas/canvas.tsx b/packages/core/src/components/canvas/canvas.tsx index 94dca9201..511cc6699 100644 --- a/packages/core/src/components/canvas/canvas.tsx +++ b/packages/core/src/components/canvas/canvas.tsx @@ -8,7 +8,7 @@ import { useCameraCss, useKeyEvents, } from '+hooks' -import type { TLBinding, TLPage, TLPageState, TLShape, TLUsers } from '+types' +import type { TLBinding, TLPage, TLPageState, TLShape, TLSnapLine, TLUsers } from '+types' import { ErrorFallback } from '+components/error-fallback' import { ErrorBoundary } from '+components/error-boundary' import { Brush } from '+components/brush' @@ -17,6 +17,8 @@ import { Users } from '+components/users' import { useResizeObserver } from '+hooks/useResizeObserver' import { inputs } from '+inputs' import { UsersIndicators } from '+components/users-indicators' +import { SnapLines } from '+components/snap-lines/snap-lines' +import { Overlay } from '+components/overlay' function resetError() { void null @@ -25,6 +27,7 @@ function resetError() { interface CanvasProps> { page: TLPage pageState: TLPageState + snapLines?: TLSnapLine[] users?: TLUsers userId?: string hideBounds?: boolean @@ -39,6 +42,7 @@ export function Canvas>({ id, page, pageState, + snapLines, users, userId, meta, @@ -87,6 +91,9 @@ export function Canvas>({ {users && } + + {snapLines && } + ) diff --git a/packages/core/src/components/overlay/index.ts b/packages/core/src/components/overlay/index.ts new file mode 100644 index 000000000..390ee7095 --- /dev/null +++ b/packages/core/src/components/overlay/index.ts @@ -0,0 +1 @@ +export * from './overlay' diff --git a/packages/core/src/components/overlay/overlay.tsx b/packages/core/src/components/overlay/overlay.tsx new file mode 100644 index 000000000..7b8ad1c4a --- /dev/null +++ b/packages/core/src/components/overlay/overlay.tsx @@ -0,0 +1,24 @@ +import * as React from 'react' + +export function Overlay({ + camera, + children, +}: { + camera: { point: number[]; zoom: number } + children: React.ReactNode +}) { + const l = 2.5 / camera.zoom + return ( + + + + + + + {children} + + ) +} diff --git a/packages/core/src/components/renderer/renderer.tsx b/packages/core/src/components/renderer/renderer.tsx index cf010fcce..5b61ba2c2 100644 --- a/packages/core/src/components/renderer/renderer.tsx +++ b/packages/core/src/components/renderer/renderer.tsx @@ -12,7 +12,7 @@ import type { import { Canvas } from '../canvas' import { Inputs } from '../../inputs' import { useTLTheme, TLContext, TLContextType } from '../../hooks' -import type { TLShapeUtil, TLUsers } from '+index' +import type { TLShapeUtil, TLSnapLine, TLUsers } from '+index' export interface RendererProps extends Partial> { @@ -40,6 +40,10 @@ export interface RendererProps + /** + * (optional) The current snap lines to render. + */ + snapLines?: TLSnapLine[] /** * (optional) The current user's id, used to identify the user. */ @@ -97,6 +101,7 @@ export function Renderer + {snapLines.map((snapLine, i) => ( + + ))} + + ) +} + +export function SnapLine({ snapLine }: { snapLine: TLSnapLine }) { + const bounds = Utils.getBoundsFromPoints(snapLine) + + return ( + <> + + {snapLine.map(([x, y], i) => ( + + ))} + + ) +} diff --git a/packages/core/src/hooks/useShapeEvents.tsx b/packages/core/src/hooks/useShapeEvents.tsx index 22697caf1..22229467e 100644 --- a/packages/core/src/hooks/useShapeEvents.tsx +++ b/packages/core/src/hooks/useShapeEvents.tsx @@ -69,6 +69,8 @@ export function useShapeEvents(id: string, disable = false) { if (!inputs.pointerIsValid(e)) return if (disable) return + e.stopPropagation() + if (inputs.pointer && e.pointerId !== inputs.pointer.pointerId) return const info = inputs.pointerMove(e, id) diff --git a/packages/core/src/hooks/useStyle.tsx b/packages/core/src/hooks/useStyle.tsx index ab4010506..1f12c06ca 100644 --- a/packages/core/src/hooks/useStyle.tsx +++ b/packages/core/src/hooks/useStyle.tsx @@ -66,6 +66,7 @@ const css = (strings: TemplateStringsArray, ...args: unknown[]) => ) const defaultTheme: TLTheme = { + accent: 'rgb(255, 0, 0)', brushFill: 'rgba(0,0,0,.05)', brushStroke: 'rgba(0,0,0,.25)', selectStroke: 'rgb(66, 133, 244)', @@ -133,6 +134,24 @@ const tlcss = css` box-sizing: border-box; } + .tl-overlay { + position: absolute; + width: 100%; + height: 100%; + touch-action: none; + pointer-events: none; + } + + .tl-snap-line { + stroke: var(--tl-accent); + stroke-width: calc(1px * var(--tl-scale)); + } + + .tl-snap-point { + stroke: var(--tl-accent); + stroke-width: calc(1px * var(--tl-scale)); + } + .tl-canvas { position: absolute; width: 100%; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f893da35d..d8cf4c7de 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -42,6 +42,8 @@ export interface TLUser { export type TLUsers = TLUser> = Record +export type TLSnapLine = number[][] + export interface TLHandle { id: string index: number @@ -112,6 +114,7 @@ export interface TLBinding { } export interface TLTheme { + accent?: string brushFill?: string brushStroke?: string selectFill?: string @@ -242,6 +245,11 @@ export interface TLBounds { rotation?: number } +export interface TLBoundsWithCenter extends TLBounds { + midX: number + midY: number +} + export type TLIntersection = { didIntersect: boolean message: string diff --git a/packages/core/src/utils/utils.ts b/packages/core/src/utils/utils.ts index 99c91e2bc..ba2224f35 100644 --- a/packages/core/src/utils/utils.ts +++ b/packages/core/src/utils/utils.ts @@ -6,7 +6,7 @@ import type React from 'react' import { TLBezierCurveSegment, TLBounds, TLBoundsCorner, TLBoundsEdge } from '../types' import { Vec } from '@tldraw/vec' import './polyfills' -import type { Patch } from '+index' +import type { Patch, TLBoundsWithCenter } from '+index' export class Utils { /* -------------------------------------------------- */ @@ -1519,6 +1519,146 @@ left past the initial left edge) then swap points on that axis. return [bounds.minX + bounds.width / 2, bounds.minY + bounds.height / 2] } + /** + * Get a bounding box with a midX and midY. + * @param bounds + */ + static getBoundsWithCenter(bounds: TLBounds): TLBounds & { midX: number; midY: number } { + return { + ...bounds, + midX: bounds.minX + bounds.width / 2, + midY: bounds.minY + bounds.height / 2, + } + } + + static getSnapPoints = ( + bounds: TLBoundsWithCenter, + others: TLBoundsWithCenter[], + distance: number, + isCareful: boolean + ) => { + const A = { ...bounds } + + const offset = [0, 0] + const snapLines: number[][][] = [] + + // 1. + // Find the snap points for the x and y axes + + let xs = null as { B: TLBoundsWithCenter; i: number } | null + let ys = null as { B: TLBoundsWithCenter; i: number } | null + + const fxs = [A.midX, A.minX, A.maxX] + const fys = [A.midY, A.minY, A.maxY] + + for (const B of others) { + if (!xs) { + const txs = [B.midX, B.minX, B.maxX] + + fxs.forEach((f, i) => + txs.forEach((t, k) => { + // If we're not dragging carefully, only snap to + // matching points, (e.g. min to min, mid to mid) + if (xs || !(isCareful || i === k)) return + + if (Math.abs(t - f) < distance) { + xs = { B, i } + + offset[0] = [ + // How far to offset the delta on the x axis in + // order to "snap" the selection to the right place + A.midX - t, + A.midX - (t + A.width / 2), + A.midX - (t - A.width / 2), + ][i] + + // Also apply the offset to the bounds + A.minX -= offset[0] + A.midX -= offset[0] + A.maxX -= offset[0] + } + }) + ) + } + + if (!ys) { + const tys = [B.midY, B.minY, B.maxY] + + fys.forEach((f, i) => + tys.forEach((t, k) => { + if (ys || !(isCareful || i === k)) return + + if (Math.abs(t - f) < distance) { + ys = { B, i } + + offset[1] = [ + // + A.midY - t, + A.midY - (t + A.height / 2), + A.midY - (t - A.height / 2), + ][i] + + A.minY -= offset[1] + A.midY -= offset[1] + A.maxY -= offset[1] + } + }) + ) + } + + if (xs && ys) break + } + + // 2. + // Calculate snap lines based on adjusted bounds A. This has + // to happen after we've adjusted both dimensions x and y of + // the bounds A! + + if (xs) { + const { i, B } = xs + const x = [A.midX, A.minX, A.maxX][i % 3] + + // If A is snapped at its center, show include only the midY; + // otherwise, include both its minY and maxY. + snapLines.push( + i === 0 + ? [ + [x, A.midY], + [x, B.minY], + [x, B.maxY], + ] + : [ + [x, A.minY], + [x, A.maxY], + [x, B.minY], + [x, B.maxY], + ] + ) + } + + if (ys) { + const { i, B } = ys + const y = [A.midY, A.minY, A.maxY][i % 3] + + snapLines.push( + i === 0 + ? [ + [A.midX, y], + [B.minX, y], + [B.maxX, y], + ] + : [ + [A.minX, y], + [A.maxX, y], + [B.minX, y], + [B.maxX, y], + ] + ) + } + + return { offset, snapLines } + } + /* -------------------------------------------------- */ /* Lists and Collections */ /* -------------------------------------------------- */ @@ -1684,6 +1824,59 @@ left past the initial left edge) then swap points on that axis. /* Browser and DOM */ /* -------------------------------------------------- */ + /** + * Get balanced dash-strokearray and dash-strokeoffset properties for a path of a given length. + * @param length The length of the path. + * @param strokeWidth The shape's stroke-width property. + * @param style The stroke's style: "dashed" or "dotted" (default "dashed"). + * @param snap An interval for dashes (e.g. 4 will produce arrays with 4, 8, 16, etc dashes). + */ + static getPerfectDashProps( + length: number, + strokeWidth: number, + style: 'dashed' | 'dotted' | string, + snap = 1, + outset = true + ): { + strokeDasharray: string + strokeDashoffset: string + } { + let dashLength: number + let strokeDashoffset: string + let ratio: number + + if (style.toLowerCase() === 'dashed') { + dashLength = strokeWidth * 2 + ratio = 1 + strokeDashoffset = outset ? (dashLength / 2).toString() : '0' + } else if (style.toLowerCase() === 'dotted') { + dashLength = strokeWidth / 100 + ratio = 100 + strokeDashoffset = '0' + } else { + return { + strokeDasharray: 'none', + strokeDashoffset: 'none', + } + } + + let dashes = Math.floor(length / dashLength / (2 * ratio)) + + dashes -= dashes % snap + + dashes = Math.max(dashes, 4) + + const gapLength = Math.max( + dashLength, + (length - dashes * dashLength) / (outset ? dashes : dashes - 1) + ) + + return { + strokeDasharray: [dashLength, gapLength].join(' '), + strokeDashoffset, + } + } + static isMobileSize() { if (typeof window === 'undefined') return false return window.innerWidth < 768 diff --git a/packages/dev/package.json b/packages/dev/package.json index 793bd482c..9c15220c5 100644 --- a/packages/dev/package.json +++ b/packages/dev/package.json @@ -35,7 +35,7 @@ "@types/react-router-dom": "^5.1.8", "concurrently": "6.0.1", "create-serve": "1.0.1", - "esbuild": "^0.12.26", + "esbuild": "^0.13.8", "rimraf": "3.0.2", "typescript": "4.2.3" }, diff --git a/packages/intersect/package.json b/packages/intersect/package.json index f62a46678..987671c46 100644 --- a/packages/intersect/package.json +++ b/packages/intersect/package.json @@ -39,7 +39,7 @@ "@types/node": "^16.7.10", "@typescript-eslint/eslint-plugin": "^4.30.0", "@typescript-eslint/parser": "^4.30.0", - "esbuild": "^0.12.24", + "esbuild": "^0.13.8", "eslint": "^7.32.0", "lerna": "^4.0.0", "ts-node": "^10.2.1", diff --git a/packages/tldraw/package.json b/packages/tldraw/package.json index 578b0014f..eb801ffaa 100644 --- a/packages/tldraw/package.json +++ b/packages/tldraw/package.json @@ -40,7 +40,7 @@ "@types/react-dom": "^16.9.9", "@typescript-eslint/eslint-plugin": "^4.30.0", "@typescript-eslint/parser": "^4.30.0", - "esbuild": "^0.12.24", + "esbuild": "^0.13.8", "eslint": "^7.32.0", "lerna": "^4.0.0", "ts-node": "^10.2.1", diff --git a/packages/tldraw/scripts/dev.js b/packages/tldraw/scripts/dev.js index eaf341726..78bc92595 100644 --- a/packages/tldraw/scripts/dev.js +++ b/packages/tldraw/scripts/dev.js @@ -6,10 +6,10 @@ const name = process.env.npm_package_name || '' async function main() { esbuild.build({ entryPoints: ['./src/index.ts'], - outdir: 'dist/cjs', + outdir: 'dist/esm', minify: false, bundle: true, - format: 'cjs', + format: 'esm', target: 'es6', jsxFactory: 'React.createElement', jsxFragment: 'React.Fragment', diff --git a/packages/tldraw/scripts/pre-dev.js b/packages/tldraw/scripts/pre-dev.js index 4f531b17d..759096073 100644 --- a/packages/tldraw/scripts/pre-dev.js +++ b/packages/tldraw/scripts/pre-dev.js @@ -13,10 +13,10 @@ async function main() { esbuild.build({ entryPoints: ['./src/index.ts'], - outdir: 'dist/cjs', + outdir: 'dist/esm', minify: false, bundle: true, - format: 'cjs', + format: 'esm', target: 'es6', jsxFactory: 'React.createElement', jsxFragment: 'React.Fragment', diff --git a/packages/tldraw/src/components/tldraw/tldraw.tsx b/packages/tldraw/src/components/tldraw/tldraw.tsx index cffc10ebf..ca33e40cc 100644 --- a/packages/tldraw/src/components/tldraw/tldraw.tsx +++ b/packages/tldraw/src/components/tldraw/tldraw.tsx @@ -29,6 +29,8 @@ const isHideBoundsShapeSelector = (s: Data) => { const pageSelector = (s: Data) => s.document.pages[s.appState.currentPageId] +const snapLinesSelector = (s: Data) => s.appState.snapLines + const usersSelector = (s: Data) => s.room?.users const pageStateSelector = (s: Data) => s.document.pageStates[s.appState.currentPageId] @@ -149,6 +151,8 @@ function InnerTldraw({ const pageState = useSelector(pageStateSelector) + const snapLines = useSelector(snapLinesSelector) + const users = useSelector(usersSelector) const isDarkMode = useSelector(isDarkModeSelector) @@ -217,6 +221,7 @@ function InnerTldraw({ containerRef={rWrapper} page={page} pageState={pageState} + snapLines={snapLines} users={users} userId={tlstate.state.room?.userId} shapeUtils={tldrawShapeUtils} diff --git a/packages/tldraw/src/shape/shape-styles.ts b/packages/tldraw/src/shape/shape-styles.ts index fd693cab9..a3655aa23 100644 --- a/packages/tldraw/src/shape/shape-styles.ts +++ b/packages/tldraw/src/shape/shape-styles.ts @@ -150,56 +150,3 @@ export const defaultStyle: ShapeStyles = { isFilled: false, dash: DashStyle.Draw, } - -/** - * Get balanced dash-strokearray and dash-strokeoffset properties for a path of a given length. - * @param length The length of the path. - * @param strokeWidth The shape's stroke-width property. - * @param style The stroke's style: "dashed" or "dotted" (default "dashed"). - * @param snap An interval for dashes (e.g. 4 will produce arrays with 4, 8, 16, etc dashes). - */ -export function getPerfectDashProps( - length: number, - strokeWidth: number, - style: DashStyle, - snap = 1, - outset = true -): { - strokeDasharray: string - strokeDashoffset: string -} { - let dashLength: number - let strokeDashoffset: string - let ratio: number - - if (style === DashStyle.Solid || style === DashStyle.Draw) { - return { - strokeDasharray: 'none', - strokeDashoffset: 'none', - } - } else if (style === DashStyle.Dashed) { - dashLength = strokeWidth * 2 - ratio = 1 - strokeDashoffset = outset ? (dashLength / 2).toString() : '0' - } else { - dashLength = strokeWidth / 100 - ratio = 100 - strokeDashoffset = '0' - } - - let dashes = Math.floor(length / dashLength / (2 * ratio)) - - dashes -= dashes % snap - - dashes = Math.max(dashes, 4) - - const gapLength = Math.max( - dashLength, - (length - dashes * dashLength) / (outset ? dashes : dashes - 1) - ) - - return { - strokeDasharray: [dashLength, gapLength].join(' '), - strokeDashoffset, - } -} diff --git a/packages/tldraw/src/shape/shapes/arrow/arrow.tsx b/packages/tldraw/src/shape/shapes/arrow/arrow.tsx index 3fb2502ad..9b81588b6 100644 --- a/packages/tldraw/src/shape/shapes/arrow/arrow.tsx +++ b/packages/tldraw/src/shape/shapes/arrow/arrow.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { ShapeUtil, SVGContainer, TLBounds, Utils, TLHandle } from '@tldraw/core' import { Vec } from '@tldraw/vec' import getStroke from 'perfect-freehand' -import { defaultStyle, getPerfectDashProps, getShapeStyle } from '~shape/shape-styles' +import { defaultStyle, getShapeStyle } from '~shape/shape-styles' import { ArrowShape, Decoration, @@ -102,7 +102,7 @@ export const Arrow = new ShapeUtil(() => ? renderFreehandArrowShaft(shape) : 'M' + Vec.round(start.point) + 'L' + Vec.round(end.point) - const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( + const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps( arrowDist, strokeWidth * 1.618, shape.style.dash, @@ -154,7 +154,7 @@ export const Arrow = new ShapeUtil(() => ? renderCurvedFreehandArrowShaft(shape, circle, length, easing) : getArrowArcPath(start, end, circle, shape.bend) - const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( + const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps( Math.abs(length), sw, shape.style.dash, diff --git a/packages/tldraw/src/shape/shapes/ellipse/ellipse.tsx b/packages/tldraw/src/shape/shapes/ellipse/ellipse.tsx index d028b13c6..3cf18e368 100644 --- a/packages/tldraw/src/shape/shapes/ellipse/ellipse.tsx +++ b/packages/tldraw/src/shape/shapes/ellipse/ellipse.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { SVGContainer, Utils, ShapeUtil, TLTransformInfo, TLBounds } from '@tldraw/core' import { Vec } from '@tldraw/vec' import { DashStyle, EllipseShape, TLDrawShapeType, TLDrawMeta } from '~types' -import { defaultStyle, getPerfectDashProps, getShapeStyle } from '~shape/shape-styles' +import { defaultStyle, getShapeStyle } from '~shape/shape-styles' import { getStrokeOutlinePoints, getStrokePoints } from 'perfect-freehand' import { intersectBoundsEllipse, @@ -82,7 +82,7 @@ export const Ellipse = new ShapeUtil(() const perimeter = Math.PI * (rx + ry) * (1 + (3 * h) / (10 + Math.sqrt(4 - 3 * h))) - const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( + const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps( perimeter < 64 ? perimeter * 2 : perimeter, strokeWidth * 1.618, shape.style.dash, diff --git a/packages/tldraw/src/shape/shapes/rectangle/rectangle.tsx b/packages/tldraw/src/shape/shapes/rectangle/rectangle.tsx index 1484d1e31..11bd52958 100644 --- a/packages/tldraw/src/shape/shapes/rectangle/rectangle.tsx +++ b/packages/tldraw/src/shape/shapes/rectangle/rectangle.tsx @@ -1,13 +1,10 @@ import * as React from 'react' import { Utils, SVGContainer, ShapeUtil } from '@tldraw/core' import { Vec } from '@tldraw/vec' -import getStroke, { getStrokeOutlinePoints, getStrokePoints } from 'perfect-freehand' -import { getPerfectDashProps, defaultStyle, getShapeStyle } from '~shape/shape-styles' +import getStroke, { getStrokePoints } from 'perfect-freehand' +import { defaultStyle, getShapeStyle } from '~shape/shape-styles' import { RectangleShape, DashStyle, TLDrawShapeType, TLDrawMeta } from '~types' import { getBoundsRectangle, transformRectangle, transformSingleRectangle } from '../shared' -import { EASINGS } from '~state/utils' - -const pathCache = new WeakMap([]) export const Rectangle = new ShapeUtil(() => ({ type: TLDrawShapeType.Rectangle, @@ -36,7 +33,7 @@ export const Rectangle = new ShapeUtil getRectanglePath(shape)) + const pathData = getRectanglePath(shape) return ( @@ -80,7 +77,7 @@ export const Rectangle = new ShapeUtil { - const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( + const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps( length, strokeWidth * 1.618, shape.style.dash diff --git a/packages/tldraw/src/state/constants.ts b/packages/tldraw/src/state/constants.ts new file mode 100644 index 000000000..37a87dea8 --- /dev/null +++ b/packages/tldraw/src/state/constants.ts @@ -0,0 +1,2 @@ +export const FIT_TO_SCREEN_PADDING = 128 +export const SNAP_DISTANCE = 5 diff --git a/packages/tldraw/src/state/session/sessions/arrow/arrow.session.ts b/packages/tldraw/src/state/session/sessions/arrow/arrow.session.ts index 29907ad98..58052d412 100644 --- a/packages/tldraw/src/state/session/sessions/arrow/arrow.session.ts +++ b/packages/tldraw/src/state/session/sessions/arrow/arrow.session.ts @@ -74,7 +74,7 @@ export class ArrowSession implements Session { start = () => void null - update = (data: Data, point: number[], shiftKey: boolean, altKey: boolean, metaKey: boolean) => { + update = (data: Data, point: number[], shiftKey = false, altKey = false, metaKey = false) => { const { initialShape } = this const page = TLDR.getPage(data, data.appState.currentPageId) diff --git a/packages/tldraw/src/state/session/sessions/draw/draw.session.ts b/packages/tldraw/src/state/session/sessions/draw/draw.session.ts index fd0502a49..fe5a001bf 100644 --- a/packages/tldraw/src/state/session/sessions/draw/draw.session.ts +++ b/packages/tldraw/src/state/session/sessions/draw/draw.session.ts @@ -31,7 +31,7 @@ export class DrawSession implements Session { start = () => void null - update = (data: Data, point: number[], shiftKey: boolean) => { + update = (data: Data, point: number[], shiftKey = false, altKey = false, metaKey = false) => { const { shapeId } = this // Even if we're not locked yet, we base the future locking direction diff --git a/packages/tldraw/src/state/session/sessions/grid/grid.session.ts b/packages/tldraw/src/state/session/sessions/grid/grid.session.ts index 5d02a4708..03871ac4a 100644 --- a/packages/tldraw/src/state/session/sessions/grid/grid.session.ts +++ b/packages/tldraw/src/state/session/sessions/grid/grid.session.ts @@ -60,7 +60,7 @@ export class GridSession implements Session { return clone } - update = (data: Data, point: number[], shiftKey: boolean, altKey: boolean, metaKey: boolean) => { + update = (data: Data, point: number[], shiftKey = false, altKey = false, metaKey = false) => { const nextShapes: Patch> = {} const nextPageState: Patch = {} diff --git a/packages/tldraw/src/state/session/sessions/handle/handle.session.ts b/packages/tldraw/src/state/session/sessions/handle/handle.session.ts index 8e087610a..b2f6f239a 100644 --- a/packages/tldraw/src/state/session/sessions/handle/handle.session.ts +++ b/packages/tldraw/src/state/session/sessions/handle/handle.session.ts @@ -27,7 +27,7 @@ export class HandleSession implements Session { start = () => void null - update = (data: Data, point: number[], shiftKey: boolean, altKey: boolean, metaKey: boolean) => { + update = (data: Data, point: number[], shiftKey = false, altKey = false, metaKey = false) => { const { initialShape } = this const { currentPageId } = data.appState diff --git a/packages/tldraw/src/state/session/sessions/rotate/rotate.session.ts b/packages/tldraw/src/state/session/sessions/rotate/rotate.session.ts index 2abbc3af0..d25becee1 100644 --- a/packages/tldraw/src/state/session/sessions/rotate/rotate.session.ts +++ b/packages/tldraw/src/state/session/sessions/rotate/rotate.session.ts @@ -23,7 +23,7 @@ export class RotateSession implements Session { start = () => void null - update = (data: Data, point: number[], isLocked = false) => { + update = (data: Data, point: number[], shiftKey = false, altKey = false, metaKey = false) => { const { commonBoundsCenter, initialShapes } = this.snapshot const pageId = data.appState.currentPageId @@ -32,7 +32,7 @@ export class RotateSession implements Session { let directionDelta = Vec.angle(commonBoundsCenter, point) - this.initialAngle - if (isLocked) { + if (shiftKey) { directionDelta = Utils.snapAngleToSegments(directionDelta, 24) // 15 degrees } @@ -41,7 +41,7 @@ export class RotateSession implements Session { const { rotation = 0 } = shape let shapeDelta = 0 - if (isLocked) { + if (shiftKey) { const snappedRotation = Utils.snapAngleToSegments(rotation, 24) shapeDelta = snappedRotation - rotation } @@ -50,7 +50,7 @@ export class RotateSession implements Session { shape, center, commonBoundsCenter, - isLocked ? directionDelta + shapeDelta : directionDelta + shiftKey ? directionDelta + shapeDelta : directionDelta ) if (change) { diff --git a/packages/tldraw/src/state/session/sessions/transform-single/transform-single.session.ts b/packages/tldraw/src/state/session/sessions/transform-single/transform-single.session.ts index 5851b63a9..623780b16 100644 --- a/packages/tldraw/src/state/session/sessions/transform-single/transform-single.session.ts +++ b/packages/tldraw/src/state/session/sessions/transform-single/transform-single.session.ts @@ -29,7 +29,7 @@ export class TransformSingleSession implements Session { start = () => void null - update = (data: Data, point: number[], shiftKey: boolean) => { + update = (data: Data, point: number[], shiftKey = false, altKey = false, metaKey = false) => { const { transformType } = this const { initialShapeBounds, initialShape, id } = this.snapshot diff --git a/packages/tldraw/src/state/session/sessions/transform/transform.session.ts b/packages/tldraw/src/state/session/sessions/transform/transform.session.ts index 42e26db61..2095212d0 100644 --- a/packages/tldraw/src/state/session/sessions/transform/transform.session.ts +++ b/packages/tldraw/src/state/session/sessions/transform/transform.session.ts @@ -32,7 +32,7 @@ export class TransformSession implements Session { start = () => void null // eslint-disable-next-line @typescript-eslint/no-unused-vars - update = (data: Data, point: number[], shiftKey: boolean, altKey: boolean, metaKey: boolean) => { + update = (data: Data, point: number[], shiftKey = false, altKey = false, metaKey = false) => { const { transformType, snapshot: { shapeBounds, initialBounds, isAllAspectRatioLocked }, diff --git a/packages/tldraw/src/state/session/sessions/translate/translate.session.spec.ts b/packages/tldraw/src/state/session/sessions/translate/translate.session.spec.ts index 008b18bea..4be25c7fa 100644 --- a/packages/tldraw/src/state/session/sessions/translate/translate.session.spec.ts +++ b/packages/tldraw/src/state/session/sessions/translate/translate.session.spec.ts @@ -326,3 +326,13 @@ describe('When creating with a translate session', () => { expect(tlstate.getShape('rect1')).toBe(undefined) }) }) + +describe('When snapping', () => { + it.todo('Does not snap when moving quicky') + it.todo('Snaps only matching edges when moving slowly') + it.todo('Snaps any edge to any edge when moving very slowly') + it.todo('Snaps a clone to its parent') + it.todo('Cleans up snap lines when cancelled') + it.todo('Cleans up snap lines when completed') + it.todo('Cleans up snap lines when starting to clone / not clone') +}) diff --git a/packages/tldraw/src/state/session/sessions/translate/translate.session.ts b/packages/tldraw/src/state/session/sessions/translate/translate.session.ts index f2d619af0..0e983b836 100644 --- a/packages/tldraw/src/state/session/sessions/translate/translate.session.ts +++ b/packages/tldraw/src/state/session/sessions/translate/translate.session.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { TLPageState, Utils } from '@tldraw/core' +import { TLPageState, Utils, TLBoundsWithCenter, TLSnapLine } from '@tldraw/core' import { Vec } from '@tldraw/vec' import { TLDrawShape, @@ -11,19 +11,50 @@ import { ArrowShape, GroupShape, SessionType, + ArrowBinding, } from '~types' +import { SNAP_DISTANCE } from '~state/constants' import { TLDR } from '~state/tldr' import type { Patch } from 'rko' +type CloneInfo = + | { + state: 'empty' + } + | { + state: 'ready' + clones: TLDrawShape[] + clonedBindings: ArrowBinding[] + } + +type SnapInfo = + | { + state: 'empty' + } + | { + state: 'ready' + others: TLBoundsWithCenter[] + bounds: TLBoundsWithCenter[] + } + export class TranslateSession implements Session { type = SessionType.Translate status = TLDrawStatus.Translating delta = [0, 0] prev = [0, 0] + prevPoint = [0, 0] + speed = 1 origin: number[] snapshot: TranslateSnapshot isCloning = false isCreate: boolean + cloneInfo: CloneInfo = { + state: 'empty', + } + snapInfo: SnapInfo = { + state: 'empty', + } + snapLines: TLSnapLine[] = [] constructor(data: Data, point: number[], isCreate = false) { this.origin = point @@ -34,6 +65,8 @@ export class TranslateSession implements Session { start = (data: Data) => { const { bindingsToDelete } = this.snapshot + this.createSnapInfo(data) + if (bindingsToDelete.length === 0) return data const nextBindings: Patch> = {} @@ -51,16 +84,30 @@ export class TranslateSession implements Session { } } - update = (data: Data, point: number[], shiftKey: boolean, altKey: boolean) => { - const { selectedIds, initialParentChildren, clones, initialShapes, bindingsToDelete } = - this.snapshot + update = (data: Data, point: number[], shiftKey = false, altKey = false, metaKey = false) => { + const { selectedIds, initialParentChildren, initialShapes, bindingsToDelete } = this.snapshot const { currentPageId } = data.appState + const nextBindings: Patch> = {} + const nextShapes: Patch> = {} + const nextPageState: Patch = {} - const delta = Vec.sub(point, this.origin) + let delta = Vec.sub(point, this.origin) + + let didChangeCloning = false + + if (!this.isCreate) { + if (altKey && !this.isCloning) { + this.isCloning = true + didChangeCloning = true + } else if (!altKey && this.isCloning) { + this.isCloning = false + didChangeCloning = true + } + } if (shiftKey) { if (Math.abs(delta[0]) < Math.abs(delta[1])) { @@ -70,15 +117,71 @@ export class TranslateSession implements Session { } } - const trueDelta = Vec.sub(delta, this.prev) + // Should we snap? + + // Speed is used to decide which snap points to use. At a high + // speed, we don't use any snap points. At a low speed, we only + // allow center-to-center snap points. At very low speed, we + // enable all snap points (still preferring middle snaps). We're + // using an acceleration function here to smooth the changes in + // speed, but we also want the speed to accelerate faster than + // it decelerates. + + const speed = Vec.dist(point, this.prevPoint) + + this.prevPoint = point + + const change = speed - this.speed + + this.speed = this.speed + change * (change > 1 ? 0.5 : 0.15) + + this.snapLines = [] + + if (!metaKey && this.speed < 4 && this.snapInfo.state === 'ready') { + const { zoom } = data.document.pageStates[currentPageId].camera + + const bounds = Utils.getBoundsWithCenter( + Utils.translateBounds(this.snapshot.commonBounds, delta) + ) + + const snapResult = Utils.getSnapPoints( + bounds, + this.isCloning ? this.snapInfo.bounds : this.snapInfo.others, + SNAP_DISTANCE / zoom, + this.speed * zoom < 0.45 + ) + + if (snapResult) { + this.snapLines = snapResult.snapLines + delta = Vec.sub(delta, snapResult?.offset) + } + } + + // We've now calculated the "delta", or difference between the + // cursor's position (real or adjusted by snaps or axis locking) + // and the cursor's original position ("origin"). + + // The "movement" is the actual change of position between this + // computed position and the previous computed position. + + const movement = Vec.sub(delta, this.prev) - this.delta = delta this.prev = delta // If cloning... - if (!this.isCreate && altKey) { + if (this.isCloning) { // Not Cloning -> Cloning - if (!this.isCloning) { + if (didChangeCloning) { + if (this.cloneInfo.state === 'empty') { + this.createCloneInfo(data) + } + + if (this.cloneInfo.state === 'empty') { + throw Error + } + + const { clones, clonedBindings } = this.cloneInfo + this.isCloning = true // Put back any bindings we deleted @@ -109,7 +212,7 @@ export class TranslateSession implements Session { }) // Add the cloned bindings - for (const binding of this.snapshot.clonedBindings) { + for (const binding of clonedBindings) { nextBindings[binding.id] = binding } @@ -117,6 +220,10 @@ export class TranslateSession implements Session { nextPageState.selectedIds = clones.map((clone) => clone.id) } + if (this.cloneInfo.state === 'empty') throw Error + + const { clones } = this.cloneInfo + // Either way, move the clones clones.forEach((clone) => { const current = (nextShapes[clone.id] || @@ -126,14 +233,18 @@ export class TranslateSession implements Session { nextShapes[clone.id] = { ...nextShapes[clone.id], - point: Vec.round(Vec.add(current.point, trueDelta)), + point: Vec.round(Vec.add(current.point, movement)), } }) } else { // If not cloning... // Cloning -> Not Cloning - if (this.isCloning) { + if (didChangeCloning) { + if (this.cloneInfo.state === 'empty') throw Error + + const { clones, clonedBindings } = this.cloneInfo + this.isCloning = false // Delete the bindings @@ -159,7 +270,7 @@ export class TranslateSession implements Session { }) // Delete the cloned bindings - for (const binding of this.snapshot.clonedBindings) { + for (const binding of clonedBindings) { nextBindings[binding.id] = undefined } @@ -176,12 +287,15 @@ export class TranslateSession implements Session { nextShapes[shape.id] = { ...nextShapes[shape.id], - point: Vec.round(Vec.add(current.point, trueDelta)), + point: Vec.round(Vec.add(current.point, movement)), } }) } return { + appState: { + snapLines: this.snapLines, + }, document: { pages: { [data.appState.currentPageId]: { @@ -197,7 +311,7 @@ export class TranslateSession implements Session { } cancel = (data: Data) => { - const { initialShapes, clones, clonedBindings, bindingsToDelete } = this.snapshot + const { initialShapes, bindingsToDelete } = this.snapshot const nextBindings: Record | undefined> = {} const nextShapes: Record | undefined> = {} @@ -218,13 +332,19 @@ export class TranslateSession implements Session { nextPageState.selectedIds = this.snapshot.selectedIds } - // Delete clones - clones.forEach((clone) => (nextShapes[clone.id] = undefined)) + if (this.cloneInfo.state === 'ready') { + const { clones, clonedBindings } = this.cloneInfo + // Delete clones + clones.forEach((clone) => (nextShapes[clone.id] = undefined)) - // Delete cloned bindings - clonedBindings.forEach((binding) => (nextBindings[binding.id] = undefined)) + // Delete cloned bindings + clonedBindings.forEach((binding) => (nextBindings[binding.id] = undefined)) + } return { + appState: { + snapLines: [], + }, document: { pages: { [data.appState.currentPageId]: { @@ -242,8 +362,7 @@ export class TranslateSession implements Session { complete(data: Data): TLDrawCommand { const pageId = data.appState.currentPageId - const { initialShapes, initialParentChildren, bindingsToDelete, clones, clonedBindings } = - this.snapshot + const { initialShapes, initialParentChildren, bindingsToDelete } = this.snapshot const beforeBindings: Patch> = {} const beforeShapes: Patch> = {} @@ -252,6 +371,13 @@ export class TranslateSession implements Session { const afterShapes: Patch> = {} if (this.isCloning) { + if (this.cloneInfo.state === 'empty') { + this.createCloneInfo(data) + } + + if (this.cloneInfo.state !== 'ready') throw Error + const { clones, clonedBindings } = this.cloneInfo + // Update the clones clones.forEach((clone) => { beforeShapes[clone.id] = undefined @@ -331,6 +457,9 @@ export class TranslateSession implements Session { return { id: 'translate', before: { + appState: { + snapLines: [], + }, document: { pages: { [data.appState.currentPageId]: { @@ -346,6 +475,9 @@ export class TranslateSession implements Session { }, }, after: { + appState: { + snapLines: [], + }, document: { pages: { [data.appState.currentPageId]: { @@ -362,6 +494,124 @@ export class TranslateSession implements Session { }, } } + + private createSnapInfo = async (data: Data) => { + const { currentPageId } = data.appState + const page = data.document.pages[currentPageId] + const { selectedIds } = data.document.pageStates[currentPageId] + + const allBounds: TLBoundsWithCenter[] = [] + const otherBounds: TLBoundsWithCenter[] = [] + + Object.values(page.shapes).forEach((shape) => { + const bounds = Utils.getBoundsWithCenter(TLDR.getBounds(shape)) + allBounds.push(bounds) + if (!selectedIds.includes(shape.id)) { + otherBounds.push(bounds) + } + }) + + this.snapInfo = { + state: 'ready', + bounds: allBounds, + others: otherBounds, + } + } + + private createCloneInfo = (data: Data) => { + // Create clones when as they're needed. + // Consider doing this work in a worker. + + const { currentPageId } = data.appState + const page = data.document.pages[currentPageId] + const { selectedIds, shapesToMove, initialParentChildren } = this.snapshot + + const cloneMap: Record = {} + const clonedBindingsMap: Record = {} + const clonedBindings: TLDrawBinding[] = [] + + // Create clones of selected shapes + const clones: TLDrawShape[] = [] + + shapesToMove.forEach((shape) => { + const newId = Utils.uniqueId() + + initialParentChildren[newId] = initialParentChildren[shape.id] + + cloneMap[shape.id] = newId + + const clone = { + ...Utils.deepClone(shape), + id: newId, + parentId: shape.parentId, + childIndex: TLDR.getChildIndexAbove(data, shape.id, currentPageId), + } + + clones.push(clone) + }) + + clones.forEach((clone) => { + if (clone.children !== undefined) { + clone.children = clone.children.map((childId) => cloneMap[childId]) + } + }) + + clones.forEach((clone) => { + if (selectedIds.includes(clone.parentId)) { + clone.parentId = cloneMap[clone.parentId] + } + }) + + // Potentially confusing name here: these are the ids of the + // original shapes that were cloned, not their clones' ids. + const clonedShapeIds = new Set(Object.keys(cloneMap)) + + // Create cloned bindings for shapes where both to and from shapes are selected + // (if the user clones, then we will create a new binding for the clones) + Object.values(page.bindings) + .filter((binding) => clonedShapeIds.has(binding.fromId) || clonedShapeIds.has(binding.toId)) + .forEach((binding) => { + if (clonedShapeIds.has(binding.fromId)) { + if (clonedShapeIds.has(binding.toId)) { + const cloneId = Utils.uniqueId() + + const cloneBinding = { + ...Utils.deepClone(binding), + id: cloneId, + fromId: cloneMap[binding.fromId] || binding.fromId, + toId: cloneMap[binding.toId] || binding.toId, + } + + clonedBindingsMap[binding.id] = cloneId + clonedBindings.push(cloneBinding) + } + } + }) + + // Assign new binding ids to clones (or delete them!) + clones.forEach((clone) => { + if (clone.handles) { + if (clone.handles) { + for (const id in clone.handles) { + const handle = clone.handles[id as keyof ArrowShape['handles']] + handle.bindingId = handle.bindingId ? clonedBindingsMap[handle.bindingId] : undefined + } + } + } + }) + + clones.forEach((clone) => { + if (page.shapes[clone.id]) { + throw Error("uh oh, we didn't clone correctly") + } + }) + + this.cloneInfo = { + state: 'ready', + clones, + clonedBindings, + } + } } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types @@ -382,6 +632,20 @@ export function getTranslateSnapshot(data: Data) { : [shape] }) + const idsToMove = new Set(shapesToMove.map((shape) => shape.id)) + + const bindingsToDelete: ArrowBinding[] = [] + + Object.values(page.bindings) + .filter((binding) => idsToMove.has(binding.fromId) || idsToMove.has(binding.toId)) + .forEach((binding) => { + if (idsToMove.has(binding.fromId)) { + if (!idsToMove.has(binding.toId)) { + bindingsToDelete.push(binding) + } + } + }) + const initialParentChildren: Record = {} Array.from(new Set(shapesToMove.map((s) => s.parentId)).values()) @@ -391,102 +655,20 @@ export function getTranslateSnapshot(data: Data) { initialParentChildren[id] = shape.children! }) - const cloneMap: Record = {} - const clonedBindingsMap: Record = {} - const clonedBindings: TLDrawBinding[] = [] - - // Create clones of selected shapes - const clones: TLDrawShape[] = [] - - shapesToMove.forEach((shape) => { - const newId = Utils.uniqueId() - - initialParentChildren[newId] = initialParentChildren[shape.id] - - cloneMap[shape.id] = newId - - const clone = { - ...Utils.deepClone(shape), - id: newId, - parentId: shape.parentId, - childIndex: TLDR.getChildIndexAbove(data, shape.id, currentPageId), - } - - clones.push(clone) - }) - - clones.forEach((clone) => { - if (clone.children !== undefined) { - clone.children = clone.children.map((childId) => cloneMap[childId]) - } - }) - - clones.forEach((clone) => { - if (selectedIds.includes(clone.parentId)) { - clone.parentId = cloneMap[clone.parentId] - } - }) - - // Potentially confusing name here: these are the ids of the - // original shapes that were cloned, not their clones' ids. - const clonedShapeIds = new Set(Object.keys(cloneMap)) - - const bindingsToDelete: TLDrawBinding[] = [] - - // Create cloned bindings for shapes where both to and from shapes are selected - // (if the user clones, then we will create a new binding for the clones) - Object.values(page.bindings) - .filter((binding) => clonedShapeIds.has(binding.fromId) || clonedShapeIds.has(binding.toId)) - .forEach((binding) => { - if (clonedShapeIds.has(binding.fromId)) { - if (clonedShapeIds.has(binding.toId)) { - const cloneId = Utils.uniqueId() - - const cloneBinding = { - ...Utils.deepClone(binding), - id: cloneId, - fromId: cloneMap[binding.fromId] || binding.fromId, - toId: cloneMap[binding.toId] || binding.toId, - } - - clonedBindingsMap[binding.id] = cloneId - clonedBindings.push(cloneBinding) - } else { - bindingsToDelete.push(binding) - } - } - }) - - // Assign new binding ids to clones (or delete them!) - clones.forEach((clone) => { - if (clone.handles) { - if (clone.handles) { - for (const id in clone.handles) { - const handle = clone.handles[id as keyof ArrowShape['handles']] - handle.bindingId = handle.bindingId ? clonedBindingsMap[handle.bindingId] : undefined - } - } - } - }) - - clones.forEach((clone) => { - if (page.shapes[clone.id]) { - throw Error("uh oh, we didn't clone correctly") - } - }) + const commonBounds = Utils.getCommonBounds(shapesToMove.map(TLDR.getBounds)) return { selectedIds, - bindingsToDelete, hasUnlockedShapes, initialParentChildren, + shapesToMove, + bindingsToDelete, + commonBounds, initialShapes: shapesToMove.map(({ id, point, parentId }) => ({ id, point, parentId, })), - clones, - clonedBindings, } } diff --git a/packages/tldraw/src/state/tlstate.ts b/packages/tldraw/src/state/tlstate.ts index 4c8542b62..f0cc8c94a 100644 --- a/packages/tldraw/src/state/tlstate.ts +++ b/packages/tldraw/src/state/tlstate.ts @@ -44,6 +44,7 @@ import { ArgsOfType, getSession } from './session' import { sample, USER_COLORS } from './utils' import { createTools, ToolType } from './tool' import type { BaseTool } from './tool/BaseTool' +import * as constants from './constants' const uuid = Utils.uniqueId() @@ -1392,7 +1393,10 @@ export class TLDrawState extends StateManager { const bounds = Utils.getCommonBounds(shapes.map(TLDR.getBounds)) let zoom = TLDR.getCameraZoom( - Math.min((this.bounds.width - 128) / bounds.width, (this.bounds.height - 128) / bounds.height) + Math.min( + (this.bounds.width - constants.FIT_TO_SCREEN_PADDING) / bounds.width, + (this.bounds.height - constants.FIT_TO_SCREEN_PADDING) / bounds.height + ) ) zoom = @@ -1419,7 +1423,10 @@ export class TLDrawState extends StateManager { const bounds = TLDR.getSelectedBounds(this.state) let zoom = TLDR.getCameraZoom( - Math.min((this.bounds.width - 128) / bounds.width, (this.bounds.height - 128) / bounds.height) + Math.min( + (this.bounds.width - constants.FIT_TO_SCREEN_PADDING) / bounds.width, + (this.bounds.height - constants.FIT_TO_SCREEN_PADDING) / bounds.height + ) ) zoom = @@ -1645,10 +1652,13 @@ export class TLDrawState extends StateManager { * updateSession. * @param args The arguments of the current session's update method. */ - updateSession = (point: number[], shiftKey = false, altKey = false, metaKey = false): this => { + updateSession = (...args: ExceptFirst>): this => { const { session } = this if (!session) return this - const patch = session.update(this.state, point, shiftKey, altKey, metaKey) + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const patch = session.update(this.state, ...args) if (!patch) return this return this.patchState(patch, `session:${session?.constructor.name}`) } @@ -1713,6 +1723,7 @@ export class TLDrawState extends StateManager { // the shape we just created. result.before = { appState: { + ...result.before.appState, status: TLDrawStatus.Idle, }, document: { @@ -1741,6 +1752,7 @@ export class TLDrawState extends StateManager { } result.after.appState = { + ...result.after.appState, status: TLDrawStatus.Idle, } @@ -2373,6 +2385,23 @@ export class TLDrawState extends StateManager { return Vec.round([this.bounds.width / 2, this.bounds.height / 2]) } + get viewport() { + const { camera } = this.pageState + const { width, height } = this.bounds + + const [minX, minY] = Vec.sub(Vec.div([0, 0], camera.zoom), camera.point) + const [maxX, maxY] = Vec.sub(Vec.div([width, height], camera.zoom), camera.point) + + return { + minX, + minY, + maxX, + maxY, + height: maxX - minX, + width: maxY - minY, + } + } + static version = 10.1 static defaultDocument: TLDrawDocument = { @@ -2420,6 +2449,7 @@ export class TLDrawState extends StateManager { isStyleOpen: false, isEmptyCanvas: false, status: TLDrawStatus.Idle, + snapLines: [], }, document: TLDrawState.defaultDocument, room: { diff --git a/packages/tldraw/src/types.ts b/packages/tldraw/src/types.ts index bfb1a4bb5..57066cdfa 100644 --- a/packages/tldraw/src/types.ts +++ b/packages/tldraw/src/types.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/ban-types */ -import type { TLBinding, TLShapeProps } from '@tldraw/core' +import type { TLBinding, TLShapeProps, TLSnapLine } from '@tldraw/core' import type { TLShape, TLShapeUtil, TLHandle } from '@tldraw/core' import type { TLPage, TLUser, TLPageState } from '@tldraw/core' import type { StoreApi } from 'zustand' @@ -63,6 +63,7 @@ export interface Data { isStyleOpen: boolean isEmptyCanvas: boolean status: string + snapLines: TLSnapLine[] } document: TLDrawDocument room?: { @@ -105,9 +106,9 @@ export abstract class Session { abstract update: ( data: Readonly, point: number[], - shiftKey: boolean, - altKey: boolean, - metaKey: boolean + shiftKey?: boolean, + altKey?: boolean, + metaKey?: boolean ) => TLDrawPatch | undefined abstract complete: (data: Readonly) => TLDrawPatch | TLDrawCommand | undefined abstract cancel: (data: Readonly) => TLDrawPatch | undefined diff --git a/packages/vec/package.json b/packages/vec/package.json index 08e7ed1e4..48d643eca 100644 --- a/packages/vec/package.json +++ b/packages/vec/package.json @@ -39,7 +39,7 @@ "@types/node": "^16.7.10", "@typescript-eslint/eslint-plugin": "^4.30.0", "@typescript-eslint/parser": "^4.30.0", - "esbuild": "^0.12.24", + "esbuild": "^0.13.8", "eslint": "^7.32.0", "lerna": "^4.0.0", "ts-node": "^10.2.1", diff --git a/yarn.lock b/yarn.lock index ca971cdcb..f28a893bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6319,10 +6319,113 @@ es6-promisify@^5.0.0: dependencies: es6-promise "^4.0.3" -esbuild@^0.12.24, esbuild@^0.12.26: - version "0.12.28" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.12.28.tgz#84da0d2a0d0dee181281545271e0d65cf6fab1ef" - integrity sha512-pZ0FrWZXlvQOATlp14lRSk1N9GkeJ3vLIwOcUoo3ICQn9WNR4rWoNi81pbn6sC1iYUy7QPqNzI3+AEzokwyVcA== +esbuild-android-arm64@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.13.8.tgz#c20e875c3c98164b1ffba9b28637bdf96f5e9e7c" + integrity sha512-AilbChndywpk7CdKkNSZ9klxl+9MboLctXd9LwLo3b0dawmOF/i/t2U5d8LM6SbT1Xw36F8yngSUPrd8yPs2RA== + +esbuild-darwin-64@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.13.8.tgz#f46e6b471ddbf62265234808a6a1aa91df18a417" + integrity sha512-b6sdiT84zV5LVaoF+UoMVGJzR/iE2vNUfUDfFQGrm4LBwM/PWXweKpuu6RD9mcyCq18cLxkP6w/LD/w9DtX3ng== + +esbuild-darwin-arm64@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.13.8.tgz#a991157a6013facd4f2e14159b7da52626c90154" + integrity sha512-R8YuPiiJayuJJRUBG4H0VwkEKo6AvhJs2m7Tl0JaIer3u1FHHXwGhMxjJDmK+kXwTFPriSysPvcobXC/UrrZCQ== + +esbuild-freebsd-64@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.13.8.tgz#301601d2e443ad458960e359b402a17d9500be9d" + integrity sha512-zBn6urrn8FnKC+YSgDxdof9jhPCeU8kR/qaamlV4gI8R3KUaUK162WYM7UyFVAlj9N0MyD3AtB+hltzu4cysTw== + +esbuild-freebsd-arm64@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.13.8.tgz#039a63acc12ec0892006c147ea221e55f9125a9f" + integrity sha512-pWW2slN7lGlkx0MOEBoUGwRX5UgSCLq3dy2c8RIOpiHtA87xAUpDBvZK10MykbT+aMfXc0NI2lu1X+6kI34xng== + +esbuild-linux-32@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.13.8.tgz#c537b67d7e694b60bfa2786581412838c6ba0284" + integrity sha512-T0I0ueeKVO/Is0CAeSEOG9s2jeNNb8jrrMwG9QBIm3UU18MRB60ERgkS2uV3fZ1vP2F8i3Z2e3Zju4lg9dhVmw== + +esbuild-linux-64@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.13.8.tgz#0092fc8a064001a777bfa0e3b425bb8be8f96e6a" + integrity sha512-Bm8SYmFtvfDCIu9sjKppFXzRXn2BVpuCinU1ChTuMtdKI/7aPpXIrkqBNOgPTOQO9AylJJc1Zw6EvtKORhn64w== + +esbuild-linux-arm64@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.13.8.tgz#5cd3f2bb924212971482e8dbc25c4afd09b28110" + integrity sha512-X4pWZ+SL+FJ09chWFgRNO3F+YtvAQRcWh0uxKqZSWKiWodAB20flsW/OWFYLXBKiVCTeoGMvENZS/GeVac7+tQ== + +esbuild-linux-arm@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.13.8.tgz#ad634f96bf2975536907aeb9fdb75a3194f4ddce" + integrity sha512-4/HfcC40LJ4GPyboHA+db0jpFarTB628D1ifU+/5bunIgY+t6mHkJWyxWxAAE8wl/ZIuRYB9RJFdYpu1AXGPdg== + +esbuild-linux-mips64le@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.13.8.tgz#57857edfebf9bf65766dc8be1637f2179c990572" + integrity sha512-o7e0D+sqHKT31v+mwFircJFjwSKVd2nbkHEn4l9xQ1hLR+Bv8rnt3HqlblY3+sBdlrOTGSwz0ReROlKUMJyldA== + +esbuild-linux-ppc64le@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.13.8.tgz#fdb82a059a5b86bb10fb42091b4ebcf488b9cd46" + integrity sha512-eZSQ0ERsWkukJp2px/UWJHVNuy0lMoz/HZcRWAbB6reoaBw7S9vMzYNUnflfL3XA6WDs+dZn3ekHE4Y2uWLGig== + +esbuild-netbsd-64@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.13.8.tgz#d7879e7123d3b2c04754ece8bd061aa6866deeff" + integrity sha512-gZX4kP7gVvOrvX0ZwgHmbuHczQUwqYppxqtoyC7VNd80t5nBHOFXVhWo2Ad/Lms0E8b+wwgI/WjZFTCpUHOg9Q== + +esbuild-openbsd-64@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.13.8.tgz#88b280b6cb0a3f6adb60abf27fc506c506a35cf0" + integrity sha512-afzza308X4WmcebexbTzAgfEWt9MUkdTvwIa8xOu4CM2qGbl2LanqEl8/LUs8jh6Gqw6WsicEK52GPrS9wvkcw== + +esbuild-sunos-64@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.13.8.tgz#229ae7c7703196a58acd0f0291ad9bebda815d63" + integrity sha512-mWPZibmBbuMKD+LDN23LGcOZ2EawMYBONMXXHmbuxeT0XxCNwadbCVwUQ/2p5Dp5Kvf6mhrlIffcnWOiCBpiVw== + +esbuild-windows-32@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.13.8.tgz#892d093e32a21c0c9135e5a0ffdc380aeb70e763" + integrity sha512-QsZ1HnWIcnIEApETZWw8HlOhDSWqdZX2SylU7IzGxOYyVcX7QI06ety/aDcn437mwyO7Ph4RrbhB+2ntM8kX8A== + +esbuild-windows-64@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.13.8.tgz#7defd8d79ae3bb7e6f53b65a7190be7daf901686" + integrity sha512-76Fb57B9eE/JmJi1QmUW0tRLQZfGo0it+JeYoCDTSlbTn7LV44ecOHIMJSSgZADUtRMWT9z0Kz186bnaB3amSg== + +esbuild-windows-arm64@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.13.8.tgz#e59ae004496fd8a5ab67bfc7945a2e47480d6fb9" + integrity sha512-HW6Mtq5eTudllxY2YgT62MrVcn7oq2o8TAoAvDUhyiEmRmDY8tPwAhb1vxw5/cdkbukM3KdMYtksnUhF/ekWeg== + +esbuild@^0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.13.8.tgz#bd7cc51b881ab067789f88e17baca74724c1ec4f" + integrity sha512-A4af7G7YZLfG5OnARJRMtlpEsCkq/zHZQXewgPA864l9D6VjjbH1SuFYK/OSV6BtHwDGkdwyRrX0qQFLnMfUcw== + optionalDependencies: + esbuild-android-arm64 "0.13.8" + esbuild-darwin-64 "0.13.8" + esbuild-darwin-arm64 "0.13.8" + esbuild-freebsd-64 "0.13.8" + esbuild-freebsd-arm64 "0.13.8" + esbuild-linux-32 "0.13.8" + esbuild-linux-64 "0.13.8" + esbuild-linux-arm "0.13.8" + esbuild-linux-arm64 "0.13.8" + esbuild-linux-mips64le "0.13.8" + esbuild-linux-ppc64le "0.13.8" + esbuild-netbsd-64 "0.13.8" + esbuild-openbsd-64 "0.13.8" + esbuild-sunos-64 "0.13.8" + esbuild-windows-32 "0.13.8" + esbuild-windows-64 "0.13.8" + esbuild-windows-arm64 "0.13.8" escalade@^3.1.1: version "3.1.1" @@ -7616,11 +7719,16 @@ idb-keyval@^5.1.3: dependencies: safari-14-idb-fix "^1.0.6" -idb@^6.0.0, idb@^6.1.2: +idb@^6.0.0: version "6.1.3" resolved "https://registry.yarnpkg.com/idb/-/idb-6.1.3.tgz#e6cd3b9c38f5c696a82a4b435754f3873c5a7891" integrity sha512-oIRDpVcs5KXpI1hRnTJUwkY63RB/7iqu9nSNuzXN8TLHjs7oO20IoPFbBTsqxIL5IjzIUDi+FXlVcK4zm26J8A== +idb@^6.1.2: + version "6.1.4" + resolved "https://registry.yarnpkg.com/idb/-/idb-6.1.4.tgz#ec77519fe2591be616eaf3bbdedc3662bb558a99" + integrity sha512-DshI5yxIB3NYc47cPpfipYX8MSIgQPqVR+WoaGI9EDq6cnLGgGYR1fp6z8/Bq9vMS8Jq1bS3eWUgXpFO5+ypSA== + ieee754@^1.1.4, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"