[feature] Add grids (#344)

* [feature] grids

* Shows relative grids at different zoom levels

* Update colors

* Restores vec and intersect to monorepo, changes vec.round to vec.toFixed, adds vec.snap

* Snapping in translate and transforms, fix shortcut

* fix bugs in build

* use grid size for nudge too

* update scripts

* Update grid.tsx

* Update grid.tsx

* Fixed!

* Update grid.tsx

* Fix package imports

* Update Editor.tsx

* Improve tsconfigs, imports

* Fix tiny arrow bugs, snap starting points to grid

* Update tsconfig.base.json

* Update shape-styles.ts

* Fix example tsconfig

* Fix translate type error

* Fix types, paths

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
Christian Petersen 2021-11-26 15:14:10 +00:00 committed by GitHub
parent 3de6ef334a
commit e2814943e9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
105 changed files with 4795 additions and 300 deletions

13
.vscode/tasks.json vendored
View file

@ -1,13 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Check for type errors",
"type": "typescript",
"tsconfig": "tsconfig.json",
"option": "watch",
"problemMatcher": ["$tsc-watch"],
"group": "build"
}
]
}

View file

@ -1,7 +1,7 @@
import { Tldraw, TldrawApp, useFileSystem } from '@tldraw/tldraw'
import * as gtag from '-utils/gtag'
import React from 'react'
import { useAccountHandlers } from '-hooks/useAccountHandlers'
import * as gtag from 'utils/gtag'
import { Tldraw, TldrawApp, useFileSystem } from '@tldraw/tldraw'
import { useAccountHandlers } from 'hooks/useAccountHandlers'
declare const window: Window & { app: TldrawApp }
@ -19,9 +19,9 @@ export default function Editor({ id = 'home', isUser = false, isSponsor = false
// Send events to gtag as actions.
const handlePersist = React.useCallback((_app: TldrawApp, reason?: string) => {
gtag.event({
action: reason,
action: reason ?? '',
category: 'editor',
label: reason || 'persist',
label: reason ?? 'persist',
value: 0,
})
}, [])

View file

@ -3,9 +3,9 @@ import * as React from 'react'
import { Tldraw, TldrawApp, useFileSystem } from '@tldraw/tldraw'
import { createClient } from '@liveblocks/client'
import { LiveblocksProvider, RoomProvider } from '@liveblocks/react'
import { useAccountHandlers } from '-hooks/useAccountHandlers'
import { styled } from '-styles'
import { useMultiplayerState } from '-hooks/useMultiplayerState'
import { useAccountHandlers } from 'hooks/useAccountHandlers'
import { styled } from 'styles'
import { useMultiplayerState } from 'hooks/useMultiplayerState'
const client = createClient({
publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY || '',

View file

@ -129,10 +129,17 @@ export function useMultiplayerState(roomId: string) {
page: { shapes, bindings },
},
},
} = doc.toObject()
} = doc.toObject() as { document: TDDocument }
Object.values(shapes).forEach((shape) => lShapes.set(shape.id, shape))
Object.values(bindings).forEach((binding) => lBindings.set(binding.id, binding))
for (const key in shapes) {
const shape = shapes[key]
lShapes.set(shape.id, shape)
}
for (const key in bindings) {
const binding = bindings[key]
lBindings.set(binding.id, binding)
}
}
}
@ -175,21 +182,23 @@ export function useMultiplayerState(roomId: string) {
if (!(lShapes && lBindings)) return
Object.entries(shapes).forEach(([id, shape]) => {
for (const id in shapes) {
const shape = shapes[id]
if (!shape) {
lShapes.delete(id)
} else {
lShapes.set(shape.id, shape)
}
})
}
Object.entries(bindings).forEach(([id, binding]) => {
for (const id in bindings) {
const binding = bindings[id]
if (!binding) {
lBindings.delete(id)
} else {
lBindings.set(binding.id, binding)
}
})
}
rExpectingUpdate.current = true
})

View file

@ -25,6 +25,7 @@
"@sentry/react": "^6.13.2",
"@sentry/tracing": "^6.13.2",
"@stitches/react": "^1.2.5",
"@tldraw/core": "^1.1.4",
"@tldraw/tldraw": "^1.1.4",
"@types/next-auth": "^3.15.0",
"next": "^12.0.1",
@ -42,7 +43,7 @@
"cors": "^2.8.5",
"eslint": "7.32.0",
"eslint-config-next": "11.1.2",
"typescript": "^4.4.2"
"typescript": "^4.5.2"
},
"gitHead": "838fabdbff1a66d4d7ee8aa5c5d117bc55acbff2"
}

View file

@ -1,7 +1,9 @@
import '../styles/globals.css'
import { init } from '-utils/sentry'
import Head from 'next/head'
import useGtag from '-utils/useGtag'
import useGtag from 'utils/useGtag'
import { init } from 'utils/sentry'
import type { AppProps } from 'next/app'
import type React from 'react'
init()
@ -10,7 +12,7 @@ const APP_DESCRIPTION = 'A tiny little drawing app.'
const APP_URL = 'https://tldraw.com'
const IMAGE = 'https://tldraw.com/social-image.png'
function MyApp({ Component, pageProps }) {
function MyApp({ Component, pageProps }: AppProps) {
useGtag()
return (

View file

@ -1,29 +1,19 @@
import NextDocument, { Html, Head, Main, NextScript, DocumentContext } from 'next/document'
import { getCssText } from '../styles'
import { GA_TRACKING_ID } from '../utils/gtag'
import { getCssText } from 'styles'
import { GA_TRACKING_ID } from 'utils/gtag'
class MyDocument extends NextDocument {
static async getInitialProps(ctx: DocumentContext): Promise<{
styles: JSX.Element
html: string
head?: JSX.Element[]
}> {
try {
const initialProps = await NextDocument.getInitialProps(ctx)
static async getInitialProps(ctx: DocumentContext) {
const initialProps = await NextDocument.getInitialProps(ctx)
return {
...initialProps,
styles: (
<>
{initialProps.styles}
<style id="stitches" dangerouslySetInnerHTML={{ __html: getCssText() }} />
</>
),
}
} catch (e) {
console.error(e.message)
} finally {
null
return {
...initialProps,
styles: (
<>
{initialProps.styles}
<style id="stitches" dangerouslySetInnerHTML={{ __html: getCssText() }} />
</>
),
}
}

View file

@ -1,5 +1,5 @@
import { isSponsoringMe } from '-utils/isSponsoringMe'
import { NextApiHandler, NextApiRequest, NextApiResponse } from 'next'
import { isSponsoringMe } from 'utils/isSponsoringMe'
import type { NextApiHandler, NextApiRequest, NextApiResponse } from 'next'
import NextAuth from 'next-auth'
import Providers from 'next-auth/providers'
@ -20,13 +20,15 @@ export default function Auth(
return baseUrl
},
async signIn(user, account, profile: { login?: string }) {
const canLogin = await isSponsoringMe(profile?.login)
if (profile?.login) {
const canLogin = await isSponsoringMe(profile.login)
if (canLogin) {
return canLogin
} else {
return '/sponsorware'
if (canLogin) {
return canLogin
}
}
return '/'
},
},
})

View file

@ -1,4 +1,4 @@
import { NextApiRequest, NextApiResponse } from 'next'
import type { NextApiRequest, NextApiResponse } from 'next'
const AV_SIZE = 32
const PADDING = 4

View file

@ -1,9 +1,9 @@
import dynamic from 'next/dynamic'
import { GetServerSideProps } from 'next'
import type { GetServerSideProps } from 'next'
import { getSession } from 'next-auth/client'
import Head from 'next/head'
const Editor = dynamic(() => import('-components/Editor'), { ssr: false })
const Editor = dynamic(() => import('components/Editor'), { ssr: false })
interface PageProps {
isUser: boolean

View file

@ -2,7 +2,7 @@ import * as React from 'react'
import type { GetServerSideProps } from 'next'
import { getSession } from 'next-auth/client'
import dynamic from 'next/dynamic'
const MultiplayerEditor = dynamic(() => import('-components/MultiplayerEditor'), { ssr: false })
const MultiplayerEditor = dynamic(() => import('components/MultiplayerEditor'), { ssr: false })
interface RoomProps {
id: string

View file

@ -1,6 +1,6 @@
import { styled } from 'styles'
import { getSession, signin, signout, useSession } from 'next-auth/client'
import { GetServerSideProps } from 'next'
import type { GetServerSideProps } from 'next'
import Link from 'next/link'
import React from 'react'
import Head from 'next/head'

View file

@ -1,28 +1,37 @@
{
"compilerOptions": {
"composite": true,
"composite": false,
"incremental": false,
"resolveJsonModule": true,
"target": "es6",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": ["dom", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"emitDeclarationOnly": false,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"isolatedModules": true,
"jsx": "preserve",
"baseUrl": ".",
"rootDir": ".",
"baseUrl": ".",
"paths": {
"-*": ["./*"],
"@tldraw/core": ["../../packages/core"],
"@tldraw/tldraw": ["../../packages/tldraw"]
},
"incremental": true,
"resolveJsonModule": true
"*": ["./*"],
"@tldraw/tldraw": ["./packages/tldraw"],
"@tldraw/core": ["./packages/core"],
"@tldraw/intersect": ["./packages/intersect"],
"@tldraw/vec": ["./packages/vec"]
}
},
"references": [
{ "path": "../../packages/vec" },
{ "path": "../../packages/intersect" },
{ "path": "../../packages/core" },
{ "path": "../../packages/tldraw" }
],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

View file

@ -1,6 +1 @@
import { TDDocument } from '@tldraw/tldraw'
import { LiveObject } from '@liveblocks/client'
export interface TldrawStorage {
doc: LiveObject<{ uuid: string; document: TDDocument }>
}
export {}

View file

@ -13,11 +13,11 @@ export function init(): void {
integrations.push(
new RewriteFrames({
iteratee: (frame) => {
frame.filename = frame.filename.replace(
process.env.NEXT_PUBLIC_SENTRY_SERVER_ROOT_DIR,
frame.filename = frame?.filename?.replace(
process.env.NEXT_PUBLIC_SENTRY_SERVER_ROOT_DIR as string,
'app:///'
)
frame.filename = frame.filename.replace('.next', '_next')
frame.filename = frame?.filename?.replace('.next', '_next')
return frame
},
})

View file

@ -18,9 +18,9 @@
"devDependencies": {
"@state-designer/react": "3.0.0",
"@stitches/react": "^1.2.5",
"@tldraw/core": "^1.1.3",
"@tldraw/intersect": "latest",
"@tldraw/vec": "latest",
"@tldraw/core": "^1.1.4",
"@tldraw/intersect": "^1.1.4",
"@tldraw/vec": "^1.1.4",
"@types/node": "^14.14.35",
"@types/react": "^16.9.55",
"@types/react-dom": "^16.9.9",
@ -40,4 +40,4 @@
"typescript": "4.2.3"
},
"gitHead": "a7dac0f83ad998e205c2aab58182cb4ba4e099a6"
}
}

View file

@ -8,6 +8,8 @@ export abstract class CustomShapeUtil<
canBind = false
hideBounds = false
abstract getCenter: (shape: T) => number[]
abstract getShape: (shape: Partial<T>) => T

View file

@ -8,6 +8,6 @@ export const pinchCamera: Action = (data, payload: TLPointerInfo) => {
const nextPoint = Vec.sub(camera.point, Vec.div(payload.delta, camera.zoom))
const p0 = Vec.sub(Vec.div(payload.point, camera.zoom), nextPoint)
const p1 = Vec.sub(Vec.div(payload.point, nextZoom), nextPoint)
data.pageState.camera.point = Vec.round(Vec.add(nextPoint, Vec.sub(p1, p0)))
data.pageState.camera.point = Vec.toFixed(Vec.add(nextPoint, Vec.sub(p1, p0)))
data.pageState.camera.zoom = nextZoom
}

View file

@ -10,7 +10,7 @@ export const zoomIn: Action = (data) => {
const center = [mutables.rendererBounds.width / 2, mutables.rendererBounds.height / 2]
const p0 = Vec.sub(Vec.div(center, camera.zoom), center)
const p1 = Vec.sub(Vec.div(center, zoom), center)
const point = Vec.round(Vec.add(camera.point, Vec.sub(p1, p0)))
const point = Vec.toFixed(Vec.add(camera.point, Vec.sub(p1, p0)))
data.pageState.camera.zoom = zoom
data.pageState.camera.point = point

View file

@ -10,7 +10,7 @@ export const zoomOut: Action = (data) => {
const center = [mutables.rendererBounds.width / 2, mutables.rendererBounds.height / 2]
const p0 = Vec.sub(Vec.div(center, camera.zoom), center)
const p1 = Vec.sub(Vec.div(center, zoom), center)
const point = Vec.round(Vec.add(camera.point, Vec.sub(p1, p0)))
const point = Vec.toFixed(Vec.add(camera.point, Vec.sub(p1, p0)))
data.pageState.camera.zoom = zoom
data.pageState.camera.point = point

View file

@ -38,5 +38,5 @@ export function getZoomFitCamera(
export function getZoomedCameraPoint(nextZoom: number, center: number[], pageState: TLPageState) {
const p0 = Vec.sub(Vec.div(center, pageState.camera.zoom), pageState.camera.point)
const p1 = Vec.sub(Vec.div(center, nextZoom), pageState.camera.point)
return Vec.round(Vec.add(pageState.camera.point, Vec.sub(p1, p0)))
return Vec.toFixed(Vec.add(pageState.camera.point, Vec.sub(p1, p0)))
}

View file

@ -4,10 +4,11 @@
"exclude": ["node_modules", "dist", "docs"],
"compilerOptions": {
"outDir": "./dist/types",
"baseUrl": ".",
"rootDir": "src",
"baseUrl": "src",
"emitDeclarationOnly": false,
"paths": {
"*": ["src/*"],
"@tldraw/core": ["../../packages/core"]
}
},

View file

@ -1,5 +1,5 @@
{
"version": "1.1.3",
"version": "1.1.4",
"name": "@tldraw/core-example-simple",
"description": "A simple example project for @tldraw/core.",
"author": "@steveruizok",
@ -15,8 +15,8 @@
},
"files": [],
"devDependencies": {
"@tldraw/core": "^1.1.3",
"@tldraw/vec": "^0.0.130",
"@tldraw/core": "^1.1.4",
"@tldraw/vec": "^1.1.4",
"@types/node": "^14.14.35",
"@types/react": "^16.9.55",
"@types/react-dom": "^16.9.9",
@ -30,4 +30,4 @@
"typescript": "4.2.3"
},
"gitHead": "a7dac0f83ad998e205c2aab58182cb4ba4e099a6"
}
}

View file

@ -4,10 +4,11 @@
"exclude": ["node_modules", "dist", "docs"],
"compilerOptions": {
"outDir": "./dist/types",
"baseUrl": ".",
"rootDir": "src",
"baseUrl": "src",
"emitDeclarationOnly": false,
"paths": {
"*": ["src/*"],
"@tldraw/core": ["../packages/core"]
}
},

View file

@ -4,11 +4,11 @@
"exclude": ["node_modules", "dist", "docs"],
"compilerOptions": {
"outDir": "./dist/types",
"baseUrl": ".",
"rootDir": "src",
"baseUrl": "src",
"emitDeclarationOnly": false,
"paths": {
"+*": ["./*"],
"~*": ["./src/*"],
"@tldraw/tldraw": ["../../packages/tldraw"]
}
},

View file

@ -9,8 +9,10 @@
},
"license": "MIT",
"workspaces": [
"packages/tldraw",
"packages/vec",
"packages/intersect",
"packages/core",
"packages/tldraw",
"apps/www",
"apps/electron",
"apps/vscode/editor",
@ -38,7 +40,7 @@
"test:watch": "lerna run test:watch --stream",
"docs": "lerna run typedoc",
"docs:watch": "lerna run typedoc --watch",
"postinstall": "husky install & yarn build:packages"
"postinstall": "husky install"
},
"devDependencies": {
"@swc-node/jest": "^1.3.3",
@ -62,7 +64,7 @@
"resize-observer-polyfill": "^1.5.1",
"tslib": "^2.3.0",
"typedoc": "^0.22.3",
"typescript": "^4.4.2"
"typescript": "^4.5.2"
},
"husky": {
"hooks": {
@ -73,4 +75,4 @@
"lint-staged": {
"*": "fix:style && eslint"
}
}
}

View file

@ -1,5 +1,5 @@
{
"version": "1.1.3",
"version": "1.1.4",
"name": "@tldraw/core",
"description": "The tldraw core renderer and utilities.",
"author": "@steveruizok",
@ -37,8 +37,8 @@
"test:watch": "jest --watchAll"
},
"dependencies": {
"@tldraw/intersect": "latest",
"@tldraw/vec": "latest",
"@tldraw/intersect": "^1.1.4",
"@tldraw/vec": "^1.1.4",
"@use-gesture/react": "^10.1.3"
},
"peerDependencies": {
@ -81,4 +81,4 @@
"\\~(.*)": "<rootDir>/src/$1"
}
}
}
}

View file

@ -1,8 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as React from 'react'
import Utils from '../utils'
import { intersectPolylineBounds } from '@tldraw/intersect'
import type { TLBounds, TLComponentProps, TLForwardedRef, TLShape, TLUser } from '../types'
import { intersectPolylineBounds } from '@tldraw/intersect'
export abstract class TLShapeUtil<T extends TLShape, E extends Element = any, M = any> {
refMap = new Map<string, React.RefObject<E>>()

View file

@ -18,6 +18,7 @@ import { useResizeObserver } from '~hooks/useResizeObserver'
import { inputs } from '~inputs'
import { UsersIndicators } from '~components/users-indicators'
import { SnapLines } from '~components/snap-lines/snap-lines'
import { Grid } from '~components/grid'
import { Overlay } from '~components/overlay'
function resetError() {
@ -28,6 +29,7 @@ interface CanvasProps<T extends TLShape, M extends Record<string, unknown>> {
page: TLPage<T, TLBinding>
pageState: TLPageState
snapLines?: TLSnapLine[]
grid?: number
users?: TLUsers<T>
userId?: string
hideBounds: boolean
@ -37,6 +39,7 @@ interface CanvasProps<T extends TLShape, M extends Record<string, unknown>> {
hideCloneHandles: boolean
hideResizeHandles: boolean
hideRotateHandle: boolean
hideGrid: boolean
externalContainerRef?: React.RefObject<HTMLElement>
meta?: M
id?: string
@ -48,6 +51,7 @@ export function Canvas<T extends TLShape, M extends Record<string, unknown>>({
page,
pageState,
snapLines,
grid,
users,
userId,
meta,
@ -59,6 +63,7 @@ export function Canvas<T extends TLShape, M extends Record<string, unknown>>({
hideCloneHandles,
hideResizeHandles,
hideRotateHandle,
hideGrid,
onBoundsChange,
}: CanvasProps<T, M>): JSX.Element {
const rCanvas = React.useRef<HTMLDivElement>(null)
@ -85,6 +90,7 @@ export function Canvas<T extends TLShape, M extends Record<string, unknown>>({
<div id={id} className="tl-container" ref={rContainer}>
<div id="canvas" className="tl-absolute tl-canvas" ref={rCanvas} {...events}>
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={resetError}>
{!hideGrid && grid && <Grid grid={grid} camera={pageState.camera} />}
<div ref={rLayer} className="tl-absolute tl-layer">
<Page
page={page}

View file

@ -0,0 +1,42 @@
import * as React from 'react'
import type { TLPageState } from '~types'
import Utils from '~utils'
const STEPS = [
[-1, 0.15, 64],
[0.05, 0.375, 16],
[0.15, 1, 4],
[0.7, 2.5, 1],
]
export function Grid({ grid, camera }: { camera: TLPageState['camera']; grid: number }) {
return (
<svg className="tl-grid" version="1.1" xmlns="http://www.w3.org/2000/svg">
<defs>
{STEPS.map(([min, mid, size], i) => {
const s = size * grid * camera.zoom
const xo = camera.point[0] * camera.zoom
const yo = camera.point[1] * camera.zoom
const gxo = xo > 0 ? xo % s : s + (xo % s)
const gyo = yo > 0 ? yo % s : s + (yo % s)
const opacity = camera.zoom < mid ? Utils.modulate(camera.zoom, [min, mid], [0, 1]) : 1
return (
<pattern
key={`grid-pattern-${i}`}
id={`grid-${i}`}
width={s}
height={s}
patternUnits="userSpaceOnUse"
>
<circle className={`tl-grid-dot`} cx={gxo} cy={gyo} r={1} opacity={opacity} />
</pattern>
)
})}
</defs>
{STEPS.map((_, i) => (
<rect key={`grid-rect-${i}`} width="100%" height="100%" fill={`url(#grid-${i})`} />
))}
</svg>
)
}

View file

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

View file

@ -86,6 +86,14 @@ export interface RendererProps<T extends TLShape, M = any> extends Partial<TLCal
* hovered objects,
*/
hideIndicators?: boolean
/**
* (optional) When true, the renderer will not show the grid.
*/
hideGrid?: boolean
/**
* (optional) The size of the grid step.
*/
grid?: number
/**
* (optional) A callback that receives the renderer's inputs manager.
*/
@ -114,6 +122,7 @@ export function Renderer<T extends TLShape, M extends Record<string, unknown>>({
theme,
meta,
snapLines,
grid,
containerRef,
hideHandles = false,
hideIndicators = false,
@ -122,6 +131,7 @@ export function Renderer<T extends TLShape, M extends Record<string, unknown>>({
hideResizeHandles = false,
hideRotateHandles = false,
hideBounds = false,
hideGrid = true,
...rest
}: RendererProps<T, M>): JSX.Element {
useTLTheme(theme, '#' + id)
@ -164,6 +174,7 @@ export function Renderer<T extends TLShape, M extends Record<string, unknown>>({
page={page}
pageState={pageState}
snapLines={snapLines}
grid={grid}
users={users}
userId={userId}
externalContainerRef={containerRef}
@ -174,6 +185,7 @@ export function Renderer<T extends TLShape, M extends Record<string, unknown>>({
hideBindingHandles={hideBindingHandles}
hideRotateHandle={hideRotateHandles}
hideResizeHandles={hideResizeHandles}
hideGrid={hideGrid}
onBoundsChange={onBoundsChange}
meta={meta}
/>

View file

@ -73,6 +73,7 @@ const defaultTheme: TLTheme = {
selectFill: 'rgba(65, 132, 244, 0.05)',
background: 'rgb(248, 249, 250)',
foreground: 'rgb(51, 51, 51)',
grid: 'rgba(144, 144, 144, 1)',
}
const tlcss = css`
@ -142,6 +143,15 @@ const tlcss = css`
pointer-events: none;
}
.tl-grid {
position: absolute;
width: 100%;
height: 100%;
touch-action: none;
pointer-events: none;
user-select: none;
}
.tl-snap-line {
stroke: var(--tl-accent);
stroke-width: calc(1px * var(--tl-scale));
@ -394,6 +404,10 @@ const tlcss = css`
stroke: var(--tl-selectStroke);
stroke-width: calc(2px * var(--tl-scale));
}
.tl-grid-dot {
fill: var(--tl-grid);
}
`
export function useTLTheme(theme?: Partial<TLTheme>, selector?: string) {

View file

@ -360,7 +360,7 @@ export class Inputs {
target: 'pinch',
origin,
delta: delta,
point: Vec.sub(Vec.round(point), [this.bounds.minX, this.bounds.minY]),
point: Vec.sub(Vec.toFixed(point), [this.bounds.minX, this.bounds.minY]),
pressure: 0.5,
shiftKey,
ctrlKey,

View file

@ -108,6 +108,7 @@ export interface TLTheme {
selectStroke?: string
background?: string
foreground?: string
grid?: string
}
export type TLWheelEventHandler = (

View file

@ -1089,6 +1089,26 @@ export class Utils {
return this.translateBounds(bounds, [dx, dy])
}
/**
* Snap a bounding box to a grid size.
* @param bounds
* @param gridSize
*/
static snapBoundsToGrid(bounds: TLBounds, gridSize: number): TLBounds {
const minX = Math.round(bounds.minX / gridSize) * gridSize
const minY = Math.round(bounds.minY / gridSize) * gridSize
const maxX = Math.round(bounds.maxX / gridSize) * gridSize
const maxY = Math.round(bounds.maxY / gridSize) * gridSize
return {
minX,
minY,
maxX,
maxY,
width: Math.max(1, maxX - minX),
height: Math.max(1, maxY - minY),
}
}
/**
* Move a bounding box without recalculating it.
* @param bounds
@ -1509,12 +1529,10 @@ left past the initial left edge) then swap points on that axis.
(isFlippedX
? initialBounds.maxX - initialShapeBounds.maxX
: initialShapeBounds.minX - initialBounds.minX) / initialBounds.width
const ny =
(isFlippedY
? initialBounds.maxY - initialShapeBounds.maxY
: initialShapeBounds.minY - initialBounds.minY) / initialBounds.height
const nw = initialShapeBounds.width / initialBounds.width
const nh = initialShapeBounds.height / initialBounds.height
@ -1562,7 +1580,7 @@ left past the initial left edge) then swap points on that axis.
* Get a bounding box with a midX and midY.
* @param bounds
*/
static getBoundsWithCenter(bounds: TLBounds): TLBounds & { midX: number; midY: number } {
static getBoundsWithCenter(bounds: TLBounds): TLBoundsWithCenter {
const center = Utils.getBoundsCenter(bounds)
return {
...bounds,

View file

@ -13,8 +13,9 @@
"compilerOptions": {
"composite": false,
"incremental": false,
"declarationMap": false,
"sourceMap": false,
"emitDeclarationOnly": true
}
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"references": [{ "path": "../vec" }, { "path": "../intersect" }]
}

View file

@ -1,15 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"],
"exclude": ["node_modules", "dist", "docs"],
"compilerOptions": {
"outDir": "./dist/types",
"rootDir": "src",
"baseUrl": "src",
"baseUrl": ".",
"paths": {
"~*": ["./*"]
"~*": ["./src/*"]
}
},
"references": [{ "path": "../vec" }, { "path": "../intersect" }],
"typedocOptions": {
"entryPoints": ["src/index.ts"],
"out": "docs"

View file

@ -0,0 +1,9 @@
# Changelog
## 0.1.4
- Fixes bug in `polyline`, adds `polygon` intersections.
## 0.1.0
- Hello world.

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Stephen Ruiz Ltd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,477 @@
<div style="text-align: center; transform: scale(.5);">
<img src="card-repo.png"/>
</div>
# @tldraw/core
This package contains the `Renderer` and core utilities used by [tldraw](https://tldraw.com).
You can use this package to build projects like [tldraw](https://tldraw.com), where React components are rendered on a canvas user interface. Check out the [advanced example](https://core-steveruiz.vercel.app/).
💕 Love this library? Consider [becoming a sponsor](https://github.com/sponsors/steveruizok?frequency=recurring&sponsor=steveruizok).
## Installation
Use your package manager of choice to install `@tldraw/core` and its peer dependencies.
```bash
yarn add @tldraw/core
# or
npm i @tldraw/core
```
## Examples
There are two examples in this repository.
The **simple** example in the `example` folder shows a minimal use of the library. It does not do much but this should be a good reference for the API without too much else built on top.
The **advanced** example in the `example-advanced` folder shows a more realistic use of the library. (Try it [here](https://core-steveruiz.vercel.app/)). While the fundamental patterns are the same, this example contains features such as: panning, pinching, and zooming the camera; creating, cloning, resizing, and deleting shapes; keyboard shortcuts, brush-selection; shape-snapping; undo, redo; and more. Much of the code in the advanced example comes from the [@tldraw/tldraw](https://tldraw.com) codebase.
If you're working on an app that uses this library, I recommend referring back to the advanced example for tips on how you might implement these features for your own project.
## Usage
Import the `Renderer` React component and pass it the required props.
```tsx
import * as React from "react"
import { Renderer, TLShape, TLShapeUtil, Vec } from '@tldraw/core'
import { BoxShape, BoxUtil } from "./shapes/box"
const shapeUtils = { box: new BoxUtil() }
function App() {
const [page, setPage] = React.useState({
id: "page"
shapes: {
"box1": {
id: 'box1',
type: 'box',
parentId: 'page',
childIndex: 0,
point: [0, 0],
size: [100, 100],
rotation: 0,
}
},
bindings: {}
})
const [pageState, setPageState] = React.useState({
id: "page",
selectedIds: [],
camera: {
point: [0,0],
zoom: 1
}
})
return (<Renderer
page={page}
pageState={pageState}
shapeUtils={shapeUtils}
/>)
}
```
## Documentation
### `Renderer`
To avoid unnecessary renders, be sure to pass "stable" values as props to the `Renderer`. Either define these values outside of the parent component, or place them in React state, or memoize them with `React.useMemo`.
| Prop | Type | Description |
| ------------ | ------------------------------- | ---------------------------------------------- |
| `page` | [`TLPage`](#tlpage) | The current page object. |
| `pageState` | [`TLPageState`](#tlpagestate) | The current page's state. |
| `shapeUtils` | [`TLShapeUtils`](#tlshapeutils) | The shape utilities used to render the shapes. |
In addition to these required props, the Renderer accents many other **optional** props.
| Property | Type | Description |
| -------------------- | ----------------------------- | ----------------------------------------------------------------- |
| `containerRef` | `React.MutableRefObject` | A React ref for the container, where CSS variables will be added. |
| `theme` | `object` | An object with overrides for the Renderer's default colors. |
| `hideBounds` | `boolean` | Do not show the bounding box for selected shapes. |
| `hideHandles` | `boolean` | Do not show handles for shapes with handles. |
| `hideBindingHandles` | `boolean` | Do not show binding controls for selected shapes with bindings. |
| `hideResizeHandles` | `boolean` | Do not show resize handles for selected shapes. |
| `hideRotateHandles` | `boolean` | Do not show rotate handles for selected shapes. |
| `snapLines` | [`TLSnapLine`](#tlsnapline)[] | An array of "snap" lines. |
| `users` | `object` | A table of [`TLUser`](#tluser)s. |
| `userId` | `object` | The current user's [`TLUser`](#tluser) id. |
The theme object accepts valid CSS colors for the following properties:
| Property | Description |
| -------------- | ---------------------------------------------------- |
| `foreground` | The primary (usually "text") color |
| `background` | The default page's background color |
| `brushFill` | The fill color of the brush selection box |
| `brushStroke` | The stroke color of the brush selection box |
| `selectFill` | The fill color of the selection bounds |
| `selectStroke` | The stroke color of the selection bounds and handles |
The Renderer also accepts many (optional) event callbacks.
| Prop | Description |
| --------------------------- | ----------------------------------------------------------- |
| `onPan` | Panned with the mouse wheel |
| `onZoom` | Zoomed with the mouse wheel |
| `onPinchStart` | Began a two-pointer pinch |
| `onPinch` | Moved their pointers during a pinch |
| `onPinchEnd` | Stopped a two-pointer pinch |
| `onPointerDown` | Started pointing |
| `onPointerMove` | Moved their pointer |
| `onPointerUp` | Ended a point |
| `onPointCanvas` | Pointed the canvas |
| `onDoubleClickCanvas` | Double-pointed the canvas |
| `onRightPointCanvas` | Right-pointed the canvas |
| `onDragCanvas` | Dragged the canvas |
| `onReleaseCanvas` | Stopped pointing the canvas |
| `onHoverShape` | Moved their pointer onto a shape |
| `onUnhoverShape` | Moved their pointer off of a shape |
| `onPointShape` | Pointed a shape |
| `onDoubleClickShape` | Double-pointed a shape |
| `onRightPointShape` | Right-pointed a shape |
| `onDragShape` | Dragged a shape |
| `onReleaseShape` | Stopped pointing a shape |
| `onHoverHandle` | Moved their pointer onto a shape handle |
| `onUnhoverHandle` | Moved their pointer off of a shape handle |
| `onPointHandle` | Pointed a shape handle |
| `onDoubleClickHandle` | Double-pointed a shape handle |
| `onRightPointHandle` | Right-pointed a shape handle |
| `onDragHandle` | Dragged a shape handle |
| `onReleaseHandle` | Stopped pointing shape handle |
| `onHoverBounds` | Moved their pointer onto the selection bounds |
| `onUnhoverBounds` | Moved their pointer off of the selection bounds |
| `onPointBounds` | Pointed the selection bounds |
| `onDoubleClickBounds` | Double-pointed the selection bounds |
| `onRightPointBounds` | Right-pointed the selection bounds |
| `onDragBounds` | Dragged the selection bounds |
| `onReleaseBounds` | Stopped the selection bounds |
| `onHoverBoundsHandle` | Moved their pointer onto a selection bounds handle |
| `onUnhoverBoundsHandle` | Moved their pointer off of a selection bounds handle |
| `onPointBoundsHandle` | Pointed a selection bounds handle |
| `onDoubleClickBoundsHandle` | Double-pointed a selection bounds handle |
| `onRightPointBoundsHandle` | Right-pointed a selection bounds handle |
| `onDragBoundsHandle` | Dragged a selection bounds handle |
| `onReleaseBoundsHandle` | Stopped a selection bounds handle |
| `onShapeClone` | Clicked on a shape's clone handle |
| `onShapeChange` | A shape's component prompted a change |
| `onShapeBlur` | A shape's component was prompted a blur |
| `onRenderCountChange` | The number of rendered shapes changed |
| `onBoundsChange` | The Renderer's screen bounding box of the component changed |
| `onError` | The Renderer encountered an error |
The `@tldraw/core` library provides types for most of the event handlers:
| Type |
| ---------------------------- |
| `TLPinchEventHandler` |
| `TLPointerEventHandler` |
| `TLCanvasEventHandler` |
| `TLBoundsEventHandler` |
| `TLBoundsHandleEventHandler` |
| `TLShapeChangeHandler` |
| `TLShapeBlurHandler` |
| `TLShapeCloneHandler` |
### `TLPage`
An object describing the current page. It contains:
| Property | Type | Description |
| ----------------- | --------------------------- | --------------------------------------------------------------------------- |
| `id` | `string` | A unique id for the page. |
| `shapes` | [`TLShape{}`](#tlshape) | A table of shapes. |
| `bindings` | [`TLBinding{}`](#tlbinding) | A table of bindings. |
| `backgroundColor` | `string` | (optional) The page's background fill color. Will also overwrite the theme. |
### `TLPageState`
An object describing the current page. It contains:
| Property | Type | Description |
| -------------- | ---------- | --------------------------------------------------- |
| `id` | `string` | The corresponding page's id |
| `selectedIds` | `string[]` | An array of selected shape ids |
| `camera` | `object` | An object describing the camera state |
| `camera.point` | `number[]` | The camera's `[x, y]` coordinates |
| `camera.zoom` | `number` | The camera's zoom level |
| `pointedId` | `string` | (optional) The currently pointed shape id |
| `hoveredId` | `string` | (optional) The currently hovered shape id |
| `editingId` | `string` | (optional) The currently editing shape id |
| `bindingId` | `string` | (optional) The currently editing binding. |
| `brush` | `TLBounds` | (optional) A `Bounds` for the current selection box |
### `TLShape`
An object that describes a shape on the page. The shapes in your document should extend this interface with other properties. See [Shape Type](#shape-type).
| Property | Type | Description |
| --------------------- | ---------- | ------------------------------------------------------------------------------------- |
| `id` | `string` | The shape's id. |
| `type` | `string` | The type of the shape, corresponding to the `type` of a [`TLShapeUtil`](#tlshapeutil) |
| `parentId` | `string` | The id of the shape's parent (either the current page or another shape) |
| `childIndex` | `number` | the order of the shape among its parent's children |
| `name` | `string` | the name of the shape |
| `point` | `number[]` | the shape's current `[x, y]` coordinates on the page |
| `rotation` | `number` | (optiona) The shape's current rotation in radians |
| `children` | `string[]` | (optional) An array containing the ids of this shape's children |
| `handles` | `{}` | (optional) A table of [`TLHandle`](#tlhandle) objects |
| `isGhost` | `boolean` | (optional) True if the shape is "ghosted", e.g. while deleting |
| `isLocked` | `boolean` | (optional) True if the shape is locked |
| `isHidden` | `boolean` | (optional) True if the shape is hidden |
| `isEditing` | `boolean` | (optional) True if the shape is currently editing |
| `isGenerated` | `boolean` | optional) True if the shape is generated programatically |
| `isAspectRatioLocked` | `boolean` | (optional) True if the shape's aspect ratio is locked |
### `TLHandle`
An object that describes a relationship between two shapes on the page.
| Property | Type | Description |
| -------- | ---------- | --------------------------------------------- |
| `id` | `string` | An id for the handle |
| `index` | `number` | The handle's order within the shape's handles |
| `point` | `number[]` | The handle's `[x, y]` coordinates |
When a shape with handles is the only selected shape, the `Renderer` will display its handles. You can respond to interactions with these handles using the `on
### `TLBinding`
An object that describes a relationship between two shapes on the page.
| Property | Type | Description |
| -------- | -------- | -------------------------------------------- |
| `id` | `string` | A unique id for the binding |
| `fromId` | `string` | The id of the shape where the binding begins |
| `toId` | `string` | The id of the shape where the binding begins |
### `TLSnapLine`
A snapline is an array of points (formatted as `[x, y]`) that represent a "snapping" line.
### `TLShapeUtil`
The `TLShapeUtil` is an abstract class that you can extend to create utilities for your custom shapes. See the [Creating Shapes](#creating-shapes) guide to learn more.
### `TLUser`
A `TLUser` is the presence information for a multiplayer user. The user's pointer location and selections will be shown on the canvas. If the `TLUser`'s id matches the `Renderer`'s `userId` prop, then the user's cursor and selections will not be shown.
| Property | Type | Description |
| --------------- | ---------- | --------------------------------------- |
| `id` | `string` | A unique id for the user |
| `color` | `string` | The user's color, used for indicators |
| `point` | `number[]` | The user's pointer location on the page |
| `selectedIds[]` | `string[]` | The user's selected shape ids |
### `Utils`
A general purpose utility class. See source for more.
## Guide: Creating Shapes
The `Renderer` component has no built-in shapes. It's up to you to define every shape that you want to see on the canvas. While these shapes are highly reusable between projects, you'll need to define them using the API described below.
> For several example shapes, see the folder `/example/src/shapes/`.
### Shape Type
Your first task is to define an interface for the shape that extends `TLShape`. It must have a `type` property.
```ts
// BoxShape.ts
import type { TLShape } from '@tldraw/core'
export interface BoxShape extends TLShape {
type: 'box'
size: number[]
}
```
### Component
Next, use `TLShapeUtil.Component` to create a second component for your shape's `Component`. The `Renderer` will use this component to display the shape on the canvas.
```tsx
// BoxComponent.ts
import * as React from 'react'
import { shapeComponent, SVGContainer } from '@tldraw/core'
import type { BoxShape } from './BoxShape'
export const BoxComponent = TLShapeUtil.Component<BoxShape, SVGSVGElement>(
({ shape, events, meta }, ref) => {
const color = meta.isDarkMode ? 'white' : 'black'
return (
<SVGContainer ref={ref} {...events}>
<rect
width={shape.size[0]}
height={shape.size[1]}
stroke={color}
strokeWidth={2}
strokeLinejoin="round"
fill="none"
pointerEvents="all"
/>
</SVGContainer>
)
}
)
```
Your component can return HTML elements or SVG elements. If your shape is returning only SVG elements, wrap it in an `SVGContainer`. If your shape returns HTML elements, wrap it in an `HTMLContainer`. Not that you must set `pointerEvents` manually on the shapes you wish to receive pointer events.
The component will receive the following props:
| Name | Type | Description |
| ------------------- | ---------- | ------------------------------------------------------------------ |
| `shape` | `TLShape` | The shape from `page.shapes` that is being rendered |
| `meta` | `{}` | The value provided to the `Renderer`'s `meta` prop |
| `events` | `{}` | Several pointer events that should be set on the container element |
| `isSelected` | `boolean` | The shape is selected (its `id` is in `pageState.selectedIds`) |
| `isHovered` | `boolean` | The shape is hovered (its `id` is `pageState.hoveredId`) |
| `isEditing` | `boolean` | The shape is being edited (its `id` is `pageState.editingId`) |
| `isGhost` | `boolean` | The shape is ghosted or is the child of a ghosted shape. |
| `isChildOfSelected` | `boolean` | The shape is the child of a selected shape. |
| `onShapeChange` | `Function` | The callback provided to the `Renderer`'s `onShapeChange` prop |
| `onShapeBlur` | `Function` | The callback provided to the `Renderer`'s `onShapeBlur` prop |
### Indicator
Next, use `TLShapeUtil.Indicator` to create a second component for your shape's `Indicator`. This component is shown when the shape is hovered or selected. Your `Indicator` must return SVG elements only.
```tsx
// BoxIndicator.ts
export const BoxIndicator = TLShapeUtil.Indicator<BoxShape>(({ shape }) => {
return (
<rect
fill="none"
stroke="dodgerblue"
strokeWidth={1}
width={shape.size[0]}
height={shape.size[1]}
/>
)
})
```
The indicator component will receive the following props:
| Name | Type | Description |
| ------------ | --------- | -------------------------------------------------------------------------------------- |
| `shape` | `TLShape` | The shape from `page.shapes` that is being rendered |
| `meta` | {} | The value provided to the `Renderer`'s `meta` prop |
| `user` | `TLUser` | The user when shown in a multiplayer session |
| `isSelected` | `boolean` | Whether the current shape is selected (true if its `id` is in `pageState.selectedIds`) |
| `isHovered` | `boolean` | Whether the current shape is hovered (true if its `id` is `pageState.hoveredId`) |
### ShapeUtil
Next, create a "shape util" for your shape. This is a class that extends `TLShapeUtil`. The `Renderer` will use an instance of this class to answer questions about the shape: what it should look like, where it is on screen, whether it can rotate, etc.
```ts
// BoxUtil.ts
import { Utils, TLBounds, TLShapeUtil } from '@tldraw/core'
import { BoxComponent } from './BoxComponent'
import { BoxIndicator } from './BoxIndicator'
import type { BoxShape } from './BoxShape'
export class BoxUtil extends TLShapeUtil<BoxShape, SVGSVGElement> {
Component = BoxComponent
Indicator = BoxIndicator
getBounds = (shape: BoxShape): TLBounds => {
const [width, height] = shape.size
const bounds = {
minX: 0,
maxX: width,
minY: 0,
maxY: height,
width,
height,
}
return Utils.translateBounds(bounds, shape.point)
}
}
```
Set the `Component` field to your component and the `Indicator` field to your indicator component. Then define the `getBounds` method. This method will receive a shape and should return a `TLBounds` object.
You may also set the following fields:
| Name | Type | Default | Description |
| ------------------ | --------- | ------- | ----------------------------------------------------------------------------------------------------- |
| `showCloneHandles` | `boolean` | `false` | Whether to display clone handles when the shape is the only selected shape |
| `hideBounds` | `boolean` | `false` | Whether to hide the bounds when the shape is the only selected shape |
| `isStateful` | `boolean` | `false` | Whether the shape has its own React state. When true, the shape will not be unmounted when off-screen |
### ShapeUtils Object
Finally, create a mapping of your project's shape utils and the `type` properties of their corresponding shapes. Pass this object to the `Renderer`'s `shapeUtils` prop.
```tsx
// App.tsx
const shapeUtils = {
box: new BoxUtil(),
circle: new CircleUtil(),
text: new TextUtil(),
}
export function App() {
// ...
return <Renderer page={page} pageState={pageState} {...etc} shapeUtils={shapeUtils} />
}
```
## Local Development
To start the development servers for the package and the advanced example:
- Run `yarn` to install dependencies.
- Run `yarn start`.
- Open `localhost:5420`.
You can also run:
- `start:advanced` to start development servers for the package and the advanced example.
- `start:simple` to start development servers for the package and the simple example.
- `test` to execute unit tests via [Jest](https://jestjs.io).
- `docs` to build the docs via [ts-doc](https://typedoc.org/).
- `build` to build the package.
## Example
See the `example` folder or this [CodeSandbox](https://codesandbox.io/s/laughing-elion-gp0kx) example.
## Community
### Support
Need help? Please [open an issue](https://github.com/tldraw/tldraw/issues/new) for support.
### Discussion
Want to connect with other devs? Visit the [Discord channel](https://discord.gg/SBBEVCA4PG).
### License
This project is licensed under MIT.
If you're using the library in a commercial product, please consider [becoming a sponsor](https://github.com/sponsors/steveruizok?frequency=recurring&sponsor=steveruizok).
## Author
- [@steveruizok](https://twitter.com/steveruizok)

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View file

@ -0,0 +1,39 @@
{
"version": "1.1.4",
"name": "@tldraw/intersect",
"description": "2D intersection utilities for TLDraw and maybe you, too.",
"author": "@steveruizok",
"repository": {
"type": "git",
"url": "git+https://github.com/tldraw/tldraw.git"
},
"license": "MIT",
"keywords": [
"2d",
"vector",
"intersection",
"typescript",
"javascript"
],
"files": [
"dist/**/*"
],
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts",
"scripts": {
"start:packages": "yarn start",
"start:core": "yarn start",
"start": "node scripts/dev & yarn types:dev",
"build:packages": "yarn build",
"build:core": "yarn build",
"build": "node scripts/build && yarn types:build",
"types:dev": "tsc -w --p tsconfig.build.json",
"types:build": "tsc -p tsconfig.build.json",
"lint": "eslint src/ --ext .ts,.tsx",
"clean": "rm -rf dist"
},
"dependencies": {
"@tldraw/vec": "^1.1.4"
}
}

View file

@ -0,0 +1,63 @@
/* eslint-disable */
const fs = require('fs')
const esbuild = require('esbuild')
const { gzip } = require('zlib')
const pkg = require('../package.json')
async function main() {
if (fs.existsSync('./dist')) {
fs.rmSync('./dist', { recursive: true }, (e) => {
if (e) {
throw e
}
})
}
try {
esbuild.buildSync({
entryPoints: ['./src/index.ts'],
outdir: 'dist/cjs',
minify: false,
bundle: true,
format: 'cjs',
target: 'es6',
tsconfig: './tsconfig.build.json',
external: Object.keys(pkg.dependencies),
metafile: true,
sourcemap: true,
})
const esmResult = esbuild.buildSync({
entryPoints: ['./src/index.ts'],
outdir: 'dist/esm',
minify: false,
bundle: true,
format: 'esm',
target: 'es6',
tsconfig: './tsconfig.build.json',
external: Object.keys(pkg.dependencies),
metafile: true,
sourcemap: true,
})
let esmSize = 0
Object.values(esmResult.metafile.outputs).forEach((output) => {
esmSize += output.bytes
})
fs.readFile('./dist/esm/index.js', (_err, data) => {
gzip(data, (_err, result) => {
console.log(
`${pkg.name}: Built package. ${(esmSize / 1000).toFixed(2)}kb (${(
result.length / 1000
).toFixed(2)}kb minified)`
)
})
})
} catch (e) {
console.log(`× ${pkg.name}: Build failed due to an error.`)
console.log(e)
}
}
main()

View file

@ -0,0 +1,29 @@
/* eslint-disable */
const esbuild = require('esbuild')
const pkg = require('../package.json')
async function main() {
try {
await esbuild.build({
entryPoints: ['src/index.tsx'],
outfile: 'dist/index.js',
bundle: true,
minify: false,
sourcemap: true,
incremental: true,
target: ['chrome58', 'firefox57', 'safari11', 'edge18'],
define: {
'process.env.NODE_ENV': '"development"',
},
watch: {
onRebuild(err) {
err ? error('❌ Failed') : log('✅ Updated')
},
},
})
} catch (err) {
process.exit(1)
}
}
main()

430
packages/intersect/src/index.d.ts vendored Normal file
View file

@ -0,0 +1,430 @@
export declare type TLIntersection = {
didIntersect: boolean;
message: string;
points: number[][];
};
export interface TLBounds {
minX: number;
minY: number;
maxX: number;
maxY: number;
width: number;
height: number;
rotation?: number;
}
/**
* Find the intersection between a ray and a ray.
* @param p0 The first ray's point
* @param n0 The first ray's direction vector.
* @param p1 The second ray's point.
* @param n1 The second ray's direction vector.
*/
export declare function intersectRayRay(p0: number[], n0: number[], p1: number[], n1: number[]): TLIntersection;
/**
* Find the intersections between a ray and a line segment.
* @param origin
* @param direction
* @param a1
* @param a2
*/
export declare function intersectRayLineSegment(origin: number[], direction: number[], a1: number[], a2: number[]): TLIntersection;
/**
* Find the intersections between a ray and a rectangle.
* @param origin
* @param direction
* @param point
* @param size
* @param rotation
*/
export declare function intersectRayRectangle(origin: number[], direction: number[], point: number[], size: number[], rotation?: number): TLIntersection[];
/**
* Find the intersections between a ray and an ellipse.
* @param origin
* @param direction
* @param center
* @param rx
* @param ry
* @param rotation
*/
export declare function intersectRayEllipse(origin: number[], direction: number[], center: number[], rx: number, ry: number, rotation: number): TLIntersection;
/**
* Find the intersections between a ray and a bounding box.
* @param origin
* @param direction
* @param bounds
* @param rotation
*/
export declare function intersectRayBounds(origin: number[], direction: number[], bounds: TLBounds, rotation?: number): TLIntersection[];
/**
* Find the intersection between a line segment and a ray.
* @param a1
* @param a2
* @param origin
* @param direction
*/
export declare function intersectLineSegmentRay(a1: number[], a2: number[], origin: number[], direction: number[]): TLIntersection;
/**
* Find the intersection between a line segment and a line segment.
* @param a1
* @param a2
* @param b1
* @param b2
*/
export declare function intersectLineSegmentLineSegment(a1: number[], a2: number[], b1: number[], b2: number[]): TLIntersection;
/**
* Find the intersections between a line segment and a rectangle.
* @param a1
* @param a2
* @param point
* @param size
*/
export declare function intersectLineSegmentRectangle(a1: number[], a2: number[], point: number[], size: number[]): TLIntersection[];
/**
* Find the intersections between a line segment and an arc.
* @param a1
* @param a2
* @param center
* @param radius
* @param start
* @param end
*/
export declare function intersectLineSegmentArc(a1: number[], a2: number[], center: number[], radius: number, start: number[], end: number[]): TLIntersection;
/**
* Find the intersections between a line segment and a circle.
* @param a1
* @param a2
* @param c
* @param r
*/
export declare function intersectLineSegmentCircle(a1: number[], a2: number[], c: number[], r: number): TLIntersection;
/**
* Find the intersections between a line segment and an ellipse.
* @param a1
* @param a2
* @param center
* @param rx
* @param ry
* @param rotation
*/
export declare function intersectLineSegmentEllipse(a1: number[], a2: number[], center: number[], rx: number, ry: number, rotation?: number): TLIntersection;
/**
* Find the intersections between a line segment and a bounding box.
* @param a1
* @param a2
* @param bounds
*/
export declare function intersectLineSegmentBounds(a1: number[], a2: number[], bounds: TLBounds): TLIntersection[];
/**
* Find the intersections between a line segment and a polyline.
* @param a1
* @param a2
* @param points
*/
export declare function intersectLineSegmentPolyline(a1: number[], a2: number[], points: number[][]): TLIntersection;
/**
* Find the intersections between a line segment and a closed polygon.
* @param a1
* @param a2
* @param points
*/
export declare function intersectLineSegmentPolygon(a1: number[], a2: number[], points: number[][]): TLIntersection;
/**
* Find the intersections between a rectangle and a ray.
* @param point
* @param size
* @param rotation
* @param origin
* @param direction
*/
export declare function intersectRectangleRay(point: number[], size: number[], rotation: number, origin: number[], direction: number[]): TLIntersection[];
/**
* Find the intersections between a rectangle and a line segment.
* @param point
* @param size
* @param a1
* @param a2
*/
export declare function intersectRectangleLineSegment(point: number[], size: number[], a1: number[], a2: number[]): TLIntersection[];
/**
* Find the intersections between a rectangle and a rectangle.
* @param point1
* @param size1
* @param point2
* @param size2
*/
export declare function intersectRectangleRectangle(point1: number[], size1: number[], point2: number[], size2: number[]): TLIntersection[];
/**
* Find the intersections between a rectangle and an arc.
* @param point
* @param size
* @param center
* @param radius
* @param start
* @param end
*/
export declare function intersectRectangleArc(point: number[], size: number[], center: number[], radius: number, start: number[], end: number[]): TLIntersection[];
/**
* Find the intersections between a rectangle and a circle.
* @param point
* @param size
* @param c
* @param r
*/
export declare function intersectRectangleCircle(point: number[], size: number[], c: number[], r: number): TLIntersection[];
/**
* Find the intersections between a rectangle and an ellipse.
* @param point
* @param size
* @param c
* @param rx
* @param ry
* @param rotation
*/
export declare function intersectRectangleEllipse(point: number[], size: number[], c: number[], rx: number, ry: number, rotation?: number): TLIntersection[];
/**
* Find the intersections between a rectangle and a bounding box.
* @param point
* @param size
* @param bounds
*/
export declare function intersectRectangleBounds(point: number[], size: number[], bounds: TLBounds): TLIntersection[];
/**
* Find the intersections between a rectangle and a polyline.
* @param point
* @param size
* @param points
*/
export declare function intersectRectanglePolyline(point: number[], size: number[], points: number[][]): TLIntersection[];
/**
* Find the intersections between a rectangle and a polygon.
* @param point
* @param size
* @param points
*/
export declare function intersectRectanglePolygon(point: number[], size: number[], points: number[][]): TLIntersection[];
/**
* Find the intersections between a arc and a line segment.
* @param center
* @param radius
* @param start
* @param end
* @param a1
* @param a2
*/
export declare function intersectArcLineSegment(center: number[], radius: number, start: number[], end: number[], a1: number[], a2: number[]): TLIntersection;
/**
* Find the intersections between a arc and a rectangle.
* @param center
* @param radius
* @param start
* @param end
* @param point
* @param size
*/
export declare function intersectArcRectangle(center: number[], radius: number, start: number[], end: number[], point: number[], size: number[]): TLIntersection[];
/**
* Find the intersections between a arc and a bounding box.
* @param center
* @param radius
* @param start
* @param end
* @param bounds
*/
export declare function intersectArcBounds(center: number[], radius: number, start: number[], end: number[], bounds: TLBounds): TLIntersection[];
/**
* Find the intersections between a circle and a line segment.
* @param c
* @param r
* @param a1
* @param a2
*/
export declare function intersectCircleLineSegment(c: number[], r: number, a1: number[], a2: number[]): TLIntersection;
/**
* Find the intersections between a circle and a circle.
* @param c1
* @param r1
* @param c2
* @param r2
*/
export declare function intersectCircleCircle(c1: number[], r1: number, c2: number[], r2: number): TLIntersection;
/**
* Find the intersections between a circle and a rectangle.
* @param c
* @param r
* @param point
* @param size
*/
export declare function intersectCircleRectangle(c: number[], r: number, point: number[], size: number[]): TLIntersection[];
/**
* Find the intersections between a circle and a bounding box.
* @param c
* @param r
* @param bounds
*/
export declare function intersectCircleBounds(c: number[], r: number, bounds: TLBounds): TLIntersection[];
/**
* Find the intersections between an ellipse and a ray.
* @param center
* @param rx
* @param ry
* @param rotation
* @param point
* @param direction
*/
export declare function intersectEllipseRay(center: number[], rx: number, ry: number, rotation: number, point: number[], direction: number[]): TLIntersection;
/**
* Find the intersections between an ellipse and a line segment.
* @param center
* @param rx
* @param ry
* @param rotation
* @param a1
* @param a2
*/
export declare function intersectEllipseLineSegment(center: number[], rx: number, ry: number, rotation: number | undefined, a1: number[], a2: number[]): TLIntersection;
/**
* Find the intersections between an ellipse and a rectangle.
* @param center
* @param rx
* @param ry
* @param rotation
* @param point
* @param size
*/
export declare function intersectEllipseRectangle(center: number[], rx: number, ry: number, rotation: number | undefined, point: number[], size: number[]): TLIntersection[];
/**
* Find the intersections between an ellipse and an ellipse.
* Adapted from https://gist.github.com/drawable/92792f59b6ff8869d8b1
* @param _c1
* @param _rx1
* @param _ry1
* @param _r1
* @param _c2
* @param _rx2
* @param _ry2
* @param _r2
*/
export declare function intersectEllipseEllipse(_c1: number[], _rx1: number, _ry1: number, _r1: number, _c2: number[], _rx2: number, _ry2: number, _r2: number): TLIntersection;
/**
* Find the intersections between an ellipse and a circle.
* @param c
* @param rx
* @param ry
* @param rotation
* @param c2
* @param r2
*/
export declare function intersectEllipseCircle(c: number[], rx: number, ry: number, rotation: number, c2: number[], r2: number): TLIntersection;
/**
* Find the intersections between an ellipse and a bounding box.
* @param c
* @param rx
* @param ry
* @param rotation
* @param bounds
*/
export declare function intersectEllipseBounds(c: number[], rx: number, ry: number, rotation: number, bounds: TLBounds): TLIntersection[];
/**
* Find the intersections between a bounding box and a ray.
* @param bounds
* @param origin
* @param direction
*/
export declare function intersectBoundsRay(bounds: TLBounds, origin: number[], direction: number[]): TLIntersection[];
/**
* Find the intersections between a bounding box and a line segment.
* @param bounds
* @param a1
* @param a2
*/
export declare function intersectBoundsLineSegment(bounds: TLBounds, a1: number[], a2: number[]): TLIntersection[];
/**
* Find the intersections between a bounding box and a rectangle.
* @param bounds
* @param point
* @param size
*/
export declare function intersectBoundsRectangle(bounds: TLBounds, point: number[], size: number[]): TLIntersection[];
/**
* Find the intersections between a bounding box and a bounding box.
* @param bounds1
* @param bounds2
*/
export declare function intersectBoundsBounds(bounds1: TLBounds, bounds2: TLBounds): TLIntersection[];
/**
* Find the intersections between a bounding box and an arc.
* @param bounds
* @param center
* @param radius
* @param start
* @param end
*/
export declare function intersectBoundsArc(bounds: TLBounds, center: number[], radius: number, start: number[], end: number[]): TLIntersection[];
/**
* Find the intersections between a bounding box and a circle.
* @param bounds
* @param c
* @param r
*/
export declare function intersectBoundsCircle(bounds: TLBounds, c: number[], r: number): TLIntersection[];
/**
* Find the intersections between a bounding box and an ellipse.
* @param bounds
* @param c
* @param rx
* @param ry
* @param rotation
*/
export declare function intersectBoundsEllipse(bounds: TLBounds, c: number[], rx: number, ry: number, rotation?: number): TLIntersection[];
/**
* Find the intersections between a bounding box and a polyline.
* @param bounds
* @param points
*/
export declare function intersectBoundsPolyline(bounds: TLBounds, points: number[][]): TLIntersection[];
/**
* Find the intersections between a bounding box and a polygon.
* @param bounds
* @param points
*/
export declare function intersectBoundsPolygon(bounds: TLBounds, points: number[][]): TLIntersection[];
/**
* Find the intersections between a polyline and a line segment.
* @param points
* @param a1
* @param a2
*/
export declare function intersectPolylineLineSegment(points: number[][], a1: number[], a2: number[]): TLIntersection;
/**
* Find the intersections between a polyline and a rectangle.
* @param points
* @param point
* @param size
*/
export declare function intersectPolylineRectangle(points: number[][], point: number[], size: number[]): TLIntersection[];
/**
* Find the intersections between a polyline and a bounding box.
* @param points
* @param bounds
*/
export declare function intersectPolylineBounds(points: number[][], bounds: TLBounds): TLIntersection[];
/**
* Find the intersections between a polygon nd a line segment.
* @param points
* @param a1
* @param a2
*/
export declare function intersectPolygonLineSegment(points: number[][], a1: number[], a2: number[]): TLIntersection;
/**
* Find the intersections between a polygon and a rectangle.
* @param points
* @param point
* @param size
*/
export declare function intersectPolygonRectangle(points: number[][], point: number[], size: number[]): TLIntersection[];
/**
* Find the intersections between a polygon and a bounding box.
* @param points
* @param bounds
*/
export declare function intersectPolygonBounds(points: number[][], bounds: TLBounds): TLIntersection[];

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,21 @@
{
"extends": "./tsconfig.json",
"exclude": [
"node_modules",
"**/*.test.tsx",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.spec.ts",
"src/test",
"dist",
"docs"
],
"compilerOptions": {
"composite": false,
"incremental": false,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"references": [{ "path": "../vec" }]
}

View file

@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"exclude": ["node_modules", "dist", "docs"],
"compilerOptions": {
"outDir": "./dist/types",
"rootDir": "src",
"baseUrl": "."
},
"references": [{ "path": "../vec" }],
"typedocOptions": {
"entryPoints": ["src/index.ts"],
"out": "docs"
}
}

View file

@ -49,9 +49,9 @@
"@radix-ui/react-radio-group": "^0.1.1",
"@radix-ui/react-tooltip": "^0.1.1",
"@stitches/react": "^1.2.5",
"@tldraw/core": "^1.1.3",
"@tldraw/intersect": "latest",
"@tldraw/vec": "latest",
"@tldraw/core": "^1.1.4",
"@tldraw/intersect": "^1.1.4",
"@tldraw/vec": "^1.1.4",
"idb-keyval": "^6.0.3",
"perfect-freehand": "^1.0.16",
"react-hotkeys-hook": "^3.4.0",
@ -62,7 +62,7 @@
"@swc-node/jest": "^1.3.3",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.0.0",
"tsconfig-replace-paths": "^0.0.5"
"tsconfig-replace-paths": "^0.0.11"
},
"jest": {
"setupFilesAfterEnv": [
@ -91,4 +91,4 @@
}
},
"gitHead": "325008ff82bd27b63d625ad1b760f8871fb71af9"
}
}

View file

@ -8,9 +8,10 @@ import { TldrawContext, useStylesheet, useKeyboardShortcuts, useTldrawApp } from
import { shapeUtils } from '~state/shapes'
import { ToolsPanel } from '~components/ToolsPanel'
import { TopPanel } from '~components/TopPanel'
import { TLDR } from '~state/TLDR'
import { ContextMenu } from '~components/ContextMenu'
import { FocusButton } from '~components/FocusButton/FocusButton'
import { FocusButton } from '~components/FocusButton'
import { TLDR } from '~state/TLDR'
import { GRID_SIZE } from '~constants'
export interface TldrawProps extends TDCallbacks {
/**
@ -425,6 +426,7 @@ const InnerTldraw = React.memo(function InnerTldraw({
page={page}
pageState={pageState}
snapLines={appState.snapLines}
grid={GRID_SIZE}
users={room?.users}
userId={room?.userId}
theme={theme}
@ -436,6 +438,7 @@ const InnerTldraw = React.memo(function InnerTldraw({
hideBindingHandles={!settings.showBindingHandles}
hideCloneHandles={!settings.showCloneHandles}
hideRotateHandles={!settings.showRotateHandles}
hideGrid={!settings.showGrid}
onPinchStart={app.onPinchStart}
onPinchEnd={app.onPinchEnd}
onPinch={app.onPinch}

View file

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

View file

@ -26,6 +26,10 @@ export function PreferencesMenu() {
app.setSetting('showRotateHandles', (v) => !v)
}, [app])
const toggleGrid = React.useCallback(() => {
app.setSetting('showGrid', (v) => !v)
}, [app])
const toggleBoundShapesHandle = React.useCallback(() => {
app.setSetting('showBindingHandles', (v) => !v)
}, [app])
@ -62,6 +66,9 @@ export function PreferencesMenu() {
<DMCheckboxItem checked={settings.showCloneHandles} onCheckedChange={toggleCloneControls}>
Clone Handles
</DMCheckboxItem>
<DMCheckboxItem checked={settings.showGrid} onCheckedChange={toggleGrid} kbd="#⇧G">
Grid
</DMCheckboxItem>
<DMCheckboxItem checked={settings.isSnapping} onCheckedChange={toggleisSnapping}>
Always Show Snaps
</DMCheckboxItem>

View file

@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export const GRID_SIZE = 8
export const BINDING_DISTANCE = 24
export const CLONING_DISTANCE = 32
export const FIT_TO_SCREEN_PADDING = 128

View file

@ -129,6 +129,16 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
[app]
)
useHotkeys(
'ctrl+shift+g,⌘+shift+g',
() => {
if (!canHandleEvent()) return
app.toggleGrid()
},
undefined,
[app]
)
// File System
const { onNewProject, onOpenProject, onSaveProject, onSaveProjectAs } = useFileSystemHandlers()

View file

@ -672,7 +672,7 @@ export class TLDR {
const rotatedCenter = Vec.rotWith(center, origin, delta)
// Get the top left point relative to the rotated center
const nextPoint = Vec.round(Vec.sub(rotatedCenter, relativeCenter))
const nextPoint = Vec.toFixed(Vec.sub(rotatedCenter, relativeCenter))
// If the shape has handles, we need to rotate the handles instead
// of rotating the shape. Shapes with handles should never be rotated,
@ -685,7 +685,7 @@ export class TLDR {
Object.entries(shape.handles).map(([handleId, handle]) => {
// Rotate each handle's point around the shape's center
// (in relative shape space, as the handle's point will be).
const point = Vec.round(Vec.rotWith(handle.point, relativeCenter, delta))
const point = Vec.toFixed(Vec.rotWith(handle.point, relativeCenter, delta))
return [handleId, { ...handle, point }]
})
) as T['handles'],

View file

@ -48,7 +48,7 @@ import { defaultStyle } from '~state/shapes/shared/shape-styles'
import * as Commands from './commands'
import { SessionArgsOfType, getSession, TldrawSession } from './sessions'
import type { BaseTool } from './tools/BaseTool'
import { USER_COLORS, FIT_TO_SCREEN_PADDING } from '~constants'
import { USER_COLORS, FIT_TO_SCREEN_PADDING, GRID_SIZE } from '~constants'
import { SelectTool } from './tools/SelectTool'
import { EraseTool } from './tools/EraseTool'
import { TextTool } from './tools/TextTool'
@ -790,6 +790,16 @@ export class TldrawApp extends StateManager<TDSnapshot> {
return this
}
/**
* Toggle grids.
*/
toggleGrid = (): this => {
if (this.session) return this
this.patchState({ settings: { showGrid: !this.settings.showGrid } }, 'settings:toggled_grid')
this.persist()
return this
}
/**
* Select a tool.
* @param tool The tool to select, or "select".
@ -1472,17 +1482,14 @@ export class TldrawApp extends StateManager<TDSnapshot> {
const commonBounds = Utils.getCommonBounds(shapesToPaste.map(TLDR.getBounds))
let center = Vec.round(this.getPagePoint(point || this.centerPoint))
let center = Vec.toFixed(this.getPagePoint(point || this.centerPoint))
if (
Vec.dist(center, this.pasteInfo.center) < 2 ||
Vec.dist(center, Vec.round(Utils.getBoundsCenter(commonBounds))) < 2
Vec.dist(center, Vec.toFixed(Utils.getBoundsCenter(commonBounds))) < 2
) {
center = Vec.add(center, this.pasteInfo.offset)
this.pasteInfo.offset = Vec.add(this.pasteInfo.offset, [
this.settings.nudgeDistanceLarge,
this.settings.nudgeDistanceLarge,
])
this.pasteInfo.offset = Vec.add(this.pasteInfo.offset, [GRID_SIZE, GRID_SIZE])
} else {
this.pasteInfo.center = center
this.pasteInfo.offset = [0, 0]
@ -1499,7 +1506,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
shapesToPaste.map((shape) =>
TLDR.getShapeUtil(shape.type).create({
...shape,
point: Vec.round(Vec.add(shape.point, delta)),
point: Vec.toFixed(Vec.add(shape.point, delta)),
parentId: shape.parentId || this.currentPageId,
})
),
@ -1691,7 +1698,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
*/
pan = (delta: number[]): this => {
const { camera } = this.pageState
return this.setCamera(Vec.round(Vec.sub(camera.point, delta)), camera.zoom, `panned`)
return this.setCamera(Vec.toFixed(Vec.sub(camera.point, delta)), camera.zoom, `panned`)
}
/**
@ -1706,7 +1713,11 @@ export class TldrawApp extends StateManager<TDSnapshot> {
const nextZoom = zoom
const p0 = Vec.sub(Vec.div(point, camera.zoom), nextPoint)
const p1 = Vec.sub(Vec.div(point, nextZoom), nextPoint)
return this.setCamera(Vec.round(Vec.add(nextPoint, Vec.sub(p1, p0))), nextZoom, `pinch_zoomed`)
return this.setCamera(
Vec.toFixed(Vec.add(nextPoint, Vec.sub(p1, p0))),
nextZoom,
`pinch_zoomed`
)
}
/**
@ -1718,7 +1729,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
const { zoom, point } = this.pageState.camera
const p0 = Vec.sub(Vec.div(center, zoom), point)
const p1 = Vec.sub(Vec.div(center, next), point)
return this.setCamera(Vec.round(Vec.add(point, Vec.sub(p1, p0))), next, `zoomed_camera`)
return this.setCamera(Vec.toFixed(Vec.add(point, Vec.sub(p1, p0))), next, `zoomed_camera`)
}
/**
@ -1767,7 +1778,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
const my = (rendererBounds.height - commonBounds.height * zoom) / 2 / zoom
return this.setCamera(
Vec.round(Vec.sub([mx, my], [commonBounds.minX, commonBounds.minY])),
Vec.toFixed(Vec.sub([mx, my], [commonBounds.minX, commonBounds.minY])),
zoom,
`zoomed_to_fit`
)
@ -1798,7 +1809,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
const my = (rendererBounds.height - selectedBounds.height * zoom) / 2 / zoom
return this.setCamera(
Vec.round(Vec.sub([mx, my], [selectedBounds.minX, selectedBounds.minY])),
Vec.toFixed(Vec.sub([mx, my], [selectedBounds.minX, selectedBounds.minY])),
zoom,
`zoomed_to_selection`
)
@ -1821,7 +1832,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
const my = (rendererBounds.height - commonBounds.height * zoom) / 2 / zoom
return this.setCamera(
Vec.round(Vec.sub([mx, my], [commonBounds.minX, commonBounds.minY])),
Vec.toFixed(Vec.sub([mx, my], [commonBounds.minX, commonBounds.minY])),
this.pageState.camera.zoom,
`zoomed_to_content`
)
@ -2119,6 +2130,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
status: TDStatus.Idle,
},
document: {
...result.document,
pageStates: {
[this.currentPageId]: {
...result.document?.pageStates?.[this.currentPageId],
@ -2359,7 +2371,15 @@ export class TldrawApp extends StateManager<TDSnapshot> {
*/
nudge = (delta: number[], isMajor = false, ids = this.selectedIds): this => {
if (ids.length === 0) return this
return this.setState(Commands.translateShapes(this, ids, Vec.mul(delta, isMajor ? 10 : 1)))
const size = isMajor
? this.settings.showGrid
? this.currentGrid * 4
: 10
: this.settings.showGrid
? this.currentGrid
: 1
return this.setState(Commands.translateShapes(this, ids, Vec.mul(delta, size)))
}
/**
@ -2498,7 +2518,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
onKeyDown: TLKeyboardEventHandler = (key, info, e) => {
switch (e.key) {
case '.': {
case '/': {
if (this.status === 'idle') {
const { shiftKey, metaKey, altKey, ctrlKey, spaceKey } = this
@ -2559,7 +2579,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
if (!info) return
switch (e.key) {
case '.': {
case '/': {
const { currentPoint, shiftKey, metaKey, altKey, ctrlKey, spaceKey } = this
this.onPointerUp(
@ -2950,7 +2970,18 @@ export class TldrawApp extends StateManager<TDSnapshot> {
// The center of the component (in screen space)
get centerPoint() {
const { width, height } = this.rendererBounds
return Vec.round([width / 2, height / 2])
return Vec.toFixed([width / 2, height / 2])
}
get currentGrid() {
const { zoom } = this.pageState.camera
if (zoom < 0.15) {
return GRID_SIZE * 16
} else if (zoom < 1) {
return GRID_SIZE * 4
} else {
return GRID_SIZE * 1
}
}
getShapeUtil = TLDR.getShapeUtil
@ -2996,6 +3027,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
showRotateHandles: true,
showBindingHandles: true,
showCloneHandles: false,
showGrid: false,
},
appState: {
status: TDStatus.Idle,

View file

@ -1,9 +1,8 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Vec } from '@tldraw/vec'
import { Utils } from '@tldraw/core'
import { AlignType, TldrawCommand, TDShapeType } from '~types'
import type { TDSnapshot } from '~types'
import { TLDR } from '~state/TLDR'
import Vec from '@tldraw/vec'
import type { TldrawApp } from '../../internal'
export function alignShapes(app: TldrawApp, ids: string[], type: AlignType): TldrawCommand {

View file

@ -170,7 +170,7 @@ export function moveShapesToPage(
const mx = (viewportBounds.width - bounds.width * zoom) / 2 / zoom
const my = (viewportBounds.height - bounds.height * zoom) / 2 / zoom
const point = Vec.round(Vec.add([-bounds.minX, -bounds.minY], [mx, my]))
const point = Vec.toFixed(Vec.add([-bounds.minX, -bounds.minY], [mx, my]))
return {
id: 'move_to_page',

View file

@ -35,7 +35,7 @@ export function styleShapes(
if (shape.type === TDShapeType.Text) {
beforeShapes[shape.id].point = shape.point
afterShapes[shape.id].point = Vec.round(
afterShapes[shape.id].point = Vec.toFixed(
Vec.add(
shape.point,
Vec.sub(

View file

@ -30,7 +30,7 @@ export function translateShapes(app: TldrawApp, ids: string[], delta: number[]):
app.state,
idsToMutate,
(shape) => ({
point: Vec.round(Vec.add(shape.point, delta)),
point: Vec.toFixed(Vec.add(shape.point, delta)),
}),
currentPageId
)

View file

@ -10,12 +10,12 @@ import {
TldrawCommand,
} from '~types'
import { Vec } from '@tldraw/vec'
import { Utils } from '@tldraw/core'
import { TLDR } from '~state/TLDR'
import { BINDING_DISTANCE } from '~constants'
import { shapeUtils } from '~state/shapes'
import { BaseSession } from '../BaseSession'
import type { TldrawApp } from '../../internal'
import { Utils } from '@tldraw/core'
export class ArrowSession extends BaseSession {
type = SessionType.Arrow
@ -70,7 +70,14 @@ export class ArrowSession extends BaseSession {
update = (): TldrawPatch | undefined => {
const { initialShape } = this
const { currentPoint, shiftKey, altKey, metaKey } = this.app
const {
currentPoint,
shiftKey,
altKey,
metaKey,
currentGrid,
settings: { showGrid },
} = this.app
const shape = this.app.getShape<ArrowShape>(initialShape.id)
@ -90,15 +97,18 @@ export class ArrowSession extends BaseSession {
if (shiftKey) {
const A = handles[handleId === 'start' ? 'end' : 'start'].point
const B = handles[handleId].point
const C = Vec.round(Vec.sub(Vec.add(B, delta), shape.point))
const C = Vec.toFixed(Vec.sub(Vec.add(B, delta), shape.point))
const angle = Vec.angle(A, C)
const adjusted = Vec.rotWith(C, A, Utils.snapAngleToSegments(angle, 24) - angle)
delta = Vec.add(delta, Vec.sub(adjusted, C))
}
const nextPoint = Vec.sub(Vec.add(handles[handleId].point, delta), shape.point)
const handle = {
...handles[handleId],
point: Vec.round(Vec.sub(Vec.add(handles[handleId].point, delta), shape.point)),
point: showGrid ? Vec.snap(nextPoint, currentGrid) : Vec.toFixed(nextPoint),
bindingId: undefined,
}
@ -340,14 +350,20 @@ export class ArrowSession extends BaseSession {
complete = (): TldrawPatch | TldrawCommand | undefined => {
const { initialShape, initialBinding, newStartBindingId, startBindingShapeId, handleId } = this
const currentShape = TLDR.onSessionComplete(this.app.page.shapes[initialShape.id]) as ArrowShape
const currentBindingId = currentShape.handles[handleId].bindingId
if (
!(currentBindingId || initialBinding) &&
Vec.dist(currentShape.handles.start.point, currentShape.handles.end.point) > 2
) {
return this.cancel()
}
const beforeBindings: Partial<Record<string, TDBinding>> = {}
const afterBindings: Partial<Record<string, TDBinding>> = {}
let afterShape = this.app.page.shapes[initialShape.id] as ArrowShape
const currentBindingId = afterShape.handles[handleId].bindingId
if (initialBinding) {
beforeBindings[initialBinding.id] = this.isCreate ? undefined : initialBinding
afterBindings[initialBinding.id] = undefined
@ -363,8 +379,6 @@ export class ArrowSession extends BaseSession {
afterBindings[newStartBindingId] = this.app.page.bindings[newStartBindingId]
}
afterShape = TLDR.onSessionComplete(afterShape)
return {
id: 'arrow',
before: {
@ -392,7 +406,7 @@ export class ArrowSession extends BaseSession {
pages: {
[this.app.currentPageId]: {
shapes: {
[initialShape.id]: afterShape,
[initialShape.id]: currentShape,
},
bindings: afterBindings,
},
@ -441,7 +455,7 @@ export class ArrowSession extends BaseSession {
fromId: shape.id,
toId: target.id,
handleId: handleId,
point: Vec.round(bindingPoint.point),
point: Vec.toFixed(bindingPoint.point),
distance: bindingPoint.distance,
}
}

View file

@ -81,7 +81,7 @@ export class DrawSession extends BaseSession {
}
// The new adjusted point
const newAdjustedPoint = Vec.round(Vec.sub(currentPoint, originPoint)).concat(currentPoint[2])
const newAdjustedPoint = Vec.toFixed(Vec.sub(currentPoint, originPoint)).concat(currentPoint[2])
// Don't add duplicate points.
if (Vec.isEqual(this.lastAdjustedPoint, newAdjustedPoint)) return
@ -112,7 +112,7 @@ export class DrawSession extends BaseSession {
// offset between the new top left and the original top left.
points = this.points.map((pt) => {
return Vec.round(Vec.sub(pt, delta)).concat(pt[2])
return Vec.toFixed(Vec.sub(pt, delta)).concat(pt[2])
})
} else {
// If the new top left is the same as the previous top left,
@ -197,8 +197,8 @@ export class DrawSession extends BaseSession {
shapes: {
[shapeId]: {
...shape,
point: Vec.round(shape.point),
points: shape.points.map((pt) => Vec.round(pt)),
point: Vec.toFixed(shape.point),
points: shape.points.map((pt) => Vec.toFixed(pt)),
isComplete: true,
},
},

View file

@ -57,7 +57,7 @@ export class EraseSession extends BaseSession {
}
}
const newPoint = Vec.round(Vec.add(originPoint, Vec.sub(currentPoint, originPoint)))
const newPoint = Vec.toFixed(Vec.add(originPoint, Vec.sub(currentPoint, originPoint)))
const deletedShapeIds = new Set<string>([])

View file

@ -106,7 +106,7 @@ describe('Rotate session', () => {
it('keeps the center', () => {
app.loadDocument(mockDocument).select('rect1', 'rect2')
const centerBefore = Vec.round(
const centerBefore = Vec.toFixed(
Utils.getBoundsCenter(
Utils.getCommonBounds(app.selectedIds.map((id) => app.getShapeBounds(id)))
)
@ -114,7 +114,7 @@ describe('Rotate session', () => {
app.pointBoundsHandle('rotate', { x: 50, y: 0 }).movePointer([100, 50]).completeSession()
const centerAfterA = Vec.round(
const centerAfterA = Vec.toFixed(
Utils.getBoundsCenter(
Utils.getCommonBounds(app.selectedIds.map((id) => app.getShapeBounds(id)))
)
@ -122,7 +122,7 @@ describe('Rotate session', () => {
app.pointBoundsHandle('rotate', { x: 100, y: 0 }).movePointer([50, 0]).completeSession()
const centerAfterB = Vec.round(
const centerAfterB = Vec.toFixed(
Utils.getBoundsCenter(
Utils.getCommonBounds(app.selectedIds.map((id) => app.getShapeBounds(id)))
)
@ -142,7 +142,7 @@ describe('Rotate session', () => {
it('changes the center after nudging', () => {
const app = new TldrawTestApp().loadDocument(mockDocument).select('rect1', 'rect2')
const centerBefore = Vec.round(
const centerBefore = Vec.toFixed(
Utils.getBoundsCenter(
Utils.getCommonBounds(app.selectedIds.map((id) => app.getShapeBounds(id)))
)
@ -150,7 +150,7 @@ describe('Rotate session', () => {
app.pointBoundsHandle('rotate', { x: 50, y: 0 }).movePointer([100, 50]).completeSession()
const centerAfterA = Vec.round(
const centerAfterA = Vec.toFixed(
Utils.getBoundsCenter(
Utils.getCommonBounds(app.selectedIds.map((id) => app.getShapeBounds(id)))
)
@ -163,7 +163,7 @@ describe('Rotate session', () => {
app.pointBoundsHandle('rotate', { x: 50, y: 0 }).movePointer([100, 50]).completeSession()
const centerAfterB = Vec.round(
const centerAfterB = Vec.toFixed(
Utils.getBoundsCenter(
Utils.getCommonBounds(app.selectedIds.map((id) => app.getShapeBounds(id)))
)

View file

@ -113,7 +113,8 @@ export class TransformSession extends BaseSession {
shiftKey,
altKey,
metaKey,
settings: { isSnapping },
currentGrid,
settings: { isSnapping, showGrid },
},
} = this
@ -138,6 +139,13 @@ export class TransformSession extends BaseSession {
}
}
if (showGrid) {
newBounds = {
...newBounds,
...Utils.snapBoundsToGrid(newBounds, currentGrid),
}
}
// Should we snap?
const speed = Vec.dist(currentPoint, previousPoint)
@ -180,7 +188,7 @@ export class TransformSession extends BaseSession {
this.scaleY = newBounds.scaleY
shapeBounds.forEach(({ initialShape, initialShapeBounds, transformOrigin }) => {
const newShapeBounds = Utils.getRelativeTransformedBoundingBox(
let newShapeBounds = Utils.getRelativeTransformedBoundingBox(
newBounds,
initialCommonBounds,
initialShapeBounds,
@ -188,13 +196,19 @@ export class TransformSession extends BaseSession {
this.scaleY < 0
)
shapes[initialShape.id] = TLDR.transform(this.app.getShape(initialShape.id), newShapeBounds, {
if (showGrid) {
newShapeBounds = Utils.snapBoundsToGrid(newShapeBounds, currentGrid)
}
const afterShape = TLDR.transform(this.app.getShape(initialShape.id), newShapeBounds, {
type: this.transformType,
initialShape,
scaleX: this.scaleX,
scaleY: this.scaleY,
transformOrigin,
})
shapes[initialShape.id] = afterShape
})
return {

View file

@ -70,13 +70,14 @@ export class TransformSingleSession extends BaseSession {
initialShape,
initialShapeBounds,
app: {
settings: { isSnapping },
settings: { isSnapping, showGrid },
currentPageId,
pageState: { camera },
viewport,
currentPoint,
previousPoint,
originPoint,
currentGrid,
shiftKey,
altKey,
metaKey,
@ -85,12 +86,12 @@ export class TransformSingleSession extends BaseSession {
if (initialShape.isLocked) return void null
const shapes = {} as Record<string, Partial<TDShape>>
const delta = altKey
? Vec.mul(Vec.sub(currentPoint, originPoint), 2)
: Vec.sub(currentPoint, originPoint)
const shapes = {} as Record<string, Partial<TDShape>>
const shape = this.app.getShape(initialShape.id)
const utils = TLDR.getShapeUtil(shape)
@ -110,6 +111,13 @@ export class TransformSingleSession extends BaseSession {
}
}
if (showGrid) {
newBounds = {
...newBounds,
...Utils.snapBoundsToGrid(newBounds, currentGrid),
}
}
// Should we snap?
const speed = Vec.dist(currentPoint, previousPoint)
@ -159,6 +167,10 @@ export class TransformSingleSession extends BaseSession {
shapes[shape.id] = afterShape
}
if (showGrid && afterShape && afterShape.point) {
afterShape.point = Vec.snap(afterShape.point, currentGrid)
}
return {
appState: {
snapLines,

View file

@ -1,3 +1,4 @@
import { Vec } from '@tldraw/vec'
import { mockDocument, TldrawTestApp } from '~test'
import { GroupShape, SessionType, TDShapeType, TDStatus } from '~types'
@ -114,15 +115,16 @@ describe('Translate session', () => {
expect(Object.keys(app.getPage().shapes).length).toBe(5)
app.movePointer({ x: 30, y: 30 })
app.movePointer({ x: 20, y: 20, altKey: false })
expect(Object.keys(app.getPage().shapes).length).toBe(3)
app.completeSession()
// Original position + delta
expect(app.getShape('rect1').point).toStrictEqual([30, 30])
expect(app.getShape('rect2').point).toStrictEqual([130, 130])
const rectPoint = app.getShape('rect1').point
expect(app.getShape('rect1').point).toStrictEqual(rectPoint)
expect(app.getShape('rect2').point).toStrictEqual([110, 110])
expect(Object.keys(app.page.shapes)).toStrictEqual(['rect1', 'rect2', 'rect3'])
})
@ -211,6 +213,7 @@ describe('Translate session', () => {
.movePointer({ x: 20, y: 20, altKey: true })
.completeSession()
const rectPoint = app.getShape('rect1').point
const children = app.getShape<GroupShape>('groupA').children
const newShapeId = children[children.length - 1]
@ -218,7 +221,7 @@ describe('Translate session', () => {
expect(app.getShape<GroupShape>('groupA').children.length).toBe(3)
expect(app.getShape('rect1').point).toStrictEqual([0, 0])
expect(app.getShape('rect2').point).toStrictEqual([100, 100])
expect(app.getShape(newShapeId).point).toStrictEqual([20, 20])
expect(app.getShape(newShapeId).point).toStrictEqual(Vec.add(rectPoint, [10, 10]))
expect(app.getShape(newShapeId).parentId).toBe('groupA')
app.undo()
@ -235,7 +238,7 @@ describe('Translate session', () => {
expect(app.getShape<GroupShape>('groupA').children.length).toBe(3)
expect(app.getShape('rect1').point).toStrictEqual([0, 0])
expect(app.getShape('rect2').point).toStrictEqual([100, 100])
expect(app.getShape(newShapeId).point).toStrictEqual([20, 20])
expect(app.getShape(newShapeId).point).toStrictEqual(Vec.add(rectPoint, [10, 10]))
expect(app.getShape(newShapeId).parentId).toBe('groupA')
})
})

View file

@ -24,6 +24,7 @@ type CloneInfo =
}
| {
state: 'ready'
cloneMap: Record<string, string>
clones: TDShape[]
clonedBindings: ArrowBinding[]
}
@ -172,7 +173,7 @@ export class TranslateSession extends BaseSession {
bindingsToDelete,
app: {
pageState: { camera },
settings: { isSnapping },
settings: { isSnapping, showGrid },
currentPageId,
viewport,
selectedIds,
@ -182,13 +183,12 @@ export class TranslateSession extends BaseSession {
altKey,
shiftKey,
metaKey,
currentGrid,
},
} = this
const nextBindings: Patch<Record<string, TDBinding>> = {}
const nextShapes: Patch<Record<string, TDShape>> = {}
const nextPageState: Patch<TLPageState> = {}
let delta = Vec.sub(currentPoint, originPoint)
@ -236,10 +236,12 @@ export class TranslateSession extends BaseSession {
this.speed * camera.zoom < SLOW_SPEED &&
this.snapInfo.state === 'ready'
) {
const bounds = Utils.getBoundsWithCenter(Utils.translateBounds(initialCommonBounds, delta))
const snapResult = Utils.getSnapPoints(
bounds,
Utils.getBoundsWithCenter(
showGrid
? Utils.snapBoundsToGrid(Utils.translateBounds(initialCommonBounds, delta), currentGrid)
: Utils.translateBounds(initialCommonBounds, delta)
),
(this.isCloning ? this.snapInfo.bounds : this.snapInfo.others).filter(
(bounds) => Utils.boundsContain(viewport, bounds) || Utils.boundsCollide(viewport, bounds)
),
@ -259,8 +261,6 @@ export class TranslateSession extends BaseSession {
// 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.prev = delta
// If cloning...
@ -287,7 +287,7 @@ export class TranslateSession extends BaseSession {
// Add the clones to the page
clones.forEach((clone) => {
nextShapes[clone.id] = { ...clone, point: Vec.round(Vec.add(clone.point, delta)) }
nextShapes[clone.id] = { ...clone }
// Add clones to non-selected parents
if (clone.parentId !== currentPageId && !selectedIds.includes(clone.parentId)) {
@ -313,13 +313,11 @@ export class TranslateSession extends BaseSession {
// Either way, move the clones
clones.forEach((clone) => {
const current = (nextShapes[clone.id] || this.app.getShape(clone.id)) as TDShape
if (!current.point) throw Error('No point on that clone!')
nextShapes[clone.id] = {
...clone,
point: Vec.round(Vec.add(current.point, movement)),
point: showGrid
? Vec.snap(Vec.toFixed(Vec.add(clone.point, delta)), currentGrid)
: Vec.toFixed(Vec.add(clone.point, delta)),
}
})
} else {
@ -327,14 +325,11 @@ export class TranslateSession extends BaseSession {
const { clones } = this.cloneInfo
// Either way, move the clones
clones.forEach((clone) => {
const current = (nextShapes[clone.id] || this.app.getShape(clone.id)) as TDShape
if (!current.point) throw Error('No point on that clone!')
nextShapes[clone.id] = {
point: Vec.round(Vec.add(current.point, movement)),
point: showGrid
? Vec.snap(Vec.toFixed(Vec.add(clone.point, delta)), currentGrid)
: Vec.toFixed(Vec.add(clone.point, delta)),
}
})
}
@ -350,7 +345,6 @@ export class TranslateSession extends BaseSession {
this.isCloning = false
// Delete the bindings
bindingsToDelete.forEach((binding) => (nextBindings[binding.id] = undefined))
// Remove the clones from parents
@ -369,7 +363,9 @@ export class TranslateSession extends BaseSession {
// Move the original shapes back to the cursor position
initialShapes.forEach((shape) => {
nextShapes[shape.id] = {
point: Vec.round(Vec.add(shape.point, delta)),
point: showGrid
? Vec.snap(Vec.toFixed(Vec.add(shape.point, delta)), currentGrid)
: Vec.toFixed(Vec.add(shape.point, delta)),
}
})
@ -380,18 +376,18 @@ export class TranslateSession extends BaseSession {
// Set selected ids
nextPageState.selectedIds = initialShapes.map((shape) => shape.id)
} else {
// Move the shapes by the delta
initialShapes.forEach((shape) => {
// const current = (nextShapes[shape.id] || this.app.getShape(shape.id)) as TDShape
nextShapes[shape.id] = {
point: showGrid
? Vec.snap(Vec.toFixed(Vec.add(shape.point, delta)), currentGrid)
: Vec.toFixed(Vec.add(shape.point, delta)),
}
})
}
// Move the shapes by the delta
initialShapes.forEach((shape) => {
const current = (nextShapes[shape.id] || this.app.getShape(shape.id)) as TDShape
if (!current.point) throw Error('No point on that clone!')
nextShapes[shape.id] = {
point: Vec.round(Vec.add(current.point, movement)),
}
})
}
return {
@ -696,6 +692,7 @@ export class TranslateSession extends BaseSession {
this.cloneInfo = {
state: 'ready',
clones,
cloneMap,
clonedBindings,
}
}

View file

@ -99,7 +99,7 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
const isDraw = style.dash === DashStyle.Draw
const isStraightLine = Vec.dist(bend.point, Vec.round(Vec.med(start.point, end.point))) < 1
const isStraightLine = Vec.dist(bend.point, Vec.toFixed(Vec.med(start.point, end.point))) < 1
const styles = getShapeStyle(style, meta.isDarkMode)
@ -122,7 +122,7 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
if (isStraightLine) {
const path = isDraw
? renderFreehandArrowShaft(shape)
: 'M' + Vec.round(start.point) + 'L' + Vec.round(end.point)
: 'M' + Vec.toFixed(start.point) + 'L' + Vec.toFixed(end.point)
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
arrowDist,
@ -398,11 +398,11 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
nextHandles['bend'] = {
...bend,
point: Vec.round(Math.abs(bendDist) < 10 ? midPoint : point),
point: Vec.toFixed(Math.abs(bendDist) < 10 ? midPoint : point),
}
return {
point: Vec.round([bounds.minX, bounds.minY]),
point: Vec.toFixed([bounds.minX, bounds.minY]),
handles: nextHandles,
}
}
@ -516,7 +516,7 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
return this.onHandleChange(shape, {
[handle.id]: {
...handle,
point: Vec.round(handlePoint),
point: Vec.toFixed(handlePoint),
},
})
}
@ -529,11 +529,11 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
...nextHandles,
start: {
...nextHandles.start,
point: Vec.round(nextHandles.start.point),
point: Vec.toFixed(nextHandles.start.point),
},
end: {
...nextHandles.end,
point: Vec.round(nextHandles.end.point),
point: Vec.toFixed(nextHandles.end.point),
},
}
@ -601,10 +601,10 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
if (!Vec.isEqual(offset, [0, 0])) {
Object.values(nextShape.handles).forEach((handle) => {
handle.point = Vec.round(Vec.sub(handle.point, offset))
handle.point = Vec.toFixed(Vec.sub(handle.point, offset))
})
nextShape.point = Vec.round(Vec.add(nextShape.point, offset))
nextShape.point = Vec.toFixed(Vec.add(nextShape.point, offset))
}
return nextShape

View file

@ -38,7 +38,7 @@ export function getBendPoint(handles: ArrowShape['handles'], bend: number) {
const u = Vec.uni(Vec.vec(start.point, end.point))
const point = Vec.round(
const point = Vec.toFixed(
Math.abs(bendDist) < 10 ? midPoint : Vec.add(midPoint, Vec.mul(Vec.per(u), bendDist))
)
@ -115,7 +115,7 @@ export function renderCurvedFreehandArrowShaft(
const angle = Utils.lerpAngles(startAngle, endAngle, t)
points.push(Vec.round(Vec.nudgeAtAngle(center, angle, radius)))
points.push(Vec.toFixed(Vec.nudgeAtAngle(center, angle, radius)))
}
const stroke = getStroke([startPoint, ...points, endPoint], {
@ -221,7 +221,7 @@ export function getArrowPath(shape: ArrowShape) {
const path: (string | number)[] = []
const isStraightLine = Vec.dist(_bend.point, Vec.round(Vec.med(start.point, end.point))) < 1
const isStraightLine = Vec.dist(_bend.point, Vec.toFixed(Vec.med(start.point, end.point))) < 1
if (isStraightLine) {
// Path (line segment)

View file

@ -331,7 +331,7 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
transformSingle = (shape: T, bounds: TLBounds): Partial<T> => {
return {
point: Vec.round([bounds.minX, bounds.minY]),
point: Vec.toFixed([bounds.minX, bounds.minY]),
radius: Vec.div([bounds.width, bounds.height], 2),
}
}

View file

@ -217,7 +217,7 @@ export class StickyUtil extends TDShapeUtil<T, E> {
bounds: TLBounds,
{ scaleX, scaleY, transformOrigin }: TransformInfo<T>
): Partial<T> => {
const point = Vec.round([
const point = Vec.toFixed([
bounds.minX +
(bounds.width - shape.size[0]) * (scaleX < 0 ? 1 - transformOrigin[0] : transformOrigin[0]),
bounds.minY +

View file

@ -285,7 +285,7 @@ export class TextUtil extends TDShapeUtil<T, E> {
} = initialShape
return {
point: Vec.round([bounds.minX, bounds.minY]),
point: Vec.toFixed([bounds.minX, bounds.minY]),
style: {
...initialShape.style,
scale: scale * Math.max(Math.abs(scaleY), Math.abs(scaleX)),
@ -309,7 +309,7 @@ export class TextUtil extends TDShapeUtil<T, E> {
...shape.style,
scale: 1,
},
point: Vec.round(Vec.add(shape.point, Vec.sub(center, newCenter))),
point: Vec.toFixed(Vec.add(shape.point, Vec.sub(center, newCenter))),
}
}

View file

@ -141,7 +141,8 @@ export function getStickyShapeStyle(style: ShapeStyles, isDarkMode = false) {
const { color } = style
const theme: Theme = isDarkMode ? 'dark' : 'light'
const adjustedColor = color === ColorStyle.Black ? ColorStyle.Yellow : color
const adjustedColor =
color === ColorStyle.White || color === ColorStyle.Black ? ColorStyle.Yellow : color
return {
fill: stickyFills[theme][adjustedColor],

View file

@ -13,9 +13,11 @@ export function transformRectangle<T extends TLShape & { size: number[] }>(
{ initialShape, transformOrigin, scaleX, scaleY }: TLTransformInfo<T>
) {
if (shape.rotation || initialShape.isAspectRatioLocked) {
const size = Vec.round(Vec.mul(initialShape.size, Math.min(Math.abs(scaleX), Math.abs(scaleY))))
const size = Vec.toFixed(
Vec.mul(initialShape.size, Math.min(Math.abs(scaleX), Math.abs(scaleY)))
)
const point = Vec.round([
const point = Vec.toFixed([
bounds.minX +
(bounds.width - shape.size[0]) * (scaleX < 0 ? 1 - transformOrigin[0] : transformOrigin[0]),
bounds.minY +
@ -37,8 +39,8 @@ export function transformRectangle<T extends TLShape & { size: number[] }>(
}
} else {
return {
point: Vec.round([bounds.minX, bounds.minY]),
size: Vec.round([bounds.width, bounds.height]),
point: Vec.toFixed([bounds.minX, bounds.minY]),
size: Vec.toFixed([bounds.width, bounds.height]),
}
}
}

View file

@ -11,7 +11,7 @@ export function transformSingleRectangle<T extends TLShape & { size: number[] }>
bounds: TLBounds
) {
return {
size: Vec.round([bounds.width, bounds.height]),
point: Vec.round([bounds.minX, bounds.minY]),
size: Vec.toFixed([bounds.width, bounds.height]),
point: Vec.toFixed([bounds.minX, bounds.minY]),
}
}

View file

@ -1,4 +1,5 @@
import { Utils, TLPointerEventHandler } from '@tldraw/core'
import Vec from '@tldraw/vec'
import { Arrow } from '~state/shapes'
import { SessionType, TDShapeType } from '~types'
import { BaseTool, Status } from '../BaseTool'
@ -13,6 +14,8 @@ export class ArrowTool extends BaseTool {
const {
currentPoint,
currentGrid,
settings: { showGrid },
appState: { currentPageId, currentStyle },
} = this.app
@ -24,7 +27,7 @@ export class ArrowTool extends BaseTool {
id,
parentId: currentPageId,
childIndex,
point: currentPoint,
point: showGrid ? Vec.snap(currentPoint, currentGrid) : currentPoint,
style: { ...currentStyle },
})

View file

@ -89,7 +89,6 @@ export abstract class BaseTool<T extends string = any> extends TDEventHandler {
return
}
/* noop */
if (key === 'Meta' || key === 'Control' || key === 'Alt') {
this.app.updateSession()
return
@ -97,7 +96,6 @@ export abstract class BaseTool<T extends string = any> extends TDEventHandler {
}
onKeyUp: TLKeyboardEventHandler = (key) => {
/* noop */
if (key === 'Meta' || key === 'Control' || key === 'Alt') {
this.app.updateSession()
return

View file

@ -1,4 +1,5 @@
import { Utils, TLPointerEventHandler, TLBoundsCorner } from '@tldraw/core'
import Vec from '@tldraw/vec'
import { Ellipse } from '~state/shapes'
import { SessionType, TDShapeType } from '~types'
import { BaseTool, Status } from '../BaseTool'
@ -13,6 +14,8 @@ export class EllipseTool extends BaseTool {
const {
currentPoint,
currentGrid,
settings: { showGrid },
appState: { currentPageId, currentStyle },
} = this.app
@ -24,7 +27,7 @@ export class EllipseTool extends BaseTool {
id,
parentId: currentPageId,
childIndex,
point: currentPoint,
point: showGrid ? Vec.snap(currentPoint, currentGrid) : currentPoint,
style: { ...currentStyle },
})

View file

@ -1,4 +1,5 @@
import { Utils, TLPointerEventHandler } from '@tldraw/core'
import Vec from '@tldraw/vec'
import { Arrow } from '~state/shapes'
import { SessionType, TDShapeType } from '~types'
import { BaseTool, Status } from '../BaseTool'
@ -13,6 +14,8 @@ export class LineTool extends BaseTool {
const {
currentPoint,
currentGrid,
settings: { showGrid },
appState: { currentPageId, currentStyle },
} = this.app
@ -24,7 +27,7 @@ export class LineTool extends BaseTool {
id,
parentId: currentPageId,
childIndex,
point: currentPoint,
point: showGrid ? Vec.snap(currentPoint, currentGrid) : currentPoint,
decorations: {
start: undefined,
end: undefined,

View file

@ -1,4 +1,5 @@
import { Utils, TLPointerEventHandler, TLBoundsCorner } from '@tldraw/core'
import Vec from '@tldraw/vec'
import { Rectangle } from '~state/shapes'
import { SessionType, TDShapeType } from '~types'
import { BaseTool, Status } from '../BaseTool'
@ -13,6 +14,8 @@ export class RectangleTool extends BaseTool {
const {
currentPoint,
currentGrid,
settings: { showGrid },
appState: { currentPageId, currentStyle },
} = this.app
@ -24,7 +27,7 @@ export class RectangleTool extends BaseTool {
id,
parentId: currentPageId,
childIndex,
point: currentPoint,
point: showGrid ? Vec.snap(currentPoint, currentGrid) : currentPoint,
style: { ...currentStyle },
})

View file

@ -65,9 +65,14 @@ describe('When double clicking link controls', () => {
const app = new TldrawTestApp()
.loadDocument(doc)
.select('rect2')
.pointBoundsHandle('center', { x: 0, y: 0 })
.pointBoundsHandle('center', [100, 100])
.expectShapesToBeAtPoints({
rect1: [0, 0],
rect2: [100, 0],
rect3: [200, 0],
})
app.movePointer({ x: 100, y: 100 }).expectShapesToBeAtPoints({
app.movePointer([200, 200]).expectShapesToBeAtPoints({
rect1: [100, 100],
rect2: [200, 100],
rect3: [300, 100],

View file

@ -26,6 +26,8 @@ export class StickyTool extends BaseTool {
if (this.status === Status.Idle) {
const {
currentPoint,
currentGrid,
settings: { showGrid },
appState: { currentPageId, currentStyle },
} = this.app
@ -39,7 +41,7 @@ export class StickyTool extends BaseTool {
id,
parentId: currentPageId,
childIndex,
point: currentPoint,
point: showGrid ? Vec.snap(currentPoint, currentGrid) : currentPoint,
style: { ...currentStyle },
})

View file

@ -1,4 +1,5 @@
import type { TLPointerEventHandler, TLKeyboardEventHandler } from '@tldraw/core'
import Vec from '@tldraw/vec'
import { TDShapeType } from '~types'
import { BaseTool, Status } from '../BaseTool'
@ -32,8 +33,13 @@ export class TextTool extends BaseTool {
}
if (this.status === Status.Idle) {
const { currentPoint } = this.app
this.app.createTextShapeAtPoint(currentPoint)
const {
currentPoint,
currentGrid,
settings: { showGrid },
} = this.app
this.app.createTextShapeAtPoint(showGrid ? Vec.snap(currentPoint, currentGrid) : currentPoint)
this.setStatus(Status.Creating)
return
}

View file

@ -162,4 +162,16 @@ export class TldrawTestApp extends TldrawApp {
})
return this
}
pressKey = (key: string) => {
const e = { key } as KeyboardEvent
this.onKeyDown(key, inputs.keydown(e), e)
return this
}
releaseKey = (key: string) => {
const e = { key } as KeyboardEvent
this.onKeyUp(key, inputs.keyup(e), e)
return this
}
}

View file

@ -1,8 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/ban-types */
import type { TLPage, TLUser, TLPageState } from '@tldraw/core'
import type { FileSystemHandle } from '~state/data/browser-fs-access'
import type {
TLPage,
TLUser,
TLPageState,
TLBinding,
TLBoundsCorner,
TLBoundsEdge,
@ -89,6 +90,7 @@ export interface TDSnapshot {
showRotateHandles: boolean
showBindingHandles: boolean
showCloneHandles: boolean
showGrid: boolean
}
appState: {
currentStyle: ShapeStyles
@ -470,3 +472,29 @@ export interface Command<T extends { [key: string]: any }> {
before: Patch<T>
after: Patch<T>
}
export interface FileWithHandle extends File {
handle?: FileSystemHandle
}
export interface FileWithDirectoryHandle extends File {
directoryHandle?: FileSystemHandle
}
// The following typings implement the relevant parts of the File System Access
// API. This can be removed once the specification reaches the Candidate phase
// and is implemented as part of microsoft/TSJS-lib-generator.
export interface FileSystemHandlePermissionDescriptor {
mode?: 'read' | 'readwrite'
}
export interface FileSystemHandle {
readonly kind: 'file' | 'directory'
readonly name: string
isSameEntry: (other: FileSystemHandle) => Promise<boolean>
queryPermission: (descriptor?: FileSystemHandlePermissionDescriptor) => Promise<PermissionState>
requestPermission: (descriptor?: FileSystemHandlePermissionDescriptor) => Promise<PermissionState>
}

View file

@ -11,12 +11,11 @@
"docs"
],
"compilerOptions": {
"rootDir": "src",
"baseUrl": "src",
"composite": false,
"incremental": false,
"declarationMap": false,
"sourceMap": false,
"emitDeclarationOnly": true
}
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"references": [{ "path": "../vec" }, { "path": "../intersect" }, { "path": "../core" }]
}

View file

@ -1,17 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "src/test/*.json"],
"exclude": ["node_modules", "dist", "docs"],
"include": ["src"],
"compilerOptions": {
"resolveJsonModule": true,
"outDir": "./dist/types",
"rootDir": "src",
"baseUrl": "src",
"baseUrl": ".",
"paths": {
"~*": ["./*"],
"@tldraw/core": ["../core"]
"~*": ["./src/*"]
}
},
"references": [{ "path": "../vec" }, { "path": "../intersect" }, { "path": "../core" }],
"typedocOptions": {
"entryPoints": ["src/index.ts"],
"out": "docs"

86
packages/vec/CHANGELOG.md Normal file
View file

@ -0,0 +1,86 @@
# Changelog
## 0.1.21
New:
- Adds the `isGhost` prop to `TLShape`. In `TLComponentProps`, the `isGhost` prop will be true if either a shape has its `isGhost` set to `true` OR if a shape is the descendant of a shape with `isGhost` set to `true`. A ghost shape will have the `tl-ghost` class name, though this is not used in the Renderer. You can set it yourself in your app.
- Adds the `isChildOfSelected` prop for `TLComponentProps`. If a shape is the child of a selected shape, its `isChildOfSelected` prop will be true.
Improved:
- Fixes a bug that could occur with the order of grouped shapes.
- Adds an Eraser tool to the advanced example.
- Adds a Pencil tool to the advanced example.
## 0.1.20
- Update docs.
- Adds `hideResizeHandles` prop.
## 0.1.19
- Remove stray `index.js` files.
## 0.1.18
- Even more dependency fixes.
## 0.1.17
- More dependency fixes.
## 0.1.16
- Fix dependencies, remove `@use-gesture/react` from bundle.
## 0.1.15
- Fix README.
## 0.1.14
- Add README to package.
## 0.1.13
- Remove `type` from `TLBinding`.
## 0.1.12
- Fix bug with initial bounds.
## 0.1.12
- Fix bug with initial bounds.
## 0.1.12
- Fix bug with bounds handle events.
## 0.1.11
- Fix bug with initial camera state.
## 0.1.10
- Improve example.
- Improve types for `TLPage`.
## 0.1.9
- Bug fixes.
## 0.1.8
- Expands README.
- Removes properties specific to the tldraw app.
## 0.1.7
- Fixes selection bug with SVGContainer.
- Removes various properties specific to the tldraw app.
## 0.1.0
- Re-writes API for ShapeUtils.

21
packages/vec/LICENSE.md Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Stephen Ruiz Ltd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

477
packages/vec/README.md Normal file
View file

@ -0,0 +1,477 @@
<div style="text-align: center; transform: scale(.5);">
<img src="card-repo.png"/>
</div>
# @tldraw/core
This package contains the `Renderer` and core utilities used by [tldraw](https://tldraw.com).
You can use this package to build projects like [tldraw](https://tldraw.com), where React components are rendered on a canvas user interface. Check out the [advanced example](https://core-steveruiz.vercel.app/).
💕 Love this library? Consider [becoming a sponsor](https://github.com/sponsors/steveruizok?frequency=recurring&sponsor=steveruizok).
## Installation
Use your package manager of choice to install `@tldraw/core` and its peer dependencies.
```bash
yarn add @tldraw/core
# or
npm i @tldraw/core
```
## Examples
There are two examples in this repository.
The **simple** example in the `example` folder shows a minimal use of the library. It does not do much but this should be a good reference for the API without too much else built on top.
The **advanced** example in the `example-advanced` folder shows a more realistic use of the library. (Try it [here](https://core-steveruiz.vercel.app/)). While the fundamental patterns are the same, this example contains features such as: panning, pinching, and zooming the camera; creating, cloning, resizing, and deleting shapes; keyboard shortcuts, brush-selection; shape-snapping; undo, redo; and more. Much of the code in the advanced example comes from the [@tldraw/tldraw](https://tldraw.com) codebase.
If you're working on an app that uses this library, I recommend referring back to the advanced example for tips on how you might implement these features for your own project.
## Usage
Import the `Renderer` React component and pass it the required props.
```tsx
import * as React from "react"
import { Renderer, TLShape, TLShapeUtil, Vec } from '@tldraw/core'
import { BoxShape, BoxUtil } from "./shapes/box"
const shapeUtils = { box: new BoxUtil() }
function App() {
const [page, setPage] = React.useState({
id: "page"
shapes: {
"box1": {
id: 'box1',
type: 'box',
parentId: 'page',
childIndex: 0,
point: [0, 0],
size: [100, 100],
rotation: 0,
}
},
bindings: {}
})
const [pageState, setPageState] = React.useState({
id: "page",
selectedIds: [],
camera: {
point: [0,0],
zoom: 1
}
})
return (<Renderer
page={page}
pageState={pageState}
shapeUtils={shapeUtils}
/>)
}
```
## Documentation
### `Renderer`
To avoid unnecessary renders, be sure to pass "stable" values as props to the `Renderer`. Either define these values outside of the parent component, or place them in React state, or memoize them with `React.useMemo`.
| Prop | Type | Description |
| ------------ | ------------------------------- | ---------------------------------------------- |
| `page` | [`TLPage`](#tlpage) | The current page object. |
| `pageState` | [`TLPageState`](#tlpagestate) | The current page's state. |
| `shapeUtils` | [`TLShapeUtils`](#tlshapeutils) | The shape utilities used to render the shapes. |
In addition to these required props, the Renderer accents many other **optional** props.
| Property | Type | Description |
| -------------------- | ----------------------------- | ----------------------------------------------------------------- |
| `containerRef` | `React.MutableRefObject` | A React ref for the container, where CSS variables will be added. |
| `theme` | `object` | An object with overrides for the Renderer's default colors. |
| `hideBounds` | `boolean` | Do not show the bounding box for selected shapes. |
| `hideHandles` | `boolean` | Do not show handles for shapes with handles. |
| `hideBindingHandles` | `boolean` | Do not show binding controls for selected shapes with bindings. |
| `hideResizeHandles` | `boolean` | Do not show resize handles for selected shapes. |
| `hideRotateHandles` | `boolean` | Do not show rotate handles for selected shapes. |
| `snapLines` | [`TLSnapLine`](#tlsnapline)[] | An array of "snap" lines. |
| `users` | `object` | A table of [`TLUser`](#tluser)s. |
| `userId` | `object` | The current user's [`TLUser`](#tluser) id. |
The theme object accepts valid CSS colors for the following properties:
| Property | Description |
| -------------- | ---------------------------------------------------- |
| `foreground` | The primary (usually "text") color |
| `background` | The default page's background color |
| `brushFill` | The fill color of the brush selection box |
| `brushStroke` | The stroke color of the brush selection box |
| `selectFill` | The fill color of the selection bounds |
| `selectStroke` | The stroke color of the selection bounds and handles |
The Renderer also accepts many (optional) event callbacks.
| Prop | Description |
| --------------------------- | ----------------------------------------------------------- |
| `onPan` | Panned with the mouse wheel |
| `onZoom` | Zoomed with the mouse wheel |
| `onPinchStart` | Began a two-pointer pinch |
| `onPinch` | Moved their pointers during a pinch |
| `onPinchEnd` | Stopped a two-pointer pinch |
| `onPointerDown` | Started pointing |
| `onPointerMove` | Moved their pointer |
| `onPointerUp` | Ended a point |
| `onPointCanvas` | Pointed the canvas |
| `onDoubleClickCanvas` | Double-pointed the canvas |
| `onRightPointCanvas` | Right-pointed the canvas |
| `onDragCanvas` | Dragged the canvas |
| `onReleaseCanvas` | Stopped pointing the canvas |
| `onHoverShape` | Moved their pointer onto a shape |
| `onUnhoverShape` | Moved their pointer off of a shape |
| `onPointShape` | Pointed a shape |
| `onDoubleClickShape` | Double-pointed a shape |
| `onRightPointShape` | Right-pointed a shape |
| `onDragShape` | Dragged a shape |
| `onReleaseShape` | Stopped pointing a shape |
| `onHoverHandle` | Moved their pointer onto a shape handle |
| `onUnhoverHandle` | Moved their pointer off of a shape handle |
| `onPointHandle` | Pointed a shape handle |
| `onDoubleClickHandle` | Double-pointed a shape handle |
| `onRightPointHandle` | Right-pointed a shape handle |
| `onDragHandle` | Dragged a shape handle |
| `onReleaseHandle` | Stopped pointing shape handle |
| `onHoverBounds` | Moved their pointer onto the selection bounds |
| `onUnhoverBounds` | Moved their pointer off of the selection bounds |
| `onPointBounds` | Pointed the selection bounds |
| `onDoubleClickBounds` | Double-pointed the selection bounds |
| `onRightPointBounds` | Right-pointed the selection bounds |
| `onDragBounds` | Dragged the selection bounds |
| `onReleaseBounds` | Stopped the selection bounds |
| `onHoverBoundsHandle` | Moved their pointer onto a selection bounds handle |
| `onUnhoverBoundsHandle` | Moved their pointer off of a selection bounds handle |
| `onPointBoundsHandle` | Pointed a selection bounds handle |
| `onDoubleClickBoundsHandle` | Double-pointed a selection bounds handle |
| `onRightPointBoundsHandle` | Right-pointed a selection bounds handle |
| `onDragBoundsHandle` | Dragged a selection bounds handle |
| `onReleaseBoundsHandle` | Stopped a selection bounds handle |
| `onShapeClone` | Clicked on a shape's clone handle |
| `onShapeChange` | A shape's component prompted a change |
| `onShapeBlur` | A shape's component was prompted a blur |
| `onRenderCountChange` | The number of rendered shapes changed |
| `onBoundsChange` | The Renderer's screen bounding box of the component changed |
| `onError` | The Renderer encountered an error |
The `@tldraw/core` library provides types for most of the event handlers:
| Type |
| ---------------------------- |
| `TLPinchEventHandler` |
| `TLPointerEventHandler` |
| `TLCanvasEventHandler` |
| `TLBoundsEventHandler` |
| `TLBoundsHandleEventHandler` |
| `TLShapeChangeHandler` |
| `TLShapeBlurHandler` |
| `TLShapeCloneHandler` |
### `TLPage`
An object describing the current page. It contains:
| Property | Type | Description |
| ----------------- | --------------------------- | --------------------------------------------------------------------------- |
| `id` | `string` | A unique id for the page. |
| `shapes` | [`TLShape{}`](#tlshape) | A table of shapes. |
| `bindings` | [`TLBinding{}`](#tlbinding) | A table of bindings. |
| `backgroundColor` | `string` | (optional) The page's background fill color. Will also overwrite the theme. |
### `TLPageState`
An object describing the current page. It contains:
| Property | Type | Description |
| -------------- | ---------- | --------------------------------------------------- |
| `id` | `string` | The corresponding page's id |
| `selectedIds` | `string[]` | An array of selected shape ids |
| `camera` | `object` | An object describing the camera state |
| `camera.point` | `number[]` | The camera's `[x, y]` coordinates |
| `camera.zoom` | `number` | The camera's zoom level |
| `pointedId` | `string` | (optional) The currently pointed shape id |
| `hoveredId` | `string` | (optional) The currently hovered shape id |
| `editingId` | `string` | (optional) The currently editing shape id |
| `bindingId` | `string` | (optional) The currently editing binding. |
| `brush` | `TLBounds` | (optional) A `Bounds` for the current selection box |
### `TLShape`
An object that describes a shape on the page. The shapes in your document should extend this interface with other properties. See [Shape Type](#shape-type).
| Property | Type | Description |
| --------------------- | ---------- | ------------------------------------------------------------------------------------- |
| `id` | `string` | The shape's id. |
| `type` | `string` | The type of the shape, corresponding to the `type` of a [`TLShapeUtil`](#tlshapeutil) |
| `parentId` | `string` | The id of the shape's parent (either the current page or another shape) |
| `childIndex` | `number` | the order of the shape among its parent's children |
| `name` | `string` | the name of the shape |
| `point` | `number[]` | the shape's current `[x, y]` coordinates on the page |
| `rotation` | `number` | (optiona) The shape's current rotation in radians |
| `children` | `string[]` | (optional) An array containing the ids of this shape's children |
| `handles` | `{}` | (optional) A table of [`TLHandle`](#tlhandle) objects |
| `isGhost` | `boolean` | (optional) True if the shape is "ghosted", e.g. while deleting |
| `isLocked` | `boolean` | (optional) True if the shape is locked |
| `isHidden` | `boolean` | (optional) True if the shape is hidden |
| `isEditing` | `boolean` | (optional) True if the shape is currently editing |
| `isGenerated` | `boolean` | optional) True if the shape is generated programatically |
| `isAspectRatioLocked` | `boolean` | (optional) True if the shape's aspect ratio is locked |
### `TLHandle`
An object that describes a relationship between two shapes on the page.
| Property | Type | Description |
| -------- | ---------- | --------------------------------------------- |
| `id` | `string` | An id for the handle |
| `index` | `number` | The handle's order within the shape's handles |
| `point` | `number[]` | The handle's `[x, y]` coordinates |
When a shape with handles is the only selected shape, the `Renderer` will display its handles. You can respond to interactions with these handles using the `on
### `TLBinding`
An object that describes a relationship between two shapes on the page.
| Property | Type | Description |
| -------- | -------- | -------------------------------------------- |
| `id` | `string` | A unique id for the binding |
| `fromId` | `string` | The id of the shape where the binding begins |
| `toId` | `string` | The id of the shape where the binding begins |
### `TLSnapLine`
A snapline is an array of points (formatted as `[x, y]`) that represent a "snapping" line.
### `TLShapeUtil`
The `TLShapeUtil` is an abstract class that you can extend to create utilities for your custom shapes. See the [Creating Shapes](#creating-shapes) guide to learn more.
### `TLUser`
A `TLUser` is the presence information for a multiplayer user. The user's pointer location and selections will be shown on the canvas. If the `TLUser`'s id matches the `Renderer`'s `userId` prop, then the user's cursor and selections will not be shown.
| Property | Type | Description |
| --------------- | ---------- | --------------------------------------- |
| `id` | `string` | A unique id for the user |
| `color` | `string` | The user's color, used for indicators |
| `point` | `number[]` | The user's pointer location on the page |
| `selectedIds[]` | `string[]` | The user's selected shape ids |
### `Utils`
A general purpose utility class. See source for more.
## Guide: Creating Shapes
The `Renderer` component has no built-in shapes. It's up to you to define every shape that you want to see on the canvas. While these shapes are highly reusable between projects, you'll need to define them using the API described below.
> For several example shapes, see the folder `/example/src/shapes/`.
### Shape Type
Your first task is to define an interface for the shape that extends `TLShape`. It must have a `type` property.
```ts
// BoxShape.ts
import type { TLShape } from '@tldraw/core'
export interface BoxShape extends TLShape {
type: 'box'
size: number[]
}
```
### Component
Next, use `TLShapeUtil.Component` to create a second component for your shape's `Component`. The `Renderer` will use this component to display the shape on the canvas.
```tsx
// BoxComponent.ts
import * as React from 'react'
import { shapeComponent, SVGContainer } from '@tldraw/core'
import type { BoxShape } from './BoxShape'
export const BoxComponent = TLShapeUtil.Component<BoxShape, SVGSVGElement>(
({ shape, events, meta }, ref) => {
const color = meta.isDarkMode ? 'white' : 'black'
return (
<SVGContainer ref={ref} {...events}>
<rect
width={shape.size[0]}
height={shape.size[1]}
stroke={color}
strokeWidth={2}
strokeLinejoin="round"
fill="none"
pointerEvents="all"
/>
</SVGContainer>
)
}
)
```
Your component can return HTML elements or SVG elements. If your shape is returning only SVG elements, wrap it in an `SVGContainer`. If your shape returns HTML elements, wrap it in an `HTMLContainer`. Not that you must set `pointerEvents` manually on the shapes you wish to receive pointer events.
The component will receive the following props:
| Name | Type | Description |
| ------------------- | ---------- | ------------------------------------------------------------------ |
| `shape` | `TLShape` | The shape from `page.shapes` that is being rendered |
| `meta` | `{}` | The value provided to the `Renderer`'s `meta` prop |
| `events` | `{}` | Several pointer events that should be set on the container element |
| `isSelected` | `boolean` | The shape is selected (its `id` is in `pageState.selectedIds`) |
| `isHovered` | `boolean` | The shape is hovered (its `id` is `pageState.hoveredId`) |
| `isEditing` | `boolean` | The shape is being edited (its `id` is `pageState.editingId`) |
| `isGhost` | `boolean` | The shape is ghosted or is the child of a ghosted shape. |
| `isChildOfSelected` | `boolean` | The shape is the child of a selected shape. |
| `onShapeChange` | `Function` | The callback provided to the `Renderer`'s `onShapeChange` prop |
| `onShapeBlur` | `Function` | The callback provided to the `Renderer`'s `onShapeBlur` prop |
### Indicator
Next, use `TLShapeUtil.Indicator` to create a second component for your shape's `Indicator`. This component is shown when the shape is hovered or selected. Your `Indicator` must return SVG elements only.
```tsx
// BoxIndicator.ts
export const BoxIndicator = TLShapeUtil.Indicator<BoxShape>(({ shape }) => {
return (
<rect
fill="none"
stroke="dodgerblue"
strokeWidth={1}
width={shape.size[0]}
height={shape.size[1]}
/>
)
})
```
The indicator component will receive the following props:
| Name | Type | Description |
| ------------ | --------- | -------------------------------------------------------------------------------------- |
| `shape` | `TLShape` | The shape from `page.shapes` that is being rendered |
| `meta` | {} | The value provided to the `Renderer`'s `meta` prop |
| `user` | `TLUser` | The user when shown in a multiplayer session |
| `isSelected` | `boolean` | Whether the current shape is selected (true if its `id` is in `pageState.selectedIds`) |
| `isHovered` | `boolean` | Whether the current shape is hovered (true if its `id` is `pageState.hoveredId`) |
### ShapeUtil
Next, create a "shape util" for your shape. This is a class that extends `TLShapeUtil`. The `Renderer` will use an instance of this class to answer questions about the shape: what it should look like, where it is on screen, whether it can rotate, etc.
```ts
// BoxUtil.ts
import { Utils, TLBounds, TLShapeUtil } from '@tldraw/core'
import { BoxComponent } from './BoxComponent'
import { BoxIndicator } from './BoxIndicator'
import type { BoxShape } from './BoxShape'
export class BoxUtil extends TLShapeUtil<BoxShape, SVGSVGElement> {
Component = BoxComponent
Indicator = BoxIndicator
getBounds = (shape: BoxShape): TLBounds => {
const [width, height] = shape.size
const bounds = {
minX: 0,
maxX: width,
minY: 0,
maxY: height,
width,
height,
}
return Utils.translateBounds(bounds, shape.point)
}
}
```
Set the `Component` field to your component and the `Indicator` field to your indicator component. Then define the `getBounds` method. This method will receive a shape and should return a `TLBounds` object.
You may also set the following fields:
| Name | Type | Default | Description |
| ------------------ | --------- | ------- | ----------------------------------------------------------------------------------------------------- |
| `showCloneHandles` | `boolean` | `false` | Whether to display clone handles when the shape is the only selected shape |
| `hideBounds` | `boolean` | `false` | Whether to hide the bounds when the shape is the only selected shape |
| `isStateful` | `boolean` | `false` | Whether the shape has its own React state. When true, the shape will not be unmounted when off-screen |
### ShapeUtils Object
Finally, create a mapping of your project's shape utils and the `type` properties of their corresponding shapes. Pass this object to the `Renderer`'s `shapeUtils` prop.
```tsx
// App.tsx
const shapeUtils = {
box: new BoxUtil(),
circle: new CircleUtil(),
text: new TextUtil(),
}
export function App() {
// ...
return <Renderer page={page} pageState={pageState} {...etc} shapeUtils={shapeUtils} />
}
```
## Local Development
To start the development servers for the package and the advanced example:
- Run `yarn` to install dependencies.
- Run `yarn start`.
- Open `localhost:5420`.
You can also run:
- `start:advanced` to start development servers for the package and the advanced example.
- `start:simple` to start development servers for the package and the simple example.
- `test` to execute unit tests via [Jest](https://jestjs.io).
- `docs` to build the docs via [ts-doc](https://typedoc.org/).
- `build` to build the package.
## Example
See the `example` folder or this [CodeSandbox](https://codesandbox.io/s/laughing-elion-gp0kx) example.
## Community
### Support
Need help? Please [open an issue](https://github.com/tldraw/tldraw/issues/new) for support.
### Discussion
Want to connect with other devs? Visit the [Discord channel](https://discord.gg/SBBEVCA4PG).
### License
This project is licensed under MIT.
If you're using the library in a commercial product, please consider [becoming a sponsor](https://github.com/sponsors/steveruizok?frequency=recurring&sponsor=steveruizok).
## Author
- [@steveruizok](https://twitter.com/steveruizok)

BIN
packages/vec/card-repo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

35
packages/vec/package.json Normal file
View file

@ -0,0 +1,35 @@
{
"version": "1.1.4",
"name": "@tldraw/vec",
"description": "2D vector utilities for TLDraw and maybe you, too.",
"author": "@steveruizok",
"repository": {
"type": "git",
"url": "git+https://github.com/tldraw/tldraw.git"
},
"license": "MIT",
"keywords": [
"2d",
"vector",
"typescript",
"javascript"
],
"files": [
"dist/**/*"
],
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts",
"scripts": {
"start:packages": "yarn start",
"start:core": "yarn start",
"start": "node scripts/dev & yarn types:dev",
"build:core": "yarn build",
"build:packages": "yarn build",
"build": "node scripts/build && yarn types:build",
"types:dev": "tsc -w --p tsconfig.build.json",
"types:build": "tsc -p tsconfig.build.json",
"lint": "eslint src/ --ext .ts,.tsx",
"clean": "rm -rf dist"
}
}

View file

@ -0,0 +1,61 @@
/* eslint-disable */
const fs = require('fs')
const esbuild = require('esbuild')
const { gzip } = require('zlib')
const pkg = require('../package.json')
async function main() {
if (fs.existsSync('./dist')) {
fs.rmSync('./dist', { recursive: true }, (e) => {
if (e) {
throw e
}
})
}
try {
esbuild.buildSync({
entryPoints: ['./src/index.ts'],
outdir: 'dist/cjs',
minify: false,
bundle: true,
format: 'cjs',
target: 'es6',
tsconfig: './tsconfig.build.json',
metafile: false,
sourcemap: true,
})
const esmResult = esbuild.buildSync({
entryPoints: ['./src/index.ts'],
outdir: 'dist/esm',
minify: false,
bundle: true,
format: 'esm',
target: 'es6',
tsconfig: './tsconfig.build.json',
metafile: true,
sourcemap: true,
})
let esmSize = 0
Object.values(esmResult.metafile.outputs).forEach((output) => {
esmSize += output.bytes
})
fs.readFile('./dist/esm/index.js', (_err, data) => {
gzip(data, (_err, result) => {
console.log(
`${pkg.name}: Built package. ${(esmSize / 1000).toFixed(2)}kb (${(
result.length / 1000
).toFixed(2)}kb minified)`
)
})
})
} catch (e) {
console.log(`× ${pkg.name}: Build failed due to an error.`)
console.log(e)
}
}
main()

View file

@ -0,0 +1,29 @@
/* eslint-disable */
const esbuild = require('esbuild')
const pkg = require('../package.json')
async function main() {
try {
await esbuild.build({
entryPoints: ['src/index.tsx'],
outfile: 'dist/index.js',
bundle: true,
minify: false,
sourcemap: true,
incremental: true,
target: ['chrome58', 'firefox57', 'safari11', 'edge18'],
define: {
'process.env.NODE_ENV': '"development"',
},
watch: {
onRebuild(err) {
err ? error('❌ Failed') : log('✅ Updated')
},
},
})
} catch (err) {
process.exit(1)
}
}
main()

311
packages/vec/src/index.d.ts vendored Normal file
View file

@ -0,0 +1,311 @@
export declare class Vec {
/**
* Clamp a value into a range.
* @param n
* @param min
*/
static clamp(n: number, min: number): number;
static clamp(n: number, min: number, max: number): number;
/**
* Clamp a value into a range.
* @param n
* @param min
*/
static clampV(A: number[], min: number): number[];
static clampV(A: number[], min: number, max: number): number[];
/**
* Negate a vector.
* @param A
*/
static neg: (A: number[]) => number[];
/**
* Add vectors.
* @param A
* @param B
*/
static add: (A: number[], B: number[]) => number[];
/**
* Add scalar to vector.
* @param A
* @param B
*/
static addScalar: (A: number[], n: number) => number[];
/**
* Subtract vectors.
* @param A
* @param B
*/
static sub: (A: number[], B: number[]) => number[];
/**
* Subtract scalar from vector.
* @param A
* @param B
*/
static subScalar: (A: number[], n: number) => number[];
/**
* Get the vector from vectors A to B.
* @param A
* @param B
*/
static vec: (A: number[], B: number[]) => number[];
/**
* Vector multiplication by scalar
* @param A
* @param n
*/
static mul: (A: number[], n: number) => number[];
/**
* Multiple two vectors.
* @param A
* @param B
*/
static mulV: (A: number[], B: number[]) => number[];
/**
* Vector division by scalar.
* @param A
* @param n
*/
static div: (A: number[], n: number) => number[];
/**
* Vector division by vector.
* @param A
* @param n
*/
static divV: (A: number[], B: number[]) => number[];
/**
* Perpendicular rotation of a vector A
* @param A
*/
static per: (A: number[]) => number[];
/**
* Dot product
* @param A
* @param B
*/
static dpr: (A: number[], B: number[]) => number;
/**
* Cross product (outer product) | A X B |
* @param A
* @param B
*/
static cpr: (A: number[], B: number[]) => number;
/**
* Cross (for point in polygon)
*
*/
static cross(x: number[], y: number[], z: number[]): number;
/**
* Length of the vector squared
* @param A
*/
static len2: (A: number[]) => number;
/**
* Length of the vector
* @param A
*/
static len: (A: number[]) => number;
/**
* Project A over B
* @param A
* @param B
*/
static pry: (A: number[], B: number[]) => number;
/**
* Get normalized / unit vector.
* @param A
*/
static uni: (A: number[]) => number[];
/**
* Get normalized / unit vector.
* @param A
*/
static normalize: (A: number[]) => number[];
/**
* Get the tangent between two vectors.
* @param A
* @param B
* @returns
*/
static tangent: (A: number[], B: number[]) => number[];
/**
* Dist length from A to B squared.
* @param A
* @param B
*/
static dist2: (A: number[], B: number[]) => number;
/**
* Dist length from A to B
* @param A
* @param B
*/
static dist: (A: number[], B: number[]) => number;
/**
* A faster, though less accurate method for testing distances. Maybe faster?
* @param A
* @param B
* @returns
*/
static fastDist: (A: number[], B: number[]) => number[];
/**
* Angle between vector A and vector B in radians
* @param A
* @param B
*/
static ang: (A: number[], B: number[]) => number;
/**
* Angle between vector A and vector B in radians
* @param A
* @param B
*/
static angle: (A: number[], B: number[]) => number;
/**
* Mean between two vectors or mid vector between two vectors
* @param A
* @param B
*/
static med: (A: number[], B: number[]) => number[];
/**
* Vector rotation by r (radians)
* @param A
* @param r rotation in radians
*/
static rot: (A: number[], r?: number) => number[];
/**
* Rotate a vector around another vector by r (radians)
* @param A vector
* @param C center
* @param r rotation in radians
*/
static rotWith: (A: number[], C: number[], r?: number) => number[];
/**
* Check of two vectors are identical.
* @param A
* @param B
*/
static isEqual: (A: number[], B: number[]) => boolean;
/**
* Interpolate vector A to B with a scalar t
* @param A
* @param B
* @param t scalar
*/
static lrp: (A: number[], B: number[], t: number) => number[];
/**
* Interpolate from A to B when curVAL goes fromVAL: number[] => to
* @param A
* @param B
* @param from Starting value
* @param to Ending value
* @param s Strength
*/
static int: (A: number[], B: number[], from: number, to: number, s?: number) => number[];
/**
* Get the angle between the three vectors A, B, and C.
* @param p1
* @param pc
* @param p2
*/
static ang3: (p1: number[], pc: number[], p2: number[]) => number;
/**
* Absolute value of a vector.
* @param A
* @returns
*/
static abs: (A: number[]) => number[];
static rescale: (a: number[], n: number) => number[];
/**
* Get whether p1 is left of p2, relative to pc.
* @param p1
* @param pc
* @param p2
*/
static isLeft: (p1: number[], pc: number[], p2: number[]) => number;
/**
* Get whether p1 is left of p2, relative to pc.
* @param p1
* @param pc
* @param p2
*/
static clockwise: (p1: number[], pc: number[], p2: number[]) => boolean;
/**
* Round a vector to the a given precision.
* @param a
* @param d
*/
static toFixed: (a: number[], d?: number) => number[];
/**
* Snap vector to nearest step.
* @param A
* @param step
* @example
* ```ts
* Vec.snap([10.5, 28], 10) // [10, 30]
* ```
*/
static snap(a: number[], step?: number): number[];
/**
* Get the nearest point on a line with a known unit vector that passes through point A
* @param A Any point on the line
* @param u The unit vector for the line.
* @param P A point not on the line to test.
* @returns
*/
static nearestPointOnLineThroughPoint: (A: number[], u: number[], P: number[]) => number[];
/**
* Distance between a point and a line with a known unit vector that passes through a point.
* @param A Any point on the line
* @param u The unit vector for the line.
* @param P A point not on the line to test.
* @returns
*/
static distanceToLineThroughPoint: (A: number[], u: number[], P: number[]) => number;
/**
* Get the nearest point on a line segment between A and B
* @param A The start of the line segment
* @param B The end of the line segment
* @param P The off-line point
* @param clamp Whether to clamp the point between A and B.
* @returns
*/
static nearestPointOnLineSegment: (A: number[], B: number[], P: number[], clamp?: boolean) => number[];
/**
* Distance between a point and the nearest point on a line segment between A and B
* @param A The start of the line segment
* @param B The end of the line segment
* @param P The off-line point
* @param clamp Whether to clamp the point between A and B.
* @returns
*/
static distanceToLineSegment: (A: number[], B: number[], P: number[], clamp?: boolean) => number;
/**
* Push a point A towards point B by a given distance.
* @param A
* @param B
* @param d
* @returns
*/
static nudge: (A: number[], B: number[], d: number) => number[];
/**
* Push a point in a given angle by a given distance.
* @param A
* @param B
* @param d
*/
static nudgeAtAngle: (A: number[], a: number, d: number) => number[];
/**
* Round a vector to a precision length.
* @param a
* @param n
*/
static toPrecision: (a: number[], n?: number) => number[];
/**
* Get an array of points (with simulated pressure) between two points.
* @param A The first point.
* @param B The second point.
* @param steps The number of points to return.
* @param ease An easing function to apply to the simulated pressure.
*/
static pointsBetween: (A: number[], B: number[], steps?: number) => number[][];
}
export default Vec;
//# sourceMappingURL=index.d.ts.map

View file

@ -0,0 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,qBAAa,GAAG;IACd;;;;OAIG;IACH,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM;IAC5C,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM;IAKzD;;;;OAIG;IACH,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE;IACjD,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE;IAK9D;;;OAGG;IACH,MAAM,CAAC,GAAG,MAAO,MAAM,EAAE,KAAG,MAAM,EAAE,CAEnC;IAED;;;;OAIG;IACH,MAAM,CAAC,GAAG,MAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAG,MAAM,EAAE,CAEhD;IAED;;;;OAIG;IACH,MAAM,CAAC,SAAS,MAAO,MAAM,EAAE,KAAK,MAAM,KAAG,MAAM,EAAE,CAEpD;IAED;;;;OAIG;IACH,MAAM,CAAC,GAAG,MAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAG,MAAM,EAAE,CAEhD;IAED;;;;OAIG;IACH,MAAM,CAAC,SAAS,MAAO,MAAM,EAAE,KAAK,MAAM,KAAG,MAAM,EAAE,CAEpD;IAED;;;;OAIG;IACH,MAAM,CAAC,GAAG,MAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAG,MAAM,EAAE,CAGhD;IAED;;;;OAIG;IACH,MAAM,CAAC,GAAG,MAAO,MAAM,EAAE,KAAK,MAAM,KAAG,MAAM,EAAE,CAE9C;IAED;;;;OAIG;IACH,MAAM,CAAC,IAAI,MAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAG,MAAM,EAAE,CAEjD;IAED;;;;OAIG;IACH,MAAM,CAAC,GAAG,MAAO,MAAM,EAAE,KAAK,MAAM,KAAG,MAAM,EAAE,CAE9C;IAED;;;;OAIG;IACH,MAAM,CAAC,IAAI,MAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAG,MAAM,EAAE,CAEjD;IAED;;;OAGG;IACH,MAAM,CAAC,GAAG,MAAO,MAAM,EAAE,KAAG,MAAM,EAAE,CAEnC;IAED;;;;OAIG;IACH,MAAM,CAAC,GAAG,MAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAG,MAAM,CAE9C;IAED;;;;OAIG;IACH,MAAM,CAAC,GAAG,MAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAG,MAAM,CAE9C;IAED;;;OAGG;IACH,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM;IAI3D;;;OAGG;IACH,MAAM,CAAC,IAAI,MAAO,MAAM,EAAE,KAAG,MAAM,CAElC;IAED;;;OAGG;IACH,MAAM,CAAC,GAAG,MAAO,MAAM,EAAE,KAAG,MAAM,CAEjC;IAED;;;;OAIG;IACH,MAAM,CAAC,GAAG,MAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAG,MAAM,CAE9C;IAED;;;OAGG;IACH,MAAM,CAAC,GAAG,MAAO,MAAM,EAAE,KAAG,MAAM,EAAE,CAEnC;IAED;;;OAGG;IACH,MAAM,CAAC,SAAS,MAAO,MAAM,EAAE,KAAG,MAAM,EAAE,CAEzC;IAED;;;;;OAKG;IACH,MAAM,CAAC,OAAO,MAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAG,MAAM,EAAE,CAEpD;IAED;;;;OAIG;IACH,MAAM,CAAC,KAAK,MAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAG,MAAM,CAEhD;IAED;;;;OAIG;IACH,MAAM,CAAC,IAAI,MAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAG,MAAM,CAE/C;IAED;;;;;OAKG;IACH,MAAM,CAAC,QAAQ,MAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAG,MAAM,EAAE,CAMrD;IAED;;;;OAIG;IACH,MAAM,CAAC,GAAG,MAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAG,MAAM,CAE9C;IAED;;;;OAIG;IACH,MAAM,CAAC,KAAK,MAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAG,MAAM,CAEhD;IAED;;;;OAIG;IACH,MAAM,CAAC,GAAG,MAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAG,MAAM,EAAE,CAEhD;IAED;;;;OAIG;IACH,MAAM,CAAC,GAAG,MAAO,MAAM,EAAE,iBAAU,MAAM,EAAE,CAE1C;IAED;;;;;OAKG;IACH,MAAM,CAAC,OAAO,MAAO,MAAM,EAAE,KAAK,MAAM,EAAE,iBAAU,MAAM,EAAE,CAa3D;IAED;;;;OAIG;IACH,MAAM,CAAC,OAAO,MAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAG,OAAO,CAEnD;IAED;;;;;OAKG;IACH,MAAM,CAAC,GAAG,MAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAK,MAAM,KAAG,MAAM,EAAE,CAE3D;IAED;;;;;;;OAOG;IACH,MAAM,CAAC,GAAG,MAAO,MAAM,EAAE,KAAK,MAAM,EAAE,QAAQ,MAAM,MAAM,MAAM,iBAAU,MAAM,EAAE,CAGjF;IAED;;;;;OAKG;IACH,MAAM,CAAC,IAAI,OAAQ,MAAM,EAAE,MAAM,MAAM,EAAE,MAAM,MAAM,EAAE,KAAG,MAAM,CAK/D;IAED;;;;OAIG;IACH,MAAM,CAAC,GAAG,MAAO,MAAM,EAAE,KAAG,MAAM,EAAE,CAEnC;IAED,MAAM,CAAC,OAAO,MAAO,MAAM,EAAE,KAAK,MAAM,KAAG,MAAM,EAAE,CAGlD;IAED;;;;;OAKG;IACH,MAAM,CAAC,MAAM,OAAQ,MAAM,EAAE,MAAM,MAAM,EAAE,MAAM,MAAM,EAAE,KAAG,MAAM,CAKjE;IAED;;;;;OAKG;IACH,MAAM,CAAC,SAAS,OAAQ,MAAM,EAAE,MAAM,MAAM,EAAE,MAAM,MAAM,EAAE,KAAG,OAAO,CAErE;IAED;;;;OAIG;IACH,MAAM,CAAC,OAAO,MAAO,MAAM,EAAE,iBAAU,MAAM,EAAE,CAE9C;IAED;;;;;;;;OAQG;IACH,MAAM,CAAC,IAAI,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,IAAI,SAAI;IAIjC;;;;;;OAMG;IACH,MAAM,CAAC,8BAA8B,MAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAK,MAAM,EAAE,KAAG,MAAM,EAAE,CAExF;IAED;;;;;;OAMG;IACH,MAAM,CAAC,0BAA0B,MAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAK,MAAM,EAAE,KAAG,MAAM,CAElF;IAED;;;;;;;OAOG;IACH,MAAM,CAAC,yBAAyB,MAC3B,MAAM,EAAE,KACR,MAAM,EAAE,KACR,MAAM,EAAE,sBAEV,MAAM,EAAE,CAYV;IAED;;;;;;;OAOG;IACH,MAAM,CAAC,qBAAqB,MAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAK,MAAM,EAAE,sBAAiB,MAAM,CAE3F;IAED;;;;;;OAMG;IACH,MAAM,CAAC,KAAK,MAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAK,MAAM,KAAG,MAAM,EAAE,CAE7D;IAED;;;;;OAKG;IACH,MAAM,CAAC,YAAY,MAAO,MAAM,EAAE,KAAK,MAAM,KAAK,MAAM,KAAG,MAAM,EAAE,CAElE;IAED;;;;OAIG;IACH,MAAM,CAAC,WAAW,MAAO,MAAM,EAAE,iBAAU,MAAM,EAAE,CAElD;IAED;;;;;;OAMG;IACH,MAAM,CAAC,aAAa,MAAO,MAAM,EAAE,KAAK,MAAM,EAAE,qBAAc,MAAM,EAAE,EAAE,CAMvE;CACF;AAED,eAAe,GAAG,CAAA"}

499
packages/vec/src/index.ts Normal file
View file

@ -0,0 +1,499 @@
export class Vec {
/**
* Clamp a value into a range.
* @param n
* @param min
*/
static clamp(n: number, min: number): number
static clamp(n: number, min: number, max: number): number
static clamp(n: number, min: number, max?: number): number {
return Math.max(min, typeof max !== 'undefined' ? Math.min(n, max) : n)
}
/**
* Clamp a value into a range.
* @param n
* @param min
*/
static clampV(A: number[], min: number): number[]
static clampV(A: number[], min: number, max: number): number[]
static clampV(A: number[], min: number, max?: number): number[] {
return A.map((n) => (max ? Vec.clamp(n, min, max) : Vec.clamp(n, min)))
}
/**
* Negate a vector.
* @param A
*/
static neg = (A: number[]): number[] => {
return [-A[0], -A[1]]
}
/**
* Add vectors.
* @param A
* @param B
*/
static add = (A: number[], B: number[]): number[] => {
return [A[0] + B[0], A[1] + B[1]]
}
/**
* Add scalar to vector.
* @param A
* @param B
*/
static addScalar = (A: number[], n: number): number[] => {
return [A[0] + n, A[1] + n]
}
/**
* Subtract vectors.
* @param A
* @param B
*/
static sub = (A: number[], B: number[]): number[] => {
return [A[0] - B[0], A[1] - B[1]]
}
/**
* Subtract scalar from vector.
* @param A
* @param B
*/
static subScalar = (A: number[], n: number): number[] => {
return [A[0] - n, A[1] - n]
}
/**
* Get the vector from vectors A to B.
* @param A
* @param B
*/
static vec = (A: number[], B: number[]): number[] => {
// A, B as vectors get the vector from A to B
return [B[0] - A[0], B[1] - A[1]]
}
/**
* Vector multiplication by scalar
* @param A
* @param n
*/
static mul = (A: number[], n: number): number[] => {
return [A[0] * n, A[1] * n]
}
/**
* Multiple two vectors.
* @param A
* @param B
*/
static mulV = (A: number[], B: number[]): number[] => {
return [A[0] * B[0], A[1] * B[1]]
}
/**
* Vector division by scalar.
* @param A
* @param n
*/
static div = (A: number[], n: number): number[] => {
return [A[0] / n, A[1] / n]
}
/**
* Vector division by vector.
* @param A
* @param n
*/
static divV = (A: number[], B: number[]): number[] => {
return [A[0] / B[0], A[1] / B[1]]
}
/**
* Perpendicular rotation of a vector A
* @param A
*/
static per = (A: number[]): number[] => {
return [A[1], -A[0]]
}
/**
* Dot product
* @param A
* @param B
*/
static dpr = (A: number[], B: number[]): number => {
return A[0] * B[0] + A[1] * B[1]
}
/**
* Cross product (outer product) | A X B |
* @param A
* @param B
*/
static cpr = (A: number[], B: number[]): number => {
return A[0] * B[1] - B[0] * A[1]
}
/**
* Cross (for point in polygon)
*
*/
static cross(x: number[], y: number[], z: number[]): number {
return (y[0] - x[0]) * (z[1] - x[1]) - (z[0] - x[0]) * (y[1] - x[1])
}
/**
* Length of the vector squared
* @param A
*/
static len2 = (A: number[]): number => {
return A[0] * A[0] + A[1] * A[1]
}
/**
* Length of the vector
* @param A
*/
static len = (A: number[]): number => {
return Math.hypot(A[0], A[1])
}
/**
* Project A over B
* @param A
* @param B
*/
static pry = (A: number[], B: number[]): number => {
return Vec.dpr(A, B) / Vec.len(B)
}
/**
* Get normalized / unit vector.
* @param A
*/
static uni = (A: number[]): number[] => {
return Vec.div(A, Vec.len(A))
}
/**
* Get normalized / unit vector.
* @param A
*/
static normalize = (A: number[]): number[] => {
return Vec.uni(A)
}
/**
* Get the tangent between two vectors.
* @param A
* @param B
* @returns
*/
static tangent = (A: number[], B: number[]): number[] => {
return Vec.uni(Vec.sub(A, B))
}
/**
* Dist length from A to B squared.
* @param A
* @param B
*/
static dist2 = (A: number[], B: number[]): number => {
return Vec.len2(Vec.sub(A, B))
}
/**
* Dist length from A to B
* @param A
* @param B
*/
static dist = (A: number[], B: number[]): number => {
return Math.hypot(A[1] - B[1], A[0] - B[0])
}
/**
* A faster, though less accurate method for testing distances. Maybe faster?
* @param A
* @param B
* @returns
*/
static fastDist = (A: number[], B: number[]): number[] => {
const V = [B[0] - A[0], B[1] - A[1]]
const aV = [Math.abs(V[0]), Math.abs(V[1])]
let r = 1 / Math.max(aV[0], aV[1])
r = r * (1.29289 - (aV[0] + aV[1]) * r * 0.29289)
return [V[0] * r, V[1] * r]
}
/**
* Angle between vector A and vector B in radians
* @param A
* @param B
*/
static ang = (A: number[], B: number[]): number => {
return Math.atan2(Vec.cpr(A, B), Vec.dpr(A, B))
}
/**
* Angle between vector A and vector B in radians
* @param A
* @param B
*/
static angle = (A: number[], B: number[]): number => {
return Math.atan2(B[1] - A[1], B[0] - A[0])
}
/**
* Mean between two vectors or mid vector between two vectors
* @param A
* @param B
*/
static med = (A: number[], B: number[]): number[] => {
return Vec.mul(Vec.add(A, B), 0.5)
}
/**
* Vector rotation by r (radians)
* @param A
* @param r rotation in radians
*/
static rot = (A: number[], r = 0): number[] => {
return [A[0] * Math.cos(r) - A[1] * Math.sin(r), A[0] * Math.sin(r) + A[1] * Math.cos(r)]
}
/**
* Rotate a vector around another vector by r (radians)
* @param A vector
* @param C center
* @param r rotation in radians
*/
static rotWith = (A: number[], C: number[], r = 0): number[] => {
if (r === 0) return A
const s = Math.sin(r)
const c = Math.cos(r)
const px = A[0] - C[0]
const py = A[1] - C[1]
const nx = px * c - py * s
const ny = px * s + py * c
return [nx + C[0], ny + C[1]]
}
/**
* Check of two vectors are identical.
* @param A
* @param B
*/
static isEqual = (A: number[], B: number[]): boolean => {
return A[0] === B[0] && A[1] === B[1]
}
/**
* Interpolate vector A to B with a scalar t
* @param A
* @param B
* @param t scalar
*/
static lrp = (A: number[], B: number[], t: number): number[] => {
return Vec.add(A, Vec.mul(Vec.sub(B, A), t))
}
/**
* Interpolate from A to B when curVAL goes fromVAL: number[] => to
* @param A
* @param B
* @param from Starting value
* @param to Ending value
* @param s Strength
*/
static int = (A: number[], B: number[], from: number, to: number, s = 1): number[] => {
const t = (Vec.clamp(from, to) - from) / (to - from)
return Vec.add(Vec.mul(A, 1 - t), Vec.mul(B, s))
}
/**
* Get the angle between the three vectors A, B, and C.
* @param p1
* @param pc
* @param p2
*/
static ang3 = (p1: number[], pc: number[], p2: number[]): number => {
// this,
const v1 = Vec.vec(pc, p1)
const v2 = Vec.vec(pc, p2)
return Vec.ang(v1, v2)
}
/**
* Absolute value of a vector.
* @param A
* @returns
*/
static abs = (A: number[]): number[] => {
return [Math.abs(A[0]), Math.abs(A[1])]
}
static rescale = (a: number[], n: number): number[] => {
const l = Vec.len(a)
return [(n * a[0]) / l, (n * a[1]) / l]
}
/**
* Get whether p1 is left of p2, relative to pc.
* @param p1
* @param pc
* @param p2
*/
static isLeft = (p1: number[], pc: number[], p2: number[]): number => {
// isLeft: >0 for counterclockwise
// =0 for none (degenerate)
// <0 for clockwise
return (pc[0] - p1[0]) * (p2[1] - p1[1]) - (p2[0] - p1[0]) * (pc[1] - p1[1])
}
/**
* Get whether p1 is left of p2, relative to pc.
* @param p1
* @param pc
* @param p2
*/
static clockwise = (p1: number[], pc: number[], p2: number[]): boolean => {
return Vec.isLeft(p1, pc, p2) > 0
}
/**
* Round a vector to the a given precision.
* @param a
* @param d
*/
static toFixed = (a: number[], d = 2): number[] => {
return a.map((v) => +v.toFixed(d))
}
/**
* Snap vector to nearest step.
* @param A
* @param step
* @example
* ```ts
* Vec.snap([10.5, 28], 10) // [10, 30]
* ```
*/
static snap(a: number[], step = 1) {
return [Math.round(a[0] / step) * step, Math.round(a[1] / step) * step]
}
/**
* Get the nearest point on a line with a known unit vector that passes through point A
* @param A Any point on the line
* @param u The unit vector for the line.
* @param P A point not on the line to test.
* @returns
*/
static nearestPointOnLineThroughPoint = (A: number[], u: number[], P: number[]): number[] => {
return Vec.add(A, Vec.mul(u, Vec.pry(Vec.sub(P, A), u)))
}
/**
* Distance between a point and a line with a known unit vector that passes through a point.
* @param A Any point on the line
* @param u The unit vector for the line.
* @param P A point not on the line to test.
* @returns
*/
static distanceToLineThroughPoint = (A: number[], u: number[], P: number[]): number => {
return Vec.dist(P, Vec.nearestPointOnLineThroughPoint(A, u, P))
}
/**
* Get the nearest point on a line segment between A and B
* @param A The start of the line segment
* @param B The end of the line segment
* @param P The off-line point
* @param clamp Whether to clamp the point between A and B.
* @returns
*/
static nearestPointOnLineSegment = (
A: number[],
B: number[],
P: number[],
clamp = true
): number[] => {
const u = Vec.uni(Vec.sub(B, A))
const C = Vec.add(A, Vec.mul(u, Vec.pry(Vec.sub(P, A), u)))
if (clamp) {
if (C[0] < Math.min(A[0], B[0])) return A[0] < B[0] ? A : B
if (C[0] > Math.max(A[0], B[0])) return A[0] > B[0] ? A : B
if (C[1] < Math.min(A[1], B[1])) return A[1] < B[1] ? A : B
if (C[1] > Math.max(A[1], B[1])) return A[1] > B[1] ? A : B
}
return C
}
/**
* Distance between a point and the nearest point on a line segment between A and B
* @param A The start of the line segment
* @param B The end of the line segment
* @param P The off-line point
* @param clamp Whether to clamp the point between A and B.
* @returns
*/
static distanceToLineSegment = (A: number[], B: number[], P: number[], clamp = true): number => {
return Vec.dist(P, Vec.nearestPointOnLineSegment(A, B, P, clamp))
}
/**
* Push a point A towards point B by a given distance.
* @param A
* @param B
* @param d
* @returns
*/
static nudge = (A: number[], B: number[], d: number): number[] => {
return Vec.add(A, Vec.mul(Vec.uni(Vec.sub(B, A)), d))
}
/**
* Push a point in a given angle by a given distance.
* @param A
* @param B
* @param d
*/
static nudgeAtAngle = (A: number[], a: number, d: number): number[] => {
return [Math.cos(a) * d + A[0], Math.sin(a) * d + A[1]]
}
/**
* Round a vector to a precision length.
* @param a
* @param n
*/
static toPrecision = (a: number[], n = 4): number[] => {
return [+a[0].toPrecision(n), +a[1].toPrecision(n)]
}
/**
* Get an array of points (with simulated pressure) between two points.
* @param A The first point.
* @param B The second point.
* @param steps The number of points to return.
* @param ease An easing function to apply to the simulated pressure.
*/
static pointsBetween = (A: number[], B: number[], steps = 6): number[][] => {
return Array.from(Array(steps)).map((_, i) => {
const t = i / (steps - 1)
const k = Math.min(1, 0.5 + Math.abs(0.5 - t))
return [...Vec.lrp(A, B, t), k]
})
}
}
export default Vec

View file

@ -0,0 +1,20 @@
{
"extends": "./tsconfig.json",
"exclude": [
"node_modules",
"**/*.test.tsx",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.spec.ts",
"src/test",
"dist",
"docs"
],
"compilerOptions": {
"composite": false,
"incremental": false,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}

Some files were not shown because too many files have changed in this diff Show more