[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
This commit is contained in:
Steve Ruiz 2021-10-18 14:30:42 +01:00 committed by GitHub
parent b1b9f901d3
commit 0cfc68b004
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 799 additions and 222 deletions

View file

@ -41,7 +41,7 @@
"@types/react-dom": "^16.9.9", "@types/react-dom": "^16.9.9",
"@typescript-eslint/eslint-plugin": "^4.30.0", "@typescript-eslint/eslint-plugin": "^4.30.0",
"@typescript-eslint/parser": "^4.30.0", "@typescript-eslint/parser": "^4.30.0",
"esbuild": "^0.12.24", "esbuild": "^0.13.8",
"eslint": "^7.32.0", "eslint": "^7.32.0",
"lerna": "^4.0.0", "lerna": "^4.0.0",
"react": ">=16.8", "react": ">=16.8",

View file

@ -6,10 +6,10 @@ const name = process.env.npm_package_name || ''
async function main() { async function main() {
esbuild.build({ esbuild.build({
entryPoints: ['./src/index.ts'], entryPoints: ['./src/index.ts'],
outdir: 'dist/cjs', outdir: 'dist/esm',
minify: false, minify: false,
bundle: true, bundle: true,
format: 'cjs', format: 'esm',
target: 'es6', target: 'es6',
jsxFactory: 'React.createElement', jsxFactory: 'React.createElement',
jsxFragment: 'React.Fragment', jsxFragment: 'React.Fragment',

View file

@ -13,10 +13,10 @@ async function main() {
esbuild.build({ esbuild.build({
entryPoints: ['./src/index.ts'], entryPoints: ['./src/index.ts'],
outdir: 'dist/cjs', outdir: 'dist/esm',
minify: false, minify: false,
bundle: true, bundle: true,
format: 'cjs', format: 'esm',
target: 'es6', target: 'es6',
jsxFactory: 'React.createElement', jsxFactory: 'React.createElement',
jsxFragment: 'React.Fragment', jsxFragment: 'React.Fragment',

View file

@ -38,8 +38,10 @@ export const Bounds = React.memo(
const smallDimension = Math.min(bounds.width, bounds.height) * zoom const smallDimension = Math.min(bounds.width, bounds.height) * zoom
// If the bounds are small, don't show the rotate handle // If the bounds are small, don't show the rotate handle
const showRotateHandle = !isHidden && !isLocked && smallDimension > 32 const showRotateHandle = !isHidden && !isLocked && smallDimension > 32
// If the bounds are very small, don't show the corner handles // If the bounds are very small, don't show the edge handles
const showHandles = !isHidden && !isLocked && smallDimension > 16 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 ( return (
<Container bounds={bounds} rotation={rotation}> <Container bounds={bounds} rotation={rotation}>
@ -50,62 +52,62 @@ export const Bounds = React.memo(
size={size} size={size}
bounds={bounds} bounds={bounds}
edge={TLBoundsEdge.Top} edge={TLBoundsEdge.Top}
isHidden={!showHandles} isHidden={!showEdgeHandles}
/> />
<EdgeHandle <EdgeHandle
targetSize={targetSize} targetSize={targetSize}
size={size} size={size}
bounds={bounds} bounds={bounds}
edge={TLBoundsEdge.Right} edge={TLBoundsEdge.Right}
isHidden={!showHandles} isHidden={!showEdgeHandles}
/> />
<EdgeHandle <EdgeHandle
targetSize={targetSize} targetSize={targetSize}
size={size} size={size}
bounds={bounds} bounds={bounds}
edge={TLBoundsEdge.Bottom} edge={TLBoundsEdge.Bottom}
isHidden={!showHandles} isHidden={!showEdgeHandles}
/> />
<EdgeHandle <EdgeHandle
targetSize={targetSize} targetSize={targetSize}
size={size} size={size}
bounds={bounds} bounds={bounds}
edge={TLBoundsEdge.Left} edge={TLBoundsEdge.Left}
isHidden={!showHandles} isHidden={!showEdgeHandles}
/> />
<CornerHandle <CornerHandle
targetSize={targetSize} targetSize={targetSize}
size={size} size={size}
bounds={bounds} bounds={bounds}
isHidden={isHidden} isHidden={isHidden || !showCornerHandles}
corner={TLBoundsCorner.TopLeft} corner={TLBoundsCorner.TopLeft}
/> />
<CornerHandle <CornerHandle
targetSize={targetSize} targetSize={targetSize}
size={size} size={size}
bounds={bounds} bounds={bounds}
isHidden={isHidden} isHidden={isHidden || !showCornerHandles}
corner={TLBoundsCorner.TopRight} corner={TLBoundsCorner.TopRight}
/> />
<CornerHandle <CornerHandle
targetSize={targetSize} targetSize={targetSize}
size={size} size={size}
bounds={bounds} bounds={bounds}
isHidden={isHidden} isHidden={isHidden || !showCornerHandles}
corner={TLBoundsCorner.BottomRight} corner={TLBoundsCorner.BottomRight}
/> />
<CornerHandle <CornerHandle
targetSize={targetSize} targetSize={targetSize}
size={size} size={size}
bounds={bounds} bounds={bounds}
isHidden={isHidden} isHidden={isHidden || !showCornerHandles}
corner={TLBoundsCorner.BottomLeft} corner={TLBoundsCorner.BottomLeft}
/> />
<RotateHandle <RotateHandle
targetSize={targetSize} targetSize={targetSize}
size={size} size={size}
bounds={bounds} bounds={bounds}
isHidden={!showHandles || !showRotateHandle} isHidden={!showEdgeHandles || !showRotateHandle}
/> />
{showCloneButtons && <CloneButtons bounds={bounds} />} {showCloneButtons && <CloneButtons bounds={bounds} />}
</SVGContainer> </SVGContainer>

View file

@ -8,7 +8,7 @@ import {
useCameraCss, useCameraCss,
useKeyEvents, useKeyEvents,
} from '+hooks' } 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 { ErrorFallback } from '+components/error-fallback'
import { ErrorBoundary } from '+components/error-boundary' import { ErrorBoundary } from '+components/error-boundary'
import { Brush } from '+components/brush' import { Brush } from '+components/brush'
@ -17,6 +17,8 @@ import { Users } from '+components/users'
import { useResizeObserver } from '+hooks/useResizeObserver' import { useResizeObserver } from '+hooks/useResizeObserver'
import { inputs } from '+inputs' import { inputs } from '+inputs'
import { UsersIndicators } from '+components/users-indicators' import { UsersIndicators } from '+components/users-indicators'
import { SnapLines } from '+components/snap-lines/snap-lines'
import { Overlay } from '+components/overlay'
function resetError() { function resetError() {
void null void null
@ -25,6 +27,7 @@ function resetError() {
interface CanvasProps<T extends TLShape, M extends Record<string, unknown>> { interface CanvasProps<T extends TLShape, M extends Record<string, unknown>> {
page: TLPage<T, TLBinding> page: TLPage<T, TLBinding>
pageState: TLPageState pageState: TLPageState
snapLines?: TLSnapLine[]
users?: TLUsers<T> users?: TLUsers<T>
userId?: string userId?: string
hideBounds?: boolean hideBounds?: boolean
@ -39,6 +42,7 @@ export function Canvas<T extends TLShape, M extends Record<string, unknown>>({
id, id,
page, page,
pageState, pageState,
snapLines,
users, users,
userId, userId,
meta, meta,
@ -87,6 +91,9 @@ export function Canvas<T extends TLShape, M extends Record<string, unknown>>({
{users && <Users userId={userId} users={users} />} {users && <Users userId={userId} users={users} />}
</div> </div>
</ErrorBoundary> </ErrorBoundary>
<Overlay camera={pageState.camera}>
{snapLines && <SnapLines snapLines={snapLines} />}
</Overlay>
</div> </div>
</div> </div>
) )

View file

@ -0,0 +1 @@
export * from './overlay'

View file

@ -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 (
<svg className="tl-overlay">
<defs>
<g id="tl-snap-point">
<path
className="tl-snap-point"
d={`M ${-l},${-l} L ${l},${l} M ${-l},${l} L ${l},${-l}`}
/>
</g>
</defs>
<g transform={`scale(${camera.zoom}) translate(${camera.point})`}>{children}</g>
</svg>
)
}

View file

@ -12,7 +12,7 @@ import type {
import { Canvas } from '../canvas' import { Canvas } from '../canvas'
import { Inputs } from '../../inputs' import { Inputs } from '../../inputs'
import { useTLTheme, TLContext, TLContextType } from '../../hooks' import { useTLTheme, TLContext, TLContextType } from '../../hooks'
import type { TLShapeUtil, TLUsers } from '+index' import type { TLShapeUtil, TLSnapLine, TLUsers } from '+index'
export interface RendererProps<T extends TLShape, E extends Element = any, M = any> export interface RendererProps<T extends TLShape, E extends Element = any, M = any>
extends Partial<TLCallbacks<T>> { extends Partial<TLCallbacks<T>> {
@ -40,6 +40,10 @@ export interface RendererProps<T extends TLShape, E extends Element = any, M = a
* (optional) The current users to render. * (optional) The current users to render.
*/ */
users?: TLUsers<T> users?: TLUsers<T>
/**
* (optional) The current snap lines to render.
*/
snapLines?: TLSnapLine[]
/** /**
* (optional) The current user's id, used to identify the user. * (optional) The current user's id, used to identify the user.
*/ */
@ -97,6 +101,7 @@ export function Renderer<T extends TLShape, E extends Element, M extends Record<
userId, userId,
theme, theme,
meta, meta,
snapLines,
containerRef, containerRef,
hideHandles = false, hideHandles = false,
hideIndicators = false, hideIndicators = false,
@ -132,6 +137,7 @@ export function Renderer<T extends TLShape, E extends Element, M extends Record<
id={id} id={id}
page={page} page={page}
pageState={pageState} pageState={pageState}
snapLines={snapLines}
users={users} users={users}
userId={userId} userId={userId}
hideBounds={hideBounds} hideBounds={hideBounds}

View file

@ -0,0 +1 @@
export * from './snap-lines'

View file

@ -0,0 +1,32 @@
import * as React from 'react'
import type { TLSnapLine } from '+types'
import Utils from '+utils'
export function SnapLines({ snapLines }: { snapLines: TLSnapLine[] }) {
return (
<>
{snapLines.map((snapLine, i) => (
<SnapLine key={i} snapLine={snapLine} />
))}
</>
)
}
export function SnapLine({ snapLine }: { snapLine: TLSnapLine }) {
const bounds = Utils.getBoundsFromPoints(snapLine)
return (
<>
<line
className="tl-snap-line"
x1={bounds.minX}
y1={bounds.minY}
x2={bounds.maxX}
y2={bounds.maxY}
/>
{snapLine.map(([x, y], i) => (
<use key={i} href="#tl-snap-point" x={x} y={y} />
))}
</>
)
}

View file

@ -69,6 +69,8 @@ export function useShapeEvents(id: string, disable = false) {
if (!inputs.pointerIsValid(e)) return if (!inputs.pointerIsValid(e)) return
if (disable) return if (disable) return
e.stopPropagation()
if (inputs.pointer && e.pointerId !== inputs.pointer.pointerId) return if (inputs.pointer && e.pointerId !== inputs.pointer.pointerId) return
const info = inputs.pointerMove(e, id) const info = inputs.pointerMove(e, id)

View file

@ -66,6 +66,7 @@ const css = (strings: TemplateStringsArray, ...args: unknown[]) =>
) )
const defaultTheme: TLTheme = { const defaultTheme: TLTheme = {
accent: 'rgb(255, 0, 0)',
brushFill: 'rgba(0,0,0,.05)', brushFill: 'rgba(0,0,0,.05)',
brushStroke: 'rgba(0,0,0,.25)', brushStroke: 'rgba(0,0,0,.25)',
selectStroke: 'rgb(66, 133, 244)', selectStroke: 'rgb(66, 133, 244)',
@ -133,6 +134,24 @@ const tlcss = css`
box-sizing: border-box; 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 { .tl-canvas {
position: absolute; position: absolute;
width: 100%; width: 100%;

View file

@ -42,6 +42,8 @@ export interface TLUser<T extends TLShape> {
export type TLUsers<T extends TLShape, U extends TLUser<T> = TLUser<T>> = Record<string, U> export type TLUsers<T extends TLShape, U extends TLUser<T> = TLUser<T>> = Record<string, U>
export type TLSnapLine = number[][]
export interface TLHandle { export interface TLHandle {
id: string id: string
index: number index: number
@ -112,6 +114,7 @@ export interface TLBinding<M = any> {
} }
export interface TLTheme { export interface TLTheme {
accent?: string
brushFill?: string brushFill?: string
brushStroke?: string brushStroke?: string
selectFill?: string selectFill?: string
@ -242,6 +245,11 @@ export interface TLBounds {
rotation?: number rotation?: number
} }
export interface TLBoundsWithCenter extends TLBounds {
midX: number
midY: number
}
export type TLIntersection = { export type TLIntersection = {
didIntersect: boolean didIntersect: boolean
message: string message: string

View file

@ -6,7 +6,7 @@ import type React from 'react'
import { TLBezierCurveSegment, TLBounds, TLBoundsCorner, TLBoundsEdge } from '../types' import { TLBezierCurveSegment, TLBounds, TLBoundsCorner, TLBoundsEdge } from '../types'
import { Vec } from '@tldraw/vec' import { Vec } from '@tldraw/vec'
import './polyfills' import './polyfills'
import type { Patch } from '+index' import type { Patch, TLBoundsWithCenter } from '+index'
export class Utils { 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] 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 */ /* Lists and Collections */
/* -------------------------------------------------- */ /* -------------------------------------------------- */
@ -1684,6 +1824,59 @@ left past the initial left edge) then swap points on that axis.
/* Browser and DOM */ /* 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() { static isMobileSize() {
if (typeof window === 'undefined') return false if (typeof window === 'undefined') return false
return window.innerWidth < 768 return window.innerWidth < 768

View file

@ -35,7 +35,7 @@
"@types/react-router-dom": "^5.1.8", "@types/react-router-dom": "^5.1.8",
"concurrently": "6.0.1", "concurrently": "6.0.1",
"create-serve": "1.0.1", "create-serve": "1.0.1",
"esbuild": "^0.12.26", "esbuild": "^0.13.8",
"rimraf": "3.0.2", "rimraf": "3.0.2",
"typescript": "4.2.3" "typescript": "4.2.3"
}, },

View file

@ -39,7 +39,7 @@
"@types/node": "^16.7.10", "@types/node": "^16.7.10",
"@typescript-eslint/eslint-plugin": "^4.30.0", "@typescript-eslint/eslint-plugin": "^4.30.0",
"@typescript-eslint/parser": "^4.30.0", "@typescript-eslint/parser": "^4.30.0",
"esbuild": "^0.12.24", "esbuild": "^0.13.8",
"eslint": "^7.32.0", "eslint": "^7.32.0",
"lerna": "^4.0.0", "lerna": "^4.0.0",
"ts-node": "^10.2.1", "ts-node": "^10.2.1",

View file

@ -40,7 +40,7 @@
"@types/react-dom": "^16.9.9", "@types/react-dom": "^16.9.9",
"@typescript-eslint/eslint-plugin": "^4.30.0", "@typescript-eslint/eslint-plugin": "^4.30.0",
"@typescript-eslint/parser": "^4.30.0", "@typescript-eslint/parser": "^4.30.0",
"esbuild": "^0.12.24", "esbuild": "^0.13.8",
"eslint": "^7.32.0", "eslint": "^7.32.0",
"lerna": "^4.0.0", "lerna": "^4.0.0",
"ts-node": "^10.2.1", "ts-node": "^10.2.1",

View file

@ -6,10 +6,10 @@ const name = process.env.npm_package_name || ''
async function main() { async function main() {
esbuild.build({ esbuild.build({
entryPoints: ['./src/index.ts'], entryPoints: ['./src/index.ts'],
outdir: 'dist/cjs', outdir: 'dist/esm',
minify: false, minify: false,
bundle: true, bundle: true,
format: 'cjs', format: 'esm',
target: 'es6', target: 'es6',
jsxFactory: 'React.createElement', jsxFactory: 'React.createElement',
jsxFragment: 'React.Fragment', jsxFragment: 'React.Fragment',

View file

@ -13,10 +13,10 @@ async function main() {
esbuild.build({ esbuild.build({
entryPoints: ['./src/index.ts'], entryPoints: ['./src/index.ts'],
outdir: 'dist/cjs', outdir: 'dist/esm',
minify: false, minify: false,
bundle: true, bundle: true,
format: 'cjs', format: 'esm',
target: 'es6', target: 'es6',
jsxFactory: 'React.createElement', jsxFactory: 'React.createElement',
jsxFragment: 'React.Fragment', jsxFragment: 'React.Fragment',

View file

@ -29,6 +29,8 @@ const isHideBoundsShapeSelector = (s: Data) => {
const pageSelector = (s: Data) => s.document.pages[s.appState.currentPageId] 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 usersSelector = (s: Data) => s.room?.users
const pageStateSelector = (s: Data) => s.document.pageStates[s.appState.currentPageId] const pageStateSelector = (s: Data) => s.document.pageStates[s.appState.currentPageId]
@ -149,6 +151,8 @@ function InnerTldraw({
const pageState = useSelector(pageStateSelector) const pageState = useSelector(pageStateSelector)
const snapLines = useSelector(snapLinesSelector)
const users = useSelector(usersSelector) const users = useSelector(usersSelector)
const isDarkMode = useSelector(isDarkModeSelector) const isDarkMode = useSelector(isDarkModeSelector)
@ -217,6 +221,7 @@ function InnerTldraw({
containerRef={rWrapper} containerRef={rWrapper}
page={page} page={page}
pageState={pageState} pageState={pageState}
snapLines={snapLines}
users={users} users={users}
userId={tlstate.state.room?.userId} userId={tlstate.state.room?.userId}
shapeUtils={tldrawShapeUtils} shapeUtils={tldrawShapeUtils}

View file

@ -150,56 +150,3 @@ export const defaultStyle: ShapeStyles = {
isFilled: false, isFilled: false,
dash: DashStyle.Draw, 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,
}
}

View file

@ -2,7 +2,7 @@ import * as React from 'react'
import { ShapeUtil, SVGContainer, TLBounds, Utils, TLHandle } from '@tldraw/core' import { ShapeUtil, SVGContainer, TLBounds, Utils, TLHandle } from '@tldraw/core'
import { Vec } from '@tldraw/vec' import { Vec } from '@tldraw/vec'
import getStroke from 'perfect-freehand' import getStroke from 'perfect-freehand'
import { defaultStyle, getPerfectDashProps, getShapeStyle } from '~shape/shape-styles' import { defaultStyle, getShapeStyle } from '~shape/shape-styles'
import { import {
ArrowShape, ArrowShape,
Decoration, Decoration,
@ -102,7 +102,7 @@ export const Arrow = new ShapeUtil<ArrowShape, SVGSVGElement, TLDrawMeta>(() =>
? renderFreehandArrowShaft(shape) ? renderFreehandArrowShaft(shape)
: 'M' + Vec.round(start.point) + 'L' + Vec.round(end.point) : 'M' + Vec.round(start.point) + 'L' + Vec.round(end.point)
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
arrowDist, arrowDist,
strokeWidth * 1.618, strokeWidth * 1.618,
shape.style.dash, shape.style.dash,
@ -154,7 +154,7 @@ export const Arrow = new ShapeUtil<ArrowShape, SVGSVGElement, TLDrawMeta>(() =>
? renderCurvedFreehandArrowShaft(shape, circle, length, easing) ? renderCurvedFreehandArrowShaft(shape, circle, length, easing)
: getArrowArcPath(start, end, circle, shape.bend) : getArrowArcPath(start, end, circle, shape.bend)
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
Math.abs(length), Math.abs(length),
sw, sw,
shape.style.dash, shape.style.dash,

View file

@ -2,7 +2,7 @@ import * as React from 'react'
import { SVGContainer, Utils, ShapeUtil, TLTransformInfo, TLBounds } from '@tldraw/core' import { SVGContainer, Utils, ShapeUtil, TLTransformInfo, TLBounds } from '@tldraw/core'
import { Vec } from '@tldraw/vec' import { Vec } from '@tldraw/vec'
import { DashStyle, EllipseShape, TLDrawShapeType, TLDrawMeta } from '~types' 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 { getStrokeOutlinePoints, getStrokePoints } from 'perfect-freehand'
import { import {
intersectBoundsEllipse, intersectBoundsEllipse,
@ -82,7 +82,7 @@ export const Ellipse = new ShapeUtil<EllipseShape, SVGSVGElement, TLDrawMeta>(()
const perimeter = Math.PI * (rx + ry) * (1 + (3 * h) / (10 + Math.sqrt(4 - 3 * h))) 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, perimeter < 64 ? perimeter * 2 : perimeter,
strokeWidth * 1.618, strokeWidth * 1.618,
shape.style.dash, shape.style.dash,

View file

@ -1,13 +1,10 @@
import * as React from 'react' import * as React from 'react'
import { Utils, SVGContainer, ShapeUtil } from '@tldraw/core' import { Utils, SVGContainer, ShapeUtil } from '@tldraw/core'
import { Vec } from '@tldraw/vec' import { Vec } from '@tldraw/vec'
import getStroke, { getStrokeOutlinePoints, getStrokePoints } from 'perfect-freehand' import getStroke, { getStrokePoints } from 'perfect-freehand'
import { getPerfectDashProps, defaultStyle, getShapeStyle } from '~shape/shape-styles' import { defaultStyle, getShapeStyle } from '~shape/shape-styles'
import { RectangleShape, DashStyle, TLDrawShapeType, TLDrawMeta } from '~types' import { RectangleShape, DashStyle, TLDrawShapeType, TLDrawMeta } from '~types'
import { getBoundsRectangle, transformRectangle, transformSingleRectangle } from '../shared' import { getBoundsRectangle, transformRectangle, transformSingleRectangle } from '../shared'
import { EASINGS } from '~state/utils'
const pathCache = new WeakMap<number[], string>([])
export const Rectangle = new ShapeUtil<RectangleShape, SVGSVGElement, TLDrawMeta>(() => ({ export const Rectangle = new ShapeUtil<RectangleShape, SVGSVGElement, TLDrawMeta>(() => ({
type: TLDrawShapeType.Rectangle, type: TLDrawShapeType.Rectangle,
@ -36,7 +33,7 @@ export const Rectangle = new ShapeUtil<RectangleShape, SVGSVGElement, TLDrawMeta
const strokeWidth = +styles.strokeWidth const strokeWidth = +styles.strokeWidth
if (style.dash === DashStyle.Draw) { if (style.dash === DashStyle.Draw) {
const pathData = Utils.getFromCache(pathCache, shape.size, () => getRectanglePath(shape)) const pathData = getRectanglePath(shape)
return ( return (
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}> <SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
@ -80,7 +77,7 @@ export const Rectangle = new ShapeUtil<RectangleShape, SVGSVGElement, TLDrawMeta
] ]
const paths = strokes.map(([start, end, length], i) => { const paths = strokes.map(([start, end, length], i) => {
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
length, length,
strokeWidth * 1.618, strokeWidth * 1.618,
shape.style.dash shape.style.dash

View file

@ -0,0 +1,2 @@
export const FIT_TO_SCREEN_PADDING = 128
export const SNAP_DISTANCE = 5

View file

@ -74,7 +74,7 @@ export class ArrowSession implements Session {
start = () => void null 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 { initialShape } = this
const page = TLDR.getPage(data, data.appState.currentPageId) const page = TLDR.getPage(data, data.appState.currentPageId)

View file

@ -31,7 +31,7 @@ export class DrawSession implements Session {
start = () => void null start = () => void null
update = (data: Data, point: number[], shiftKey: boolean) => { update = (data: Data, point: number[], shiftKey = false, altKey = false, metaKey = false) => {
const { shapeId } = this const { shapeId } = this
// Even if we're not locked yet, we base the future locking direction // Even if we're not locked yet, we base the future locking direction

View file

@ -60,7 +60,7 @@ export class GridSession implements Session {
return clone 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<Record<string, TLDrawShape>> = {} const nextShapes: Patch<Record<string, TLDrawShape>> = {}
const nextPageState: Patch<TLPageState> = {} const nextPageState: Patch<TLPageState> = {}

View file

@ -27,7 +27,7 @@ export class HandleSession implements Session {
start = () => void null 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 { initialShape } = this
const { currentPageId } = data.appState const { currentPageId } = data.appState

View file

@ -23,7 +23,7 @@ export class RotateSession implements Session {
start = () => void null 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 { commonBoundsCenter, initialShapes } = this.snapshot
const pageId = data.appState.currentPageId const pageId = data.appState.currentPageId
@ -32,7 +32,7 @@ export class RotateSession implements Session {
let directionDelta = Vec.angle(commonBoundsCenter, point) - this.initialAngle let directionDelta = Vec.angle(commonBoundsCenter, point) - this.initialAngle
if (isLocked) { if (shiftKey) {
directionDelta = Utils.snapAngleToSegments(directionDelta, 24) // 15 degrees directionDelta = Utils.snapAngleToSegments(directionDelta, 24) // 15 degrees
} }
@ -41,7 +41,7 @@ export class RotateSession implements Session {
const { rotation = 0 } = shape const { rotation = 0 } = shape
let shapeDelta = 0 let shapeDelta = 0
if (isLocked) { if (shiftKey) {
const snappedRotation = Utils.snapAngleToSegments(rotation, 24) const snappedRotation = Utils.snapAngleToSegments(rotation, 24)
shapeDelta = snappedRotation - rotation shapeDelta = snappedRotation - rotation
} }
@ -50,7 +50,7 @@ export class RotateSession implements Session {
shape, shape,
center, center,
commonBoundsCenter, commonBoundsCenter,
isLocked ? directionDelta + shapeDelta : directionDelta shiftKey ? directionDelta + shapeDelta : directionDelta
) )
if (change) { if (change) {

View file

@ -29,7 +29,7 @@ export class TransformSingleSession implements Session {
start = () => void null 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 { transformType } = this
const { initialShapeBounds, initialShape, id } = this.snapshot const { initialShapeBounds, initialShape, id } = this.snapshot

View file

@ -32,7 +32,7 @@ export class TransformSession implements Session {
start = () => void null start = () => void null
// eslint-disable-next-line @typescript-eslint/no-unused-vars // 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 { const {
transformType, transformType,
snapshot: { shapeBounds, initialBounds, isAllAspectRatioLocked }, snapshot: { shapeBounds, initialBounds, isAllAspectRatioLocked },

View file

@ -326,3 +326,13 @@ describe('When creating with a translate session', () => {
expect(tlstate.getShape('rect1')).toBe(undefined) 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')
})

View file

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* 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 { Vec } from '@tldraw/vec'
import { import {
TLDrawShape, TLDrawShape,
@ -11,19 +11,50 @@ import {
ArrowShape, ArrowShape,
GroupShape, GroupShape,
SessionType, SessionType,
ArrowBinding,
} from '~types' } from '~types'
import { SNAP_DISTANCE } from '~state/constants'
import { TLDR } from '~state/tldr' import { TLDR } from '~state/tldr'
import type { Patch } from 'rko' 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 { export class TranslateSession implements Session {
type = SessionType.Translate type = SessionType.Translate
status = TLDrawStatus.Translating status = TLDrawStatus.Translating
delta = [0, 0] delta = [0, 0]
prev = [0, 0] prev = [0, 0]
prevPoint = [0, 0]
speed = 1
origin: number[] origin: number[]
snapshot: TranslateSnapshot snapshot: TranslateSnapshot
isCloning = false isCloning = false
isCreate: boolean isCreate: boolean
cloneInfo: CloneInfo = {
state: 'empty',
}
snapInfo: SnapInfo = {
state: 'empty',
}
snapLines: TLSnapLine[] = []
constructor(data: Data, point: number[], isCreate = false) { constructor(data: Data, point: number[], isCreate = false) {
this.origin = point this.origin = point
@ -34,6 +65,8 @@ export class TranslateSession implements Session {
start = (data: Data) => { start = (data: Data) => {
const { bindingsToDelete } = this.snapshot const { bindingsToDelete } = this.snapshot
this.createSnapInfo(data)
if (bindingsToDelete.length === 0) return data if (bindingsToDelete.length === 0) return data
const nextBindings: Patch<Record<string, TLDrawBinding>> = {} const nextBindings: Patch<Record<string, TLDrawBinding>> = {}
@ -51,16 +84,30 @@ export class TranslateSession implements Session {
} }
} }
update = (data: Data, point: number[], shiftKey: boolean, altKey: boolean) => { update = (data: Data, point: number[], shiftKey = false, altKey = false, metaKey = false) => {
const { selectedIds, initialParentChildren, clones, initialShapes, bindingsToDelete } = const { selectedIds, initialParentChildren, initialShapes, bindingsToDelete } = this.snapshot
this.snapshot
const { currentPageId } = data.appState const { currentPageId } = data.appState
const nextBindings: Patch<Record<string, TLDrawBinding>> = {} const nextBindings: Patch<Record<string, TLDrawBinding>> = {}
const nextShapes: Patch<Record<string, TLDrawShape>> = {} const nextShapes: Patch<Record<string, TLDrawShape>> = {}
const nextPageState: Patch<TLPageState> = {} const nextPageState: Patch<TLPageState> = {}
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 (shiftKey) {
if (Math.abs(delta[0]) < Math.abs(delta[1])) { 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 this.prev = delta
// If cloning... // If cloning...
if (!this.isCreate && altKey) { if (this.isCloning) {
// Not Cloning -> Cloning // 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 this.isCloning = true
// Put back any bindings we deleted // Put back any bindings we deleted
@ -109,7 +212,7 @@ export class TranslateSession implements Session {
}) })
// Add the cloned bindings // Add the cloned bindings
for (const binding of this.snapshot.clonedBindings) { for (const binding of clonedBindings) {
nextBindings[binding.id] = binding nextBindings[binding.id] = binding
} }
@ -117,6 +220,10 @@ export class TranslateSession implements Session {
nextPageState.selectedIds = clones.map((clone) => clone.id) nextPageState.selectedIds = clones.map((clone) => clone.id)
} }
if (this.cloneInfo.state === 'empty') throw Error
const { clones } = this.cloneInfo
// Either way, move the clones // Either way, move the clones
clones.forEach((clone) => { clones.forEach((clone) => {
const current = (nextShapes[clone.id] || const current = (nextShapes[clone.id] ||
@ -126,14 +233,18 @@ export class TranslateSession implements Session {
nextShapes[clone.id] = { nextShapes[clone.id] = {
...nextShapes[clone.id], ...nextShapes[clone.id],
point: Vec.round(Vec.add(current.point, trueDelta)), point: Vec.round(Vec.add(current.point, movement)),
} }
}) })
} else { } else {
// If not cloning... // If not cloning...
// Cloning -> 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 this.isCloning = false
// Delete the bindings // Delete the bindings
@ -159,7 +270,7 @@ export class TranslateSession implements Session {
}) })
// Delete the cloned bindings // Delete the cloned bindings
for (const binding of this.snapshot.clonedBindings) { for (const binding of clonedBindings) {
nextBindings[binding.id] = undefined nextBindings[binding.id] = undefined
} }
@ -176,12 +287,15 @@ export class TranslateSession implements Session {
nextShapes[shape.id] = { nextShapes[shape.id] = {
...nextShapes[shape.id], ...nextShapes[shape.id],
point: Vec.round(Vec.add(current.point, trueDelta)), point: Vec.round(Vec.add(current.point, movement)),
} }
}) })
} }
return { return {
appState: {
snapLines: this.snapLines,
},
document: { document: {
pages: { pages: {
[data.appState.currentPageId]: { [data.appState.currentPageId]: {
@ -197,7 +311,7 @@ export class TranslateSession implements Session {
} }
cancel = (data: Data) => { cancel = (data: Data) => {
const { initialShapes, clones, clonedBindings, bindingsToDelete } = this.snapshot const { initialShapes, bindingsToDelete } = this.snapshot
const nextBindings: Record<string, Partial<TLDrawBinding> | undefined> = {} const nextBindings: Record<string, Partial<TLDrawBinding> | undefined> = {}
const nextShapes: Record<string, Partial<TLDrawShape> | undefined> = {} const nextShapes: Record<string, Partial<TLDrawShape> | undefined> = {}
@ -218,13 +332,19 @@ export class TranslateSession implements Session {
nextPageState.selectedIds = this.snapshot.selectedIds nextPageState.selectedIds = this.snapshot.selectedIds
} }
if (this.cloneInfo.state === 'ready') {
const { clones, clonedBindings } = this.cloneInfo
// Delete clones // Delete clones
clones.forEach((clone) => (nextShapes[clone.id] = undefined)) clones.forEach((clone) => (nextShapes[clone.id] = undefined))
// Delete cloned bindings // Delete cloned bindings
clonedBindings.forEach((binding) => (nextBindings[binding.id] = undefined)) clonedBindings.forEach((binding) => (nextBindings[binding.id] = undefined))
}
return { return {
appState: {
snapLines: [],
},
document: { document: {
pages: { pages: {
[data.appState.currentPageId]: { [data.appState.currentPageId]: {
@ -242,8 +362,7 @@ export class TranslateSession implements Session {
complete(data: Data): TLDrawCommand { complete(data: Data): TLDrawCommand {
const pageId = data.appState.currentPageId const pageId = data.appState.currentPageId
const { initialShapes, initialParentChildren, bindingsToDelete, clones, clonedBindings } = const { initialShapes, initialParentChildren, bindingsToDelete } = this.snapshot
this.snapshot
const beforeBindings: Patch<Record<string, TLDrawBinding>> = {} const beforeBindings: Patch<Record<string, TLDrawBinding>> = {}
const beforeShapes: Patch<Record<string, TLDrawShape>> = {} const beforeShapes: Patch<Record<string, TLDrawShape>> = {}
@ -252,6 +371,13 @@ export class TranslateSession implements Session {
const afterShapes: Patch<Record<string, TLDrawShape>> = {} const afterShapes: Patch<Record<string, TLDrawShape>> = {}
if (this.isCloning) { 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 // Update the clones
clones.forEach((clone) => { clones.forEach((clone) => {
beforeShapes[clone.id] = undefined beforeShapes[clone.id] = undefined
@ -331,6 +457,9 @@ export class TranslateSession implements Session {
return { return {
id: 'translate', id: 'translate',
before: { before: {
appState: {
snapLines: [],
},
document: { document: {
pages: { pages: {
[data.appState.currentPageId]: { [data.appState.currentPageId]: {
@ -346,6 +475,9 @@ export class TranslateSession implements Session {
}, },
}, },
after: { after: {
appState: {
snapLines: [],
},
document: { document: {
pages: { pages: {
[data.appState.currentPageId]: { [data.appState.currentPageId]: {
@ -362,34 +494,37 @@ export class TranslateSession implements Session {
}, },
} }
} }
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types private createSnapInfo = async (data: Data) => {
export function getTranslateSnapshot(data: Data) {
const { currentPageId } = data.appState const { currentPageId } = data.appState
const selectedIds = TLDR.getSelectedIds(data, currentPageId) const page = data.document.pages[currentPageId]
const page = TLDR.getPage(data, currentPageId) const { selectedIds } = data.document.pageStates[currentPageId]
const selectedShapes = selectedIds.flatMap((id) => TLDR.getShape(data, id, currentPageId)) const allBounds: TLBoundsWithCenter[] = []
const otherBounds: TLBoundsWithCenter[] = []
const hasUnlockedShapes = selectedShapes.length > 0 Object.values(page.shapes).forEach((shape) => {
const bounds = Utils.getBoundsWithCenter(TLDR.getBounds(shape))
const shapesToMove: TLDrawShape[] = selectedShapes allBounds.push(bounds)
.filter((shape) => !selectedIds.includes(shape.parentId)) if (!selectedIds.includes(shape.id)) {
.flatMap((shape) => { otherBounds.push(bounds)
return shape.children }
? [shape, ...shape.children!.map((childId) => TLDR.getShape(data, childId, currentPageId))]
: [shape]
}) })
const initialParentChildren: Record<string, string[]> = {} this.snapInfo = {
state: 'ready',
bounds: allBounds,
others: otherBounds,
}
}
Array.from(new Set(shapesToMove.map((s) => s.parentId)).values()) private createCloneInfo = (data: Data) => {
.filter((id) => id !== page.id) // Create clones when as they're needed.
.forEach((id) => { // Consider doing this work in a worker.
const shape = TLDR.getShape(data, id, currentPageId)
initialParentChildren[id] = shape.children! const { currentPageId } = data.appState
}) const page = data.document.pages[currentPageId]
const { selectedIds, shapesToMove, initialParentChildren } = this.snapshot
const cloneMap: Record<string, string> = {} const cloneMap: Record<string, string> = {}
const clonedBindingsMap: Record<string, string> = {} const clonedBindingsMap: Record<string, string> = {}
@ -431,8 +566,6 @@ export function getTranslateSnapshot(data: Data) {
// original shapes that were cloned, not their clones' ids. // original shapes that were cloned, not their clones' ids.
const clonedShapeIds = new Set(Object.keys(cloneMap)) const clonedShapeIds = new Set(Object.keys(cloneMap))
const bindingsToDelete: TLDrawBinding[] = []
// Create cloned bindings for shapes where both to and from shapes are selected // 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) // (if the user clones, then we will create a new binding for the clones)
Object.values(page.bindings) Object.values(page.bindings)
@ -451,8 +584,6 @@ export function getTranslateSnapshot(data: Data) {
clonedBindingsMap[binding.id] = cloneId clonedBindingsMap[binding.id] = cloneId
clonedBindings.push(cloneBinding) clonedBindings.push(cloneBinding)
} else {
bindingsToDelete.push(binding)
} }
} }
}) })
@ -475,18 +606,69 @@ export function getTranslateSnapshot(data: Data) {
} }
}) })
this.cloneInfo = {
state: 'ready',
clones,
clonedBindings,
}
}
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export function getTranslateSnapshot(data: Data) {
const { currentPageId } = data.appState
const selectedIds = TLDR.getSelectedIds(data, currentPageId)
const page = TLDR.getPage(data, currentPageId)
const selectedShapes = selectedIds.flatMap((id) => TLDR.getShape(data, id, currentPageId))
const hasUnlockedShapes = selectedShapes.length > 0
const shapesToMove: TLDrawShape[] = selectedShapes
.filter((shape) => !selectedIds.includes(shape.parentId))
.flatMap((shape) => {
return shape.children
? [shape, ...shape.children!.map((childId) => TLDR.getShape(data, childId, currentPageId))]
: [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<string, string[]> = {}
Array.from(new Set(shapesToMove.map((s) => s.parentId)).values())
.filter((id) => id !== page.id)
.forEach((id) => {
const shape = TLDR.getShape(data, id, currentPageId)
initialParentChildren[id] = shape.children!
})
const commonBounds = Utils.getCommonBounds(shapesToMove.map(TLDR.getBounds))
return { return {
selectedIds, selectedIds,
bindingsToDelete,
hasUnlockedShapes, hasUnlockedShapes,
initialParentChildren, initialParentChildren,
shapesToMove,
bindingsToDelete,
commonBounds,
initialShapes: shapesToMove.map(({ id, point, parentId }) => ({ initialShapes: shapesToMove.map(({ id, point, parentId }) => ({
id, id,
point, point,
parentId, parentId,
})), })),
clones,
clonedBindings,
} }
} }

View file

@ -44,6 +44,7 @@ import { ArgsOfType, getSession } from './session'
import { sample, USER_COLORS } from './utils' import { sample, USER_COLORS } from './utils'
import { createTools, ToolType } from './tool' import { createTools, ToolType } from './tool'
import type { BaseTool } from './tool/BaseTool' import type { BaseTool } from './tool/BaseTool'
import * as constants from './constants'
const uuid = Utils.uniqueId() const uuid = Utils.uniqueId()
@ -1392,7 +1393,10 @@ export class TLDrawState extends StateManager<Data> {
const bounds = Utils.getCommonBounds(shapes.map(TLDR.getBounds)) const bounds = Utils.getCommonBounds(shapes.map(TLDR.getBounds))
let zoom = TLDR.getCameraZoom( 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 = zoom =
@ -1419,7 +1423,10 @@ export class TLDrawState extends StateManager<Data> {
const bounds = TLDR.getSelectedBounds(this.state) const bounds = TLDR.getSelectedBounds(this.state)
let zoom = TLDR.getCameraZoom( 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 = zoom =
@ -1645,10 +1652,13 @@ export class TLDrawState extends StateManager<Data> {
* updateSession. * updateSession.
* @param args The arguments of the current session's update method. * @param args The arguments of the current session's update method.
*/ */
updateSession = (point: number[], shiftKey = false, altKey = false, metaKey = false): this => { updateSession = <T extends Session>(...args: ExceptFirst<Parameters<T['update']>>): this => {
const { session } = this const { session } = this
if (!session) return 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 if (!patch) return this
return this.patchState(patch, `session:${session?.constructor.name}`) return this.patchState(patch, `session:${session?.constructor.name}`)
} }
@ -1713,6 +1723,7 @@ export class TLDrawState extends StateManager<Data> {
// the shape we just created. // the shape we just created.
result.before = { result.before = {
appState: { appState: {
...result.before.appState,
status: TLDrawStatus.Idle, status: TLDrawStatus.Idle,
}, },
document: { document: {
@ -1741,6 +1752,7 @@ export class TLDrawState extends StateManager<Data> {
} }
result.after.appState = { result.after.appState = {
...result.after.appState,
status: TLDrawStatus.Idle, status: TLDrawStatus.Idle,
} }
@ -2373,6 +2385,23 @@ export class TLDrawState extends StateManager<Data> {
return Vec.round([this.bounds.width / 2, this.bounds.height / 2]) 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 version = 10.1
static defaultDocument: TLDrawDocument = { static defaultDocument: TLDrawDocument = {
@ -2420,6 +2449,7 @@ export class TLDrawState extends StateManager<Data> {
isStyleOpen: false, isStyleOpen: false,
isEmptyCanvas: false, isEmptyCanvas: false,
status: TLDrawStatus.Idle, status: TLDrawStatus.Idle,
snapLines: [],
}, },
document: TLDrawState.defaultDocument, document: TLDrawState.defaultDocument,
room: { room: {

View file

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/ban-types */ /* 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 { TLShape, TLShapeUtil, TLHandle } from '@tldraw/core'
import type { TLPage, TLUser, TLPageState } from '@tldraw/core' import type { TLPage, TLUser, TLPageState } from '@tldraw/core'
import type { StoreApi } from 'zustand' import type { StoreApi } from 'zustand'
@ -63,6 +63,7 @@ export interface Data {
isStyleOpen: boolean isStyleOpen: boolean
isEmptyCanvas: boolean isEmptyCanvas: boolean
status: string status: string
snapLines: TLSnapLine[]
} }
document: TLDrawDocument document: TLDrawDocument
room?: { room?: {
@ -105,9 +106,9 @@ export abstract class Session {
abstract update: ( abstract update: (
data: Readonly<Data>, data: Readonly<Data>,
point: number[], point: number[],
shiftKey: boolean, shiftKey?: boolean,
altKey: boolean, altKey?: boolean,
metaKey: boolean metaKey?: boolean
) => TLDrawPatch | undefined ) => TLDrawPatch | undefined
abstract complete: (data: Readonly<Data>) => TLDrawPatch | TLDrawCommand | undefined abstract complete: (data: Readonly<Data>) => TLDrawPatch | TLDrawCommand | undefined
abstract cancel: (data: Readonly<Data>) => TLDrawPatch | undefined abstract cancel: (data: Readonly<Data>) => TLDrawPatch | undefined

View file

@ -39,7 +39,7 @@
"@types/node": "^16.7.10", "@types/node": "^16.7.10",
"@typescript-eslint/eslint-plugin": "^4.30.0", "@typescript-eslint/eslint-plugin": "^4.30.0",
"@typescript-eslint/parser": "^4.30.0", "@typescript-eslint/parser": "^4.30.0",
"esbuild": "^0.12.24", "esbuild": "^0.13.8",
"eslint": "^7.32.0", "eslint": "^7.32.0",
"lerna": "^4.0.0", "lerna": "^4.0.0",
"ts-node": "^10.2.1", "ts-node": "^10.2.1",

118
yarn.lock
View file

@ -6319,10 +6319,113 @@ es6-promisify@^5.0.0:
dependencies: dependencies:
es6-promise "^4.0.3" es6-promise "^4.0.3"
esbuild@^0.12.24, esbuild@^0.12.26: esbuild-android-arm64@0.13.8:
version "0.12.28" version "0.13.8"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.12.28.tgz#84da0d2a0d0dee181281545271e0d65cf6fab1ef" resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.13.8.tgz#c20e875c3c98164b1ffba9b28637bdf96f5e9e7c"
integrity sha512-pZ0FrWZXlvQOATlp14lRSk1N9GkeJ3vLIwOcUoo3ICQn9WNR4rWoNi81pbn6sC1iYUy7QPqNzI3+AEzokwyVcA== 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: escalade@^3.1.1:
version "3.1.1" version "3.1.1"
@ -7616,11 +7719,16 @@ idb-keyval@^5.1.3:
dependencies: dependencies:
safari-14-idb-fix "^1.0.6" safari-14-idb-fix "^1.0.6"
idb@^6.0.0, idb@^6.1.2: idb@^6.0.0:
version "6.1.3" version "6.1.3"
resolved "https://registry.yarnpkg.com/idb/-/idb-6.1.3.tgz#e6cd3b9c38f5c696a82a4b435754f3873c5a7891" resolved "https://registry.yarnpkg.com/idb/-/idb-6.1.3.tgz#e6cd3b9c38f5c696a82a4b435754f3873c5a7891"
integrity sha512-oIRDpVcs5KXpI1hRnTJUwkY63RB/7iqu9nSNuzXN8TLHjs7oO20IoPFbBTsqxIL5IjzIUDi+FXlVcK4zm26J8A== 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: ieee754@^1.1.4, ieee754@^1.2.1:
version "1.2.1" version "1.2.1"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"