Refactor tools (#147)

* Refactor Tools

* Update text.tsx

* Passing tests

* Error fixes

* Fix re-selecting tool

* Fix arrow
This commit is contained in:
Steve Ruiz 2021-10-13 14:55:31 +01:00 committed by GitHub
parent be271f3ca2
commit 1408ac2cbe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 2024 additions and 1616 deletions

View file

@ -42,6 +42,7 @@ export const Shape = React.memo(
meta={meta}
events={events}
onShapeChange={callbacks.onShapeChange}
onShapeBlur={callbacks.onShapeBlur}
/>
</Container>
)

View file

@ -71,7 +71,7 @@ export function useZoomEvents<T extends Element>(zoom: number, ref: React.RefObj
onPinch: ({ origin, offset, event }) => {
const elm = ref.current
if (!(event.target === elm || elm?.contains(event.target as Node))) return
if (!rOriginPoint.current) throw Error('No origin point!')
if (!rOriginPoint.current) return
const info = inputs.pinch(origin, rOriginPoint.current)

View file

@ -1,264 +1,357 @@
import type React from 'react';
import type { ForwardedRef } from 'react';
import type React from 'react'
import { MutableRefObject } from 'react-router/node_modules/@types/react'
export declare type Patch<T> = Partial<{
[P in keyof T]: T | Partial<T> | Patch<T[P]>;
}>;
[P in keyof T]: T | Partial<T> | Patch<T[P]>
}>
export interface TLPage<T extends TLShape, B extends TLBinding> {
id: string;
name?: string;
childIndex?: number;
shapes: Record<string, T>;
bindings: Record<string, B>;
id: string
name?: string
childIndex?: number
shapes: Record<string, T>
bindings: Record<string, B>
}
export interface TLPageState {
id: string;
selectedIds: string[];
camera: {
point: number[];
zoom: number;
};
brush?: TLBounds;
pointedId?: string | null;
hoveredId?: string | null;
editingId?: string | null;
bindingId?: string | null;
boundsRotation?: number;
currentParentId?: string | null;
id: string
selectedIds: string[]
camera: {
point: number[]
zoom: number
}
brush?: TLBounds
pointedId?: string | null
hoveredId?: string | null
editingId?: string | null
bindingId?: string | null
boundsRotation?: number
currentParentId?: string | null
}
export interface TLHandle {
id: string;
index: number;
point: number[];
canBind?: boolean;
bindingId?: string;
id: string
index: number
point: number[]
canBind?: boolean
bindingId?: string
}
export interface TLShape {
id: string;
type: string;
parentId: string;
childIndex: number;
name: string;
point: number[];
rotation?: number;
children?: string[];
handles?: Record<string, TLHandle>;
isLocked?: boolean;
isHidden?: boolean;
isEditing?: boolean;
isGenerated?: boolean;
isAspectRatioLocked?: boolean;
id: string
type: string
parentId: string
childIndex: number
name: string
point: number[]
rotation?: number
children?: string[]
handles?: Record<string, TLHandle>
isLocked?: boolean
isHidden?: boolean
isEditing?: boolean
isGenerated?: boolean
isAspectRatioLocked?: boolean
}
export declare type TLShapeUtils<T extends TLShape = any, E extends Element = any, M = any, K = any> = Record<string, TLShapeUtil<T, E, M, K>>;
export declare type TLShapeUtils<
T extends TLShape = any,
E extends Element = any,
M = any,
K = any
> = Record<string, TLShapeUtil<T, E, M, K>>
export interface TLRenderInfo<T extends TLShape, E = any, M = any> {
shape: T;
isEditing: boolean;
isBinding: boolean;
isHovered: boolean;
isSelected: boolean;
isCurrentParent: boolean;
meta: M extends any ? M : never;
onShapeChange?: TLCallbacks<T>['onShapeChange'];
onShapeBlur?: TLCallbacks<T>['onShapeBlur'];
events: {
onPointerDown: (e: React.PointerEvent<E>) => void;
onPointerUp: (e: React.PointerEvent<E>) => void;
onPointerEnter: (e: React.PointerEvent<E>) => void;
onPointerMove: (e: React.PointerEvent<E>) => void;
onPointerLeave: (e: React.PointerEvent<E>) => void;
};
shape: T
isEditing: boolean
isBinding: boolean
isHovered: boolean
isSelected: boolean
isCurrentParent: boolean
meta: M extends any ? M : never
onShapeChange?: TLCallbacks<T>['onShapeChange']
onShapeBlur?: TLCallbacks<T>['onShapeBlur']
events: {
onPointerDown: (e: React.PointerEvent<E>) => void
onPointerUp: (e: React.PointerEvent<E>) => void
onPointerEnter: (e: React.PointerEvent<E>) => void
onPointerMove: (e: React.PointerEvent<E>) => void
onPointerLeave: (e: React.PointerEvent<E>) => void
}
}
export interface TLShapeProps<T extends TLShape, E = any, M = any> extends TLRenderInfo<T, E, M> {
ref: ForwardedRef<E>;
shape: T;
ref: MutableRefObject<E>
shape: T
}
export interface TLTool {
id: string;
name: string;
id: string
name: string
}
export interface TLBinding<M = any> {
id: string;
type: string;
toId: string;
fromId: string;
meta: M;
id: string
type: string
toId: string
fromId: string
meta: M
}
export interface TLTheme {
brushFill?: string;
brushStroke?: string;
selectFill?: string;
selectStroke?: string;
background?: string;
foreground?: string;
brushFill?: string
brushStroke?: string
selectFill?: string
selectStroke?: string
background?: string
foreground?: string
}
export declare type TLWheelEventHandler = (info: TLPointerInfo<string>, e: React.WheelEvent<Element> | WheelEvent) => void;
export declare type TLPinchEventHandler = (info: TLPointerInfo<string>, e: React.WheelEvent<Element> | WheelEvent | React.TouchEvent<Element> | TouchEvent | React.PointerEvent<Element> | PointerEventInit) => void;
export declare type TLPointerEventHandler = (info: TLPointerInfo<string>, e: React.PointerEvent) => void;
export declare type TLCanvasEventHandler = (info: TLPointerInfo<'canvas'>, e: React.PointerEvent) => void;
export declare type TLBoundsEventHandler = (info: TLPointerInfo<'bounds'>, e: React.PointerEvent) => void;
export declare type TLBoundsHandleEventHandler = (info: TLPointerInfo<TLBoundsCorner | TLBoundsEdge | 'rotate'>, e: React.PointerEvent) => void;
export declare type TLWheelEventHandler = (
info: TLPointerInfo<string>,
e: React.WheelEvent<Element> | WheelEvent
) => void
export declare type TLPinchEventHandler = (
info: TLPointerInfo<string>,
e:
| React.WheelEvent<Element>
| WheelEvent
| React.TouchEvent<Element>
| TouchEvent
| React.PointerEvent<Element>
| PointerEventInit
) => void
export declare type TLPointerEventHandler = (
info: TLPointerInfo<string>,
e: React.PointerEvent
) => void
export declare type TLCanvasEventHandler = (
info: TLPointerInfo<'canvas'>,
e: React.PointerEvent
) => void
export declare type TLBoundsEventHandler = (
info: TLPointerInfo<'bounds'>,
e: React.PointerEvent
) => void
export declare type TLBoundsHandleEventHandler = (
info: TLPointerInfo<TLBoundsCorner | TLBoundsEdge | 'rotate'>,
e: React.PointerEvent
) => void
export interface TLCallbacks<T extends TLShape> {
onPinchStart: TLPinchEventHandler;
onPinchEnd: TLPinchEventHandler;
onPinch: TLPinchEventHandler;
onPan: TLWheelEventHandler;
onZoom: TLWheelEventHandler;
onPointerMove: TLPointerEventHandler;
onPointerUp: TLPointerEventHandler;
onPointerDown: TLPointerEventHandler;
onPointCanvas: TLCanvasEventHandler;
onDoubleClickCanvas: TLCanvasEventHandler;
onRightPointCanvas: TLCanvasEventHandler;
onDragCanvas: TLCanvasEventHandler;
onReleaseCanvas: TLCanvasEventHandler;
onPointShape: TLPointerEventHandler;
onDoubleClickShape: TLPointerEventHandler;
onRightPointShape: TLPointerEventHandler;
onDragShape: TLPointerEventHandler;
onHoverShape: TLPointerEventHandler;
onUnhoverShape: TLPointerEventHandler;
onReleaseShape: TLPointerEventHandler;
onPointBounds: TLBoundsEventHandler;
onDoubleClickBounds: TLBoundsEventHandler;
onRightPointBounds: TLBoundsEventHandler;
onDragBounds: TLBoundsEventHandler;
onHoverBounds: TLBoundsEventHandler;
onUnhoverBounds: TLBoundsEventHandler;
onReleaseBounds: TLBoundsEventHandler;
onPointBoundsHandle: TLBoundsHandleEventHandler;
onDoubleClickBoundsHandle: TLBoundsHandleEventHandler;
onRightPointBoundsHandle: TLBoundsHandleEventHandler;
onDragBoundsHandle: TLBoundsHandleEventHandler;
onHoverBoundsHandle: TLBoundsHandleEventHandler;
onUnhoverBoundsHandle: TLBoundsHandleEventHandler;
onReleaseBoundsHandle: TLBoundsHandleEventHandler;
onPointHandle: TLPointerEventHandler;
onDoubleClickHandle: TLPointerEventHandler;
onRightPointHandle: TLPointerEventHandler;
onDragHandle: TLPointerEventHandler;
onHoverHandle: TLPointerEventHandler;
onUnhoverHandle: TLPointerEventHandler;
onReleaseHandle: TLPointerEventHandler;
onRenderCountChange: (ids: string[]) => void;
onShapeChange: (shape: {
id: string;
} & Partial<T>) => void;
onShapeBlur: () => void;
onError: (error: Error) => void;
onPinchStart: TLPinchEventHandler
onPinchEnd: TLPinchEventHandler
onPinch: TLPinchEventHandler
onPan: TLWheelEventHandler
onZoom: TLWheelEventHandler
onPointerMove: TLPointerEventHandler
onPointerUp: TLPointerEventHandler
onPointerDown: TLPointerEventHandler
onPointCanvas: TLCanvasEventHandler
onDoubleClickCanvas: TLCanvasEventHandler
onRightPointCanvas: TLCanvasEventHandler
onDragCanvas: TLCanvasEventHandler
onReleaseCanvas: TLCanvasEventHandler
onPointShape: TLPointerEventHandler
onDoubleClickShape: TLPointerEventHandler
onRightPointShape: TLPointerEventHandler
onDragShape: TLPointerEventHandler
onHoverShape: TLPointerEventHandler
onUnhoverShape: TLPointerEventHandler
onReleaseShape: TLPointerEventHandler
onPointBounds: TLBoundsEventHandler
onDoubleClickBounds: TLBoundsEventHandler
onRightPointBounds: TLBoundsEventHandler
onDragBounds: TLBoundsEventHandler
onHoverBounds: TLBoundsEventHandler
onUnhoverBounds: TLBoundsEventHandler
onReleaseBounds: TLBoundsEventHandler
onPointBoundsHandle: TLBoundsHandleEventHandler
onDoubleClickBoundsHandle: TLBoundsHandleEventHandler
onRightPointBoundsHandle: TLBoundsHandleEventHandler
onDragBoundsHandle: TLBoundsHandleEventHandler
onHoverBoundsHandle: TLBoundsHandleEventHandler
onUnhoverBoundsHandle: TLBoundsHandleEventHandler
onReleaseBoundsHandle: TLBoundsHandleEventHandler
onPointHandle: TLPointerEventHandler
onDoubleClickHandle: TLPointerEventHandler
onRightPointHandle: TLPointerEventHandler
onDragHandle: TLPointerEventHandler
onHoverHandle: TLPointerEventHandler
onUnhoverHandle: TLPointerEventHandler
onReleaseHandle: TLPointerEventHandler
onRenderCountChange: (ids: string[]) => void
onShapeChange: (
shape: {
id: string
} & Partial<T>
) => void
onShapeBlur: () => void
onError: (error: Error) => void
}
export interface TLBounds {
minX: number;
minY: number;
maxX: number;
maxY: number;
width: number;
height: number;
rotation?: number;
minX: number
minY: number
maxX: number
maxY: number
width: number
height: number
rotation?: number
}
export declare type TLIntersection = {
didIntersect: boolean;
message: string;
points: number[][];
};
didIntersect: boolean
message: string
points: number[][]
}
export declare enum TLBoundsEdge {
Top = "top_edge",
Right = "right_edge",
Bottom = "bottom_edge",
Left = "left_edge"
Top = 'top_edge',
Right = 'right_edge',
Bottom = 'bottom_edge',
Left = 'left_edge',
}
export declare enum TLBoundsCorner {
TopLeft = "top_left_corner",
TopRight = "top_right_corner",
BottomRight = "bottom_right_corner",
BottomLeft = "bottom_left_corner"
TopLeft = 'top_left_corner',
TopRight = 'top_right_corner',
BottomRight = 'bottom_right_corner',
BottomLeft = 'bottom_left_corner',
}
export interface TLPointerInfo<T extends string = string> {
target: T;
pointerId: number;
origin: number[];
point: number[];
delta: number[];
pressure: number;
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
altKey: boolean;
target: T
pointerId: number
origin: number[]
point: number[]
delta: number[]
pressure: number
shiftKey: boolean
ctrlKey: boolean
metaKey: boolean
altKey: boolean
}
export interface TLKeyboardInfo {
origin: number[];
point: number[];
key: string;
keys: string[];
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
altKey: boolean;
origin: number[]
point: number[]
key: string
keys: string[]
shiftKey: boolean
ctrlKey: boolean
metaKey: boolean
altKey: boolean
}
export interface TLTransformInfo<T extends TLShape> {
type: TLBoundsEdge | TLBoundsCorner;
initialShape: T;
scaleX: number;
scaleY: number;
transformOrigin: number[];
type: TLBoundsEdge | TLBoundsCorner
initialShape: T
scaleX: number
scaleY: number
transformOrigin: number[]
}
export interface TLBezierCurveSegment {
start: number[];
tangentStart: number[];
normalStart: number[];
pressureStart: number;
end: number[];
tangentEnd: number[];
normalEnd: number[];
pressureEnd: number;
start: number[]
tangentStart: number[]
normalStart: number[]
pressureStart: number
end: number[]
tangentEnd: number[]
normalEnd: number[]
pressureEnd: number
}
export declare type TLShapeUtil<
T extends TLShape,
E extends Element,
M = any,
K = {
[key: string]: any
}
> = K & {
type: T['type']
defaultProps: T
Component(
this: TLShapeUtil<T, E, M>,
props: TLRenderInfo<T, E, M>,
ref: React.MutableRefObject<E>
): React.ReactElement<TLRenderInfo<T, E, M>, E['tagName']>
Indicator(
this: TLShapeUtil<T, E, M>,
props: {
shape: T
}
): React.ReactElement | null
getBounds(this: TLShapeUtil<T, E, M>, shape: T): TLBounds
refMap: Map<string, React.RefObject<E>>
boundsCache: WeakMap<TLShape, TLBounds>
isAspectRatioLocked: boolean
canEdit: boolean
canBind: boolean
getRotatedBounds(this: TLShapeUtil<T, E, M>, shape: T): TLBounds
hitTest(this: TLShapeUtil<T, E, M>, shape: T, point: number[]): boolean
hitTestBounds(this: TLShapeUtil<T, E, M>, shape: T, bounds: TLBounds): boolean
shouldRender(this: TLShapeUtil<T, E, M>, prev: T, next: T): boolean
getCenter(this: TLShapeUtil<T, E, M>, shape: T): number[]
getRef(this: TLShapeUtil<T, E, M>, shape: T): React.RefObject<E>
getBindingPoint<K extends TLShape>(
this: TLShapeUtil<T, E, M>,
shape: T,
fromShape: K,
point: number[],
origin: number[],
direction: number[],
padding: number,
bindAnywhere: boolean
):
| {
point: number[]
distance: number
}
| undefined
create: (
this: TLShapeUtil<T, E, M>,
props: {
id: string
} & Partial<T>
) => T
mutate: (this: TLShapeUtil<T, E, M>, shape: T, props: Partial<T>) => Partial<T>
transform: (
this: TLShapeUtil<T, E, M>,
shape: T,
bounds: TLBounds,
info: TLTransformInfo<T>
) => Partial<T> | void
transformSingle: (
this: TLShapeUtil<T, E, M>,
shape: T,
bounds: TLBounds,
info: TLTransformInfo<T>
) => Partial<T> | void
updateChildren: <K extends TLShape>(
this: TLShapeUtil<T, E, M>,
shape: T,
children: K[]
) => Partial<K>[] | void
onChildrenChange: (this: TLShapeUtil<T, E, M>, shape: T, children: TLShape[]) => Partial<T> | void
onBindingChange: (
this: TLShapeUtil<T, E, M>,
shape: T,
binding: TLBinding,
target: TLShape,
targetBounds: TLBounds,
center: number[]
) => Partial<T> | void
onHandleChange: (
this: TLShapeUtil<T, E, M>,
shape: T,
handle: Partial<T['handles']>,
info: Partial<TLPointerInfo>
) => Partial<T> | void
onRightPointHandle: (
this: TLShapeUtil<T, E, M>,
shape: T,
handle: Partial<T['handles']>,
info: Partial<TLPointerInfo>
) => Partial<T> | void
onDoubleClickHandle: (
this: TLShapeUtil<T, E, M>,
shape: T,
handle: Partial<T['handles']>,
info: Partial<TLPointerInfo>
) => Partial<T> | void
onDoubleClickBoundsHandle: (this: TLShapeUtil<T, E, M>, shape: T) => Partial<T> | void
onSessionComplete: (this: TLShapeUtil<T, E, M>, shape: T) => Partial<T> | void
onStyleChange: (this: TLShapeUtil<T, E, M>, shape: T) => Partial<T> | void
_Component: React.ForwardRefExoticComponent<any>
}
export declare type TLShapeUtil<T extends TLShape, E extends Element, M = any, K = {
[key: string]: any;
}> = K & {
type: T['type'];
defaultProps: T;
Component(this: TLShapeUtil<T, E, M>, props: TLRenderInfo<T, E, M>, ref: React.ForwardedRef<E>): React.ReactElement<TLRenderInfo<T, E, M>, E['tagName']>;
Indicator(this: TLShapeUtil<T, E, M>, props: {
shape: T;
}): React.ReactElement | null;
getBounds(this: TLShapeUtil<T, E, M>, shape: T): TLBounds;
refMap: Map<string, React.RefObject<E>>;
boundsCache: WeakMap<TLShape, TLBounds>;
isAspectRatioLocked: boolean;
canEdit: boolean;
canBind: boolean;
getRotatedBounds(this: TLShapeUtil<T, E, M>, shape: T): TLBounds;
hitTest(this: TLShapeUtil<T, E, M>, shape: T, point: number[]): boolean;
hitTestBounds(this: TLShapeUtil<T, E, M>, shape: T, bounds: TLBounds): boolean;
shouldRender(this: TLShapeUtil<T, E, M>, prev: T, next: T): boolean;
getCenter(this: TLShapeUtil<T, E, M>, shape: T): number[];
getRef(this: TLShapeUtil<T, E, M>, shape: T): React.RefObject<E>;
getBindingPoint<K extends TLShape>(this: TLShapeUtil<T, E, M>, shape: T, fromShape: K, point: number[], origin: number[], direction: number[], padding: number, bindAnywhere: boolean): {
point: number[];
distance: number;
} | undefined;
create: (this: TLShapeUtil<T, E, M>, props: {
id: string;
} & Partial<T>) => T;
mutate: (this: TLShapeUtil<T, E, M>, shape: T, props: Partial<T>) => Partial<T>;
transform: (this: TLShapeUtil<T, E, M>, shape: T, bounds: TLBounds, info: TLTransformInfo<T>) => Partial<T> | void;
transformSingle: (this: TLShapeUtil<T, E, M>, shape: T, bounds: TLBounds, info: TLTransformInfo<T>) => Partial<T> | void;
updateChildren: <K extends TLShape>(this: TLShapeUtil<T, E, M>, shape: T, children: K[]) => Partial<K>[] | void;
onChildrenChange: (this: TLShapeUtil<T, E, M>, shape: T, children: TLShape[]) => Partial<T> | void;
onBindingChange: (this: TLShapeUtil<T, E, M>, shape: T, binding: TLBinding, target: TLShape, targetBounds: TLBounds, center: number[]) => Partial<T> | void;
onHandleChange: (this: TLShapeUtil<T, E, M>, shape: T, handle: Partial<T['handles']>, info: Partial<TLPointerInfo>) => Partial<T> | void;
onRightPointHandle: (this: TLShapeUtil<T, E, M>, shape: T, handle: Partial<T['handles']>, info: Partial<TLPointerInfo>) => Partial<T> | void;
onDoubleClickHandle: (this: TLShapeUtil<T, E, M>, shape: T, handle: Partial<T['handles']>, info: Partial<TLPointerInfo>) => Partial<T> | void;
onDoubleClickBoundsHandle: (this: TLShapeUtil<T, E, M>, shape: T) => Partial<T> | void;
onSessionComplete: (this: TLShapeUtil<T, E, M>, shape: T) => Partial<T> | void;
onStyleChange: (this: TLShapeUtil<T, E, M>, shape: T) => Partial<T> | void;
_Component: React.ForwardRefExoticComponent<any>;
};
export interface IShapeTreeNode<T extends TLShape, M = any> {
shape: T;
children?: IShapeTreeNode<TLShape, M>[];
isEditing: boolean;
isBinding: boolean;
isHovered: boolean;
isSelected: boolean;
isCurrentParent: boolean;
meta?: M extends any ? M : never;
shape: T
children?: IShapeTreeNode<TLShape, M>[]
isEditing: boolean
isBinding: boolean
isHovered: boolean
isSelected: boolean
isCurrentParent: boolean
meta?: M extends any ? M : never
}

View file

@ -163,7 +163,10 @@ function InnerTldraw({
// Hide bounds when not using the select tool, or when the only selected shape has handles
const hideBounds =
(tlstate.session && tlstate.session.id !== 'brush') || !isSelecting || isSelectedHandlesShape
(isInSession && tlstate.session?.constructor.name !== 'BrushSession') ||
!isSelecting ||
isSelectedHandlesShape ||
!!pageState.editingId
// Hide bounds when not using the select tool, or when in session
const hideHandles = isInSession || !isSelecting

View file

@ -150,6 +150,7 @@ export function PrimaryButton({
name: label,
isActive,
})}
onPointerDown={onClick}
onClick={onClick}
onDoubleClick={onDoubleClick}
>

View file

@ -14,5 +14,6 @@ export const tldrawShapeUtils: Record<TLDrawShapeType, any> = {
}
export function getShapeUtils<T extends TLDrawShape>(type: T['type']) {
if (!tldrawShapeUtils[type]) throw Error(`Could not find a util of type ${type}`)
return tldrawShapeUtils[type] as TLDrawShapeUtil<T>
}

View file

@ -7,7 +7,6 @@ import {
ArrowShape,
Decoration,
TLDrawShapeType,
TLDrawToolType,
DashStyle,
ArrowBinding,
TLDrawMeta,
@ -26,8 +25,6 @@ import { EASINGS } from '~state/utils'
export const Arrow = new ShapeUtil<ArrowShape, SVGSVGElement, TLDrawMeta>(() => ({
type: TLDrawShapeType.Arrow,
toolType: TLDrawToolType.Handle,
canStyleFill: false,
pathCache: new WeakMap<ArrowShape, string>(),
@ -100,7 +97,7 @@ export const Arrow = new ShapeUtil<ArrowShape, SVGSVGElement, TLDrawMeta>(() =>
if (isStraightLine) {
const path = isDraw
? renderFreehandArrowShaft(shape, arrowDist, easing)
? renderFreehandArrowShaft(shape)
: 'M' + Vec.round(start.point) + 'L' + Vec.round(end.point)
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
@ -425,9 +422,20 @@ export const Arrow = new ShapeUtil<ArrowShape, SVGSVGElement, TLDrawMeta>(() =>
// And passes through the dragging handle
const direction = Vec.uni(Vec.sub(Vec.add(anchor, shape.point), origin))
if (
[TLDrawShapeType.Rectangle, TLDrawShapeType.Text].includes(target.type as TLDrawShapeType)
) {
if (target.type === TLDrawShapeType.Ellipse) {
const hits = intersectRayEllipse(
origin,
direction,
center,
(target as EllipseShape).radius[0] + binding.meta.distance,
(target as EllipseShape).radius[1] + binding.meta.distance,
target.rotation || 0
).points.sort((a, b) => Vec.dist(a, origin) - Vec.dist(b, origin))
if (hits[0]) {
handlePoint = Vec.sub(hits[0], shape.point)
}
} else {
let hits = intersectRayBounds(origin, direction, intersectBounds, target.rotation)
.filter((int) => int.didIntersect)
.map((int) => int.points[0])
@ -440,27 +448,9 @@ export const Arrow = new ShapeUtil<ArrowShape, SVGSVGElement, TLDrawMeta>(() =>
.sort((a, b) => Vec.dist(a, origin) - Vec.dist(b, origin))
}
if (!hits[0]) {
console.warn('No intersection.')
return
if (hits[0]) {
handlePoint = Vec.sub(hits[0], shape.point)
}
handlePoint = Vec.sub(hits[0], shape.point)
} else if (target.type === TLDrawShapeType.Ellipse) {
const hits = intersectRayEllipse(
origin,
direction,
center,
(target as EllipseShape).radius[0] + binding.meta.distance,
(target as EllipseShape).radius[1] + binding.meta.distance,
target.rotation || 0
).points.sort((a, b) => Vec.dist(a, origin) - Vec.dist(b, origin))
if (!hits[0]) {
console.warn('No intersections')
}
handlePoint = Vec.sub(hits[0], shape.point)
}
}
@ -621,11 +611,7 @@ function getBendPoint(handles: ArrowShape['handles'], bend: number) {
return point
}
function renderFreehandArrowShaft(
shape: ArrowShape,
length: number,
easing: (t: number) => number
) {
function renderFreehandArrowShaft(shape: ArrowShape) {
const { style, id } = shape
const { start, end } = shape.handles

View file

@ -4,7 +4,7 @@ import { Vec } from '@tldraw/vec'
import { intersectBoundsBounds, intersectBoundsPolyline } from '@tldraw/intersect'
import { getStrokeOutlinePoints, getStrokePoints } from 'perfect-freehand'
import { defaultStyle, getShapeStyle } from '~shape/shape-styles'
import { DrawShape, DashStyle, TLDrawShapeType, TLDrawToolType, TLDrawMeta } from '~types'
import { DrawShape, DashStyle, TLDrawShapeType, TLDrawMeta } from '~types'
import { EASINGS } from '~state/utils'
const pointsBoundsCache = new WeakMap<DrawShape['points'], TLBounds>([])
@ -15,8 +15,6 @@ const pointCache: Record<string, number[]> = {}
export const Draw = new ShapeUtil<DrawShape, SVGSVGElement, TLDrawMeta>(() => ({
type: TLDrawShapeType.Draw,
toolType: TLDrawToolType.Draw,
defaultProps: {
id: 'id',
type: TLDrawShapeType.Draw,

View file

@ -1,7 +1,7 @@
import * as React from 'react'
import { SVGContainer, Utils, ShapeUtil, TLTransformInfo, TLBounds } from '@tldraw/core'
import { Vec } from '@tldraw/vec'
import { DashStyle, EllipseShape, TLDrawShapeType, TLDrawMeta, TLDrawToolType } from '~types'
import { DashStyle, EllipseShape, TLDrawShapeType, TLDrawMeta } from '~types'
import { defaultStyle, getPerfectDashProps, getShapeStyle } from '~shape/shape-styles'
import getStroke, { getStrokeOutlinePoints, getStrokePoints } from 'perfect-freehand'
import {
@ -14,8 +14,6 @@ import { EASINGS } from '~state/utils'
export const Ellipse = new ShapeUtil<EllipseShape, SVGSVGElement, TLDrawMeta>(() => ({
type: TLDrawShapeType.Ellipse,
toolType: TLDrawToolType.Bounds,
pathCache: new WeakMap<EllipseShape, string>([]),
canBind: true,

View file

@ -1,15 +1,13 @@
import * as React from 'react'
import { SVGContainer, ShapeUtil } from '@tldraw/core'
import { defaultStyle } from '~shape/shape-styles'
import { GroupShape, TLDrawShapeType, TLDrawToolType, ColorStyle, TLDrawMeta } from '~types'
import { GroupShape, TLDrawShapeType, ColorStyle, TLDrawMeta } from '~types'
import { getBoundsRectangle } from '../shared'
import css from '~styles'
export const Group = new ShapeUtil<GroupShape, SVGSVGElement, TLDrawMeta>(() => ({
type: TLDrawShapeType.Group,
toolType: TLDrawToolType.Bounds,
canBind: true,
defaultProps: {

View file

@ -1,14 +1,12 @@
import * as React from 'react'
import { HTMLContainer, ShapeUtil } from '@tldraw/core'
import { defaultStyle, getShapeStyle } from '~shape/shape-styles'
import { PostItShape, TLDrawMeta, TLDrawShapeType, TLDrawToolType } from '~types'
import { PostItShape, TLDrawMeta, TLDrawShapeType } from '~types'
import { getBoundsRectangle, transformRectangle, transformSingleRectangle } from '../shared'
export const PostIt = new ShapeUtil<PostItShape, HTMLDivElement, TLDrawMeta>(() => ({
type: TLDrawShapeType.PostIt,
toolType: TLDrawToolType.Bounds,
canBind: true,
pathCache: new WeakMap<number[], string>([]),

View file

@ -3,7 +3,7 @@ import { Utils, SVGContainer, ShapeUtil } from '@tldraw/core'
import { Vec } from '@tldraw/vec'
import getStroke, { getStrokePoints } from 'perfect-freehand'
import { getPerfectDashProps, defaultStyle, getShapeStyle } from '~shape/shape-styles'
import { RectangleShape, DashStyle, TLDrawShapeType, TLDrawToolType, TLDrawMeta } from '~types'
import { RectangleShape, DashStyle, TLDrawShapeType, TLDrawMeta } from '~types'
import { getBoundsRectangle, transformRectangle, transformSingleRectangle } from '../shared'
import { EASINGS } from '~state/utils'
@ -12,8 +12,6 @@ const pathCache = new WeakMap<number[], string>([])
export const Rectangle = new ShapeUtil<RectangleShape, SVGSVGElement, TLDrawMeta>(() => ({
type: TLDrawShapeType.Rectangle,
toolType: TLDrawToolType.Bounds,
canBind: true,
defaultProps: {

View file

@ -3,7 +3,7 @@ import * as React from 'react'
import { HTMLContainer, TLBounds, Utils, ShapeUtil } from '@tldraw/core'
import { Vec } from '@tldraw/vec'
import { getShapeStyle, getFontStyle, defaultStyle } from '~shape/shape-styles'
import { TextShape, TLDrawShapeType, TLDrawToolType, TLDrawMeta } from '~types'
import { TextShape, TLDrawShapeType, TLDrawMeta } from '~types'
import css from '~styles'
import TextAreaUtils from './text-utils'
@ -54,11 +54,9 @@ if (typeof window !== 'undefined') {
export const Text = new ShapeUtil<TextShape, HTMLDivElement, TLDrawMeta>(() => ({
type: TLDrawShapeType.Text,
toolType: TLDrawToolType.Text,
isAspectRatioLocked: true,
isEditableText: true,
canEdit: true,
canBind: true,
@ -86,6 +84,8 @@ export const Text = new ShapeUtil<TextShape, HTMLDivElement, TLDrawMeta>(() => (
const styles = getShapeStyle(style, meta.isDarkMode)
const font = getFontStyle(shape.style)
const rIsMounted = React.useRef(false)
const handleChange = React.useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
onShapeChange?.({ ...shape, text: normalizeText(e.currentTarget.value) })
@ -115,15 +115,20 @@ export const Text = new ShapeUtil<TextShape, HTMLDivElement, TLDrawMeta>(() => (
const handleBlur = React.useCallback(
(e: React.FocusEvent<HTMLTextAreaElement>) => {
e.currentTarget.setSelectionRange(0, 0)
onShapeBlur?.()
if (!isEditing) return
if (rIsMounted.current) {
e.currentTarget.setSelectionRange(0, 0)
onShapeBlur?.()
}
},
[isEditing, shape]
[isEditing]
)
const handleFocus = React.useCallback(
(e: React.FocusEvent<HTMLTextAreaElement>) => {
if (!isEditing) return
if (!rIsMounted.current) return
if (document.activeElement === e.currentTarget) {
e.currentTarget.select()
}
@ -143,6 +148,7 @@ export const Text = new ShapeUtil<TextShape, HTMLDivElement, TLDrawMeta>(() => (
React.useEffect(() => {
if (isEditing) {
requestAnimationFrame(() => {
rIsMounted.current = true
const elm = rInput.current!
elm.focus()
elm.select()

View file

@ -1,7 +1,6 @@
import { TLDR } from '~state/tldr'
import { TLDrawState } from '~state'
import { mockDocument } from '~test'
import { TLDrawShape, TLDrawShapeType } from '~types'
import { SessionType, TLDrawShapeType } from '~types'
describe('Delete command', () => {
const tlstate = new TLDrawState()
@ -57,14 +56,14 @@ describe('Delete command', () => {
})
it('deletes bound shapes, undoes and redoes', () => {
const tlstate = new TLDrawState()
new TLDrawState()
.createShapes(
{ type: TLDrawShapeType.Rectangle, id: 'target1', point: [0, 0], size: [100, 100] },
{ type: TLDrawShapeType.Arrow, id: 'arrow1', point: [200, 200] }
)
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([50, 50])
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([50, 50])
.completeSession()
.delete()
.undo()
@ -80,8 +79,8 @@ describe('Delete command', () => {
type: TLDrawShapeType.Arrow,
})
.select('arrow1')
.startHandleSession([0, 0], 'start')
.updateHandleSession([110, 110])
.startSession(SessionType.Arrow, [0, 0], 'start')
.updateSession([110, 110])
.completeSession()
const binding = Object.values(tlstate.page.bindings)[0]

View file

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { TLDrawState } from '~state'
import { mockDocument } from '~test'
import { ArrowShape, TLDrawShapeType } from '~types'
import { ArrowShape, SessionType, TLDrawShapeType } from '~types'
describe('Duplicate command', () => {
const tlstate = new TLDrawState()
@ -62,8 +62,8 @@ describe('Duplicate command', () => {
tlstate
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([50, 50])
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([50, 50])
.completeSession()
const beforeArrow = tlstate.getShape<ArrowShape>('arrow1')
@ -102,8 +102,8 @@ describe('Duplicate command', () => {
tlstate
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([50, 50])
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([50, 50])
.completeSession()
const oldBindingId = tlstate.getShape<ArrowShape>('arrow1').handles.start.bindingId

View file

@ -1,6 +1,6 @@
import { TLDrawState } from '~state'
import { mockDocument } from '~test'
import { ArrowShape, TLDrawShapeType } from '~types'
import { ArrowShape, SessionType, TLDrawShapeType } from '~types'
describe('Move to page command', () => {
const tlstate = new TLDrawState()
@ -67,8 +67,8 @@ describe('Move to page command', () => {
{ type: TLDrawShapeType.Arrow, id: 'arrow1', point: [200, 200] }
)
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([50, 50])
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([50, 50])
.completeSession()
const bindingId = tlstate.bindings[0].id
@ -108,8 +108,8 @@ describe('Move to page command', () => {
{ type: TLDrawShapeType.Arrow, id: 'arrow1', point: [200, 200] }
)
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([50, 50])
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([50, 50])
.completeSession()
const bindingId = tlstate.bindings[0].id
@ -149,8 +149,8 @@ describe('Move to page command', () => {
{ type: TLDrawShapeType.Arrow, id: 'arrow1', point: [200, 200] }
)
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([50, 50])
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([50, 50])
.completeSession()
const bindingId = tlstate.bindings[0].id

View file

@ -2,7 +2,7 @@ import { TLBoundsCorner, Utils } from '@tldraw/core'
import { TLDrawState } from '~state'
import { TLDR } from '~state/tldr'
import { mockDocument } from '~test'
import { TLDrawShapeType } from '~types'
import { SessionType, TLDrawShapeType } from '~types'
describe('Reset bounds command', () => {
const tlstate = new TLDrawState()
@ -26,8 +26,8 @@ describe('Reset bounds command', () => {
tlstate
.select('text1')
.startTransformSession([0, 0], TLBoundsCorner.TopLeft)
.updateTransformSession([-100, -100], false, false)
.startSession(SessionType.Transform, [0, 0], TLBoundsCorner.TopLeft)
.updateSession([-100, -100], false, false)
.completeSession()
const scale = tlstate.getShape('text1').style.scale

View file

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { TLDrawState } from '~state'
import { mockDocument } from '~test'
import { ArrowShape, TLDrawShapeType } from '~types'
import { ArrowShape, SessionType, TLDrawShapeType } from '~types'
describe('Translate command', () => {
const tlstate = new TLDrawState()
@ -59,8 +59,8 @@ describe('Translate command', () => {
}
)
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([50, 50])
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([50, 50])
.completeSession()
const bindingId = tlstate.getShape<ArrowShape>('arrow1').handles.start.bindingId!
@ -98,8 +98,8 @@ describe('Translate command', () => {
}
)
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([50, 50])
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([50, 50])
.completeSession()
const bindingId = tlstate.getShape<ArrowShape>('arrow1').handles.start.bindingId!

View file

@ -1 +1,43 @@
export * from './sessions'
import { SessionType } from '~types'
import {
ArrowSession,
BrushSession,
DrawSession,
HandleSession,
RotateSession,
TransformSession,
TransformSingleSession,
TranslateSession,
} from './sessions'
export interface SessionsMap {
[SessionType.Arrow]: typeof ArrowSession
[SessionType.Brush]: typeof BrushSession
[SessionType.Draw]: typeof DrawSession
[SessionType.Handle]: typeof HandleSession
[SessionType.Rotate]: typeof RotateSession
[SessionType.Transform]: typeof TransformSession
[SessionType.TransformSingle]: typeof TransformSingleSession
[SessionType.Translate]: typeof TranslateSession
}
export type SessionOfType<K extends SessionType> = SessionsMap[K]
export type ArgsOfType<K extends SessionType> = ConstructorParameters<SessionOfType<K>>
export const sessions: { [K in SessionType]: SessionsMap[K] } = {
[SessionType.Arrow]: ArrowSession,
[SessionType.Brush]: BrushSession,
[SessionType.Draw]: DrawSession,
[SessionType.Handle]: HandleSession,
[SessionType.Rotate]: RotateSession,
[SessionType.Transform]: TransformSession,
[SessionType.TransformSingle]: TransformSingleSession,
[SessionType.Translate]: TranslateSession,
}
export const getSession = <K extends SessionType>(type: K): SessionOfType<K> => {
return sessions[type]
}

View file

@ -1,9 +1,10 @@
import { TLDrawState } from '~state'
import { mockDocument } from '~test'
import { ArrowShape, TLDrawShapeType, TLDrawStatus } from '~types'
import { ArrowShape, SessionType, TLDrawShapeType, TLDrawStatus } from '~types'
describe('Arrow session', () => {
const tlstate = new TLDrawState()
tlstate
.loadDocument(mockDocument)
.selectAll()
@ -15,12 +16,12 @@ describe('Arrow session', () => {
const restoreDoc = tlstate.document
it('begins, updates and completes session', () => {
tlstate
it('begins, updateSession', () => {
const tlstate = new TLDrawState()
.loadDocument(restoreDoc)
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([50, 50])
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([50, 50])
.completeSession()
const binding = tlstate.bindings[0]
@ -44,11 +45,11 @@ describe('Arrow session', () => {
})
it('cancels session', () => {
tlstate
const tlstate = new TLDrawState()
.loadDocument(restoreDoc)
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([50, 50])
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([50, 50])
.cancelSession()
expect(tlstate.bindings[0]).toBe(undefined)
@ -57,78 +58,78 @@ describe('Arrow session', () => {
describe('arrow binding', () => {
it('points to the center', () => {
tlstate
const tlstate = new TLDrawState()
.loadDocument(restoreDoc)
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([50, 50])
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([50, 50])
expect(tlstate.bindings[0].meta.point).toStrictEqual([0.5, 0.5])
})
it('Snaps to the center', () => {
tlstate
const tlstate = new TLDrawState()
.loadDocument(restoreDoc)
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([55, 55])
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([55, 55])
expect(tlstate.bindings[0].meta.point).toStrictEqual([0.5, 0.5])
})
it('Binds at the bottom left', () => {
tlstate
const tlstate = new TLDrawState()
.loadDocument(restoreDoc)
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([132, -32])
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([132, -32])
expect(tlstate.bindings[0].meta.point).toStrictEqual([1, 0])
})
it('Cancels the bind when off of the expanded bounds', () => {
tlstate
const tlstate = new TLDrawState()
.loadDocument(restoreDoc)
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([133, 133])
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([133, 133])
expect(tlstate.bindings[0]).toBe(undefined)
})
it('binds on the inside of a shape while meta is held', () => {
tlstate
const tlstate = new TLDrawState()
.loadDocument(restoreDoc)
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([91, 9])
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([91, 9])
expect(tlstate.bindings[0].meta.point).toStrictEqual([0.68, 0.13])
tlstate.updateHandleSession([91, 9], false, false, true)
tlstate.updateSession([91, 9], false, false, true)
})
it('snaps to the center when the point is close to the center', () => {
tlstate
const tlstate = new TLDrawState()
.loadDocument(restoreDoc)
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([91, 9])
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([91, 9])
expect(tlstate.bindings[0].meta.point).toStrictEqual([0.68, 0.13])
tlstate.updateHandleSession([91, 9], false, false, true)
tlstate.updateSession([91, 9], false, false, true)
expect(tlstate.bindings[0].meta.point).toStrictEqual([0.75, 0.25])
})
it('ignores binding when alt is held', () => {
tlstate
const tlstate = new TLDrawState()
.loadDocument(restoreDoc)
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([55, 45])
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([55, 45])
expect(tlstate.bindings[0].meta.point).toStrictEqual([0.5, 0.5])
tlstate.updateHandleSession([55, 45], false, false, true)
tlstate.updateSession([55, 45], false, false, true)
expect(tlstate.bindings[0].meta.point).toStrictEqual([0.5, 0.5])
})
@ -136,11 +137,13 @@ describe('Arrow session', () => {
describe('when dragging a bound shape', () => {
it('updates the arrow', () => {
const tlstate = new TLDrawState()
tlstate.loadDocument(restoreDoc)
// Select the arrow and begin a session on the handle's start handle
tlstate.select('arrow1').startHandleSession([200, 200], 'start')
tlstate.select('arrow1').startSession(SessionType.Arrow, [200, 200], 'start')
// Move to [50,50]
tlstate.updateHandleSession([50, 50]).completeSession()
tlstate.updateSession([50, 50])
// Both handles will keep the same screen positions, but their points will have changed.
expect(tlstate.getShape<ArrowShape>('arrow1').point).toStrictEqual([116, 116])
expect(tlstate.getShape<ArrowShape>('arrow1').handles.start.point).toStrictEqual([0, 0])

View file

@ -6,14 +6,14 @@ import {
Data,
Session,
TLDrawStatus,
SessionType,
} from '~types'
import { Vec } from '@tldraw/vec'
import { Utils } from '@tldraw/core'
import { TLDR } from '~state/tldr'
import { ThickArrowDownIcon } from '@radix-ui/react-icons'
export class ArrowSession implements Session {
id = 'transform_single'
static type = SessionType.Arrow
status = TLDrawStatus.TranslatingHandle
newBindingId = Utils.uniqueId()
delta = [0, 0]
@ -26,7 +26,7 @@ export class ArrowSession implements Session {
initialBinding: TLDrawBinding | undefined
didBind = false
constructor(data: Data, handleId: 'start' | 'end', point: number[]) {
constructor(data: Data, point: number[], handleId: 'start' | 'end') {
const { currentPageId } = data.appState
const page = data.document.pages[currentPageId]
const pageState = data.document.pageStates[currentPageId]

View file

@ -1,52 +1,61 @@
import { TLDrawState } from '~state'
import { mockDocument } from '~test'
import { TLDrawStatus } from '~types'
import { SessionType, TLDrawStatus } from '~types'
describe('Brush session', () => {
const tlstate = new TLDrawState()
tlstate.loadDocument(mockDocument)
it('begins, updates and completes session', () => {
tlstate.deselectAll()
tlstate.startBrushSession([-10, -10])
tlstate.updateBrushSession([10, 10])
tlstate.completeSession()
it('begins, updateSession', () => {
const tlstate = new TLDrawState()
.loadDocument(mockDocument)
.deselectAll()
.startSession(SessionType.Brush, [-10, -10])
.updateSession([10, 10])
.completeSession()
expect(tlstate.appState.status.current).toBe(TLDrawStatus.Idle)
expect(tlstate.selectedIds.length).toBe(1)
})
it('selects multiple shapes', () => {
tlstate.deselectAll()
tlstate.startBrushSession([-10, -10])
tlstate.updateBrushSession([110, 110])
tlstate.completeSession()
const tlstate = new TLDrawState()
.loadDocument(mockDocument)
.deselectAll()
.startSession(SessionType.Brush, [-10, -10])
.updateSession([110, 110])
.completeSession()
expect(tlstate.selectedIds.length).toBe(3)
})
it('does not de-select original shapes', () => {
tlstate.deselectAll()
tlstate
const tlstate = new TLDrawState()
.loadDocument(mockDocument)
.deselectAll()
.select('rect1')
.startBrushSession([300, 300])
.updateBrushSession([301, 301])
.startSession(SessionType.Brush, [300, 300])
.updateSession([301, 301])
.completeSession()
expect(tlstate.selectedIds.length).toBe(1)
})
it('does not select hidden shapes', () => {
tlstate.toggleHidden(['rect1'])
tlstate.deselectAll()
tlstate.startBrushSession([-10, -10])
tlstate.updateBrushSession([10, 10])
tlstate.completeSession()
expect(tlstate.selectedIds.length).toBe(0)
})
// it('does not select hidden shapes', () => {
// const tlstate = new TLDrawState()
// .loadDocument(mockDocument)
// .deselectAll()
// .toggleHidden(['rect1'])
// .deselectAll()
// .startSession(SessionType.Brush, [-10, -10])
// .updateSession([10, 10])
// .completeSession()
// })
it('when command is held, require the entire shape to be selected', () => {
tlstate.loadDocument(mockDocument)
tlstate.deselectAll()
tlstate.startBrushSession([-10, -10])
tlstate.updateBrushSession([10, 10])
tlstate.completeSession()
const tlstate = new TLDrawState()
.loadDocument(mockDocument)
.deselectAll()
.loadDocument(mockDocument)
.deselectAll()
.startSession(SessionType.Brush, [-10, -10])
.updateSession([10, 10], false, false, true)
.completeSession()
expect(tlstate.selectedIds.length).toBe(0)
})
})

View file

@ -1,10 +1,10 @@
import { Utils } from '@tldraw/core'
import { Vec } from '@tldraw/vec'
import { Data, Session, TLDrawPatch, TLDrawStatus } from '~types'
import { Data, Session, SessionType, TLDrawPatch, TLDrawStatus } from '~types'
import { TLDR } from '~state/tldr'
export class BrushSession implements Session {
id = 'brush'
static type = SessionType.Brush
status = TLDrawStatus.Brushing
origin: number[]
snapshot: BrushSnapshot
@ -16,7 +16,13 @@ export class BrushSession implements Session {
start = () => void null
update = (data: Data, point: number[], containMode = false): TLDrawPatch => {
update = (
data: Data,
point: number[],
shiftKey = false,
altKey = false,
metaKey = false
): TLDrawPatch => {
const { snapshot, origin } = this
const { currentPageId } = data.appState
@ -36,7 +42,7 @@ export class BrushSession implements Session {
if (!hits.has(selectId)) {
if (
containMode
metaKey
? Utils.boundsContain(brush, util.getBounds(shape))
: util.hitTestBounds(shape, brush)
) {

View file

@ -1,11 +1,18 @@
import { TLDrawState } from '~state'
import { mockDocument } from '~test'
import { ColorStyle, DashStyle, SizeStyle, TLDrawShapeType, TLDrawStatus } from '~types'
import {
ColorStyle,
DashStyle,
SessionType,
SizeStyle,
TLDrawShapeType,
TLDrawStatus,
} from '~types'
describe('Draw session', () => {
const tlstate = new TLDrawState()
it('begins, updates and completes session', () => {
it('begins, updateSession', () => {
tlstate.loadDocument(mockDocument)
expect(tlstate.getShape('draw1')).toBe(undefined)
@ -26,8 +33,8 @@ describe('Draw session', () => {
},
})
.select('draw1')
.startDrawSession('draw1', [0, 0])
.updateDrawSession([10, 10], 0.5)
.startSession(SessionType.Draw, [0, 0], 'draw1')
.updateSession([10, 10, 0.5])
.completeSession()
expect(tlstate.appState.status.current).toBe(TLDrawStatus.Idle)

View file

@ -1,10 +1,10 @@
import { Utils } from '@tldraw/core'
import { Vec } from '@tldraw/vec'
import { Data, DrawShape, Session, TLDrawStatus } from '~types'
import { Data, DrawShape, Session, SessionType, TLDrawStatus } from '~types'
import { TLDR } from '~state/tldr'
export class DrawSession implements Session {
id = 'draw'
static type = SessionType.Draw
status = TLDrawStatus.Creating
topLeft: number[]
origin: number[]
@ -12,17 +12,16 @@ export class DrawSession implements Session {
last: number[]
points: number[][]
shiftedPoints: number[][] = []
snapshot: DrawSnapshot
shapeId: string
isLocked?: boolean
lockedDirection?: 'horizontal' | 'vertical'
constructor(data: Data, id: string, point: number[]) {
constructor(data: Data, point: number[], id: string) {
this.origin = point
this.previous = point
this.last = point
this.topLeft = point
this.snapshot = getDrawSnapshot(data, id)
this.shapeId = id
// Add a first point but don't update the shape yet. We'll update
// when the draw session ends; if the user hasn't added additional
@ -32,8 +31,8 @@ export class DrawSession implements Session {
start = () => void null
update = (data: Data, point: number[], pressure: number, isLocked = false) => {
const { snapshot } = this
update = (data: Data, point: number[], shiftKey: boolean) => {
const { shapeId } = this
// Even if we're not locked yet, we base the future locking direction
// on the first dimension to reach a threshold, or the bigger dimension
@ -47,7 +46,7 @@ export class DrawSession implements Session {
// Drawing while holding shift will "lock" the pen to either the
// x or y axis, depending on the locking direction.
if (isLocked) {
if (shiftKey) {
if (!this.isLocked && this.points.length > 2) {
// If we're locking before knowing what direction we're in, set it
// early based on the bigger dimension.
@ -67,7 +66,7 @@ export class DrawSession implements Session {
}
this.previous = returning
this.points.push(returning.concat(pressure))
this.points.push(returning.concat(point[2]))
}
} else if (this.isLocked) {
this.isLocked = false
@ -82,7 +81,7 @@ export class DrawSession implements Session {
}
// The new adjusted point
const newPoint = Vec.round(Vec.sub(point, this.origin)).concat(pressure)
const newPoint = Vec.round(Vec.sub(point, this.origin)).concat(point[2])
// Don't add duplicate points.
if (Vec.isEqual(this.last, newPoint)) return
@ -129,7 +128,7 @@ export class DrawSession implements Session {
pages: {
[data.appState.currentPageId]: {
shapes: {
[snapshot.id]: {
[shapeId]: {
point: this.topLeft,
points,
},
@ -138,7 +137,7 @@ export class DrawSession implements Session {
},
pageStates: {
[data.appState.currentPageId]: {
selectedIds: [snapshot.id],
selectedIds: [shapeId],
},
},
},
@ -146,7 +145,7 @@ export class DrawSession implements Session {
}
cancel = (data: Data) => {
const { snapshot } = this
const { shapeId } = this
const pageId = data.appState.currentPageId
return {
@ -154,7 +153,7 @@ export class DrawSession implements Session {
pages: {
[pageId]: {
shapes: {
[snapshot.id]: undefined,
[shapeId]: undefined,
},
},
},
@ -168,7 +167,7 @@ export class DrawSession implements Session {
}
complete = (data: Data) => {
const { snapshot } = this
const { shapeId } = this
const pageId = data.appState.currentPageId
return {
@ -178,7 +177,7 @@ export class DrawSession implements Session {
pages: {
[pageId]: {
shapes: {
[snapshot.id]: undefined,
[shapeId]: undefined,
},
},
},
@ -194,7 +193,7 @@ export class DrawSession implements Session {
pages: {
[pageId]: {
shapes: {
[snapshot.id]: TLDR.getShape(data, snapshot.id, pageId),
[shapeId]: TLDR.getShape(data, shapeId, pageId),
},
},
},
@ -208,18 +207,3 @@ export class DrawSession implements Session {
}
}
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export function getDrawSnapshot(data: Data, shapeId: string) {
const page = { ...TLDR.getPage(data, data.appState.currentPageId) }
const { points, point } = Utils.deepClone(page.shapes[shapeId]) as DrawShape
return {
id: shapeId,
point,
points,
}
}
export type DrawSnapshot = ReturnType<typeof getDrawSnapshot>

View file

@ -1,12 +1,11 @@
import { TLDrawState } from '~state'
import { mockDocument } from '~test'
import { TLDR } from '~state/tldr'
import { TLDrawShape, TLDrawShapeType, TLDrawStatus } from '~types'
import { SessionType, TLDrawShapeType, TLDrawStatus } from '~types'
describe('Handle session', () => {
const tlstate = new TLDrawState()
it('begins, updates and completes session', () => {
it('begins, updateSession', () => {
tlstate
.loadDocument(mockDocument)
.createShapes({
@ -14,8 +13,8 @@ describe('Handle session', () => {
type: TLDrawShapeType.Arrow,
})
.select('arrow1')
.startHandleSession([-10, -10], 'end')
.updateHandleSession([10, 10])
.startSession(SessionType.Arrow, [-10, -10], 'end')
.updateSession([10, 10])
.completeSession()
expect(tlstate.appState.status.current).toBe(TLDrawStatus.Idle)
@ -31,8 +30,8 @@ describe('Handle session', () => {
id: 'arrow1',
})
.select('arrow1')
.startHandleSession([-10, -10], 'end')
.updateHandleSession([10, 10])
.startSession(SessionType.Arrow, [-10, -10], 'end')
.updateSession([10, 10])
.cancelSession()
expect(tlstate.getShape('rect1').point).toStrictEqual([0, 0])

View file

@ -1,11 +1,11 @@
import { Vec } from '@tldraw/vec'
import { ShapesWithProp, TLDrawStatus } from '~types'
import { SessionType, ShapesWithProp, TLDrawStatus } from '~types'
import type { Session } from '~types'
import type { Data } from '~types'
import { TLDR } from '~state/tldr'
export class HandleSession implements Session {
id = 'transform_single'
static type = SessionType.Handle
status = TLDrawStatus.TranslatingHandle
commandId: string
delta = [0, 0]
@ -15,7 +15,7 @@ export class HandleSession implements Session {
initialShape: ShapesWithProp<'handles'>
handleId: string
constructor(data: Data, handleId: string, point: number[], commandId = 'move_handle') {
constructor(data: Data, point: number[], handleId: string, commandId = 'move_handle') {
const { currentPageId } = data.appState
const shapeId = TLDR.getSelectedIds(data, currentPageId)[0]
this.topLeft = point

View file

@ -5,5 +5,4 @@ export * from './transform'
export * from './draw'
export * from './rotate'
export * from './handle'
export * from './text'
export * from './arrow'

View file

@ -1,37 +1,34 @@
import Vec from '@tldraw/vec'
import Utils from '~../../core/src/utils'
import { Utils } from '@tldraw/core'
import { TLDrawState } from '~state'
import { mockDocument } from '~test'
import { TLDrawStatus } from '~types'
import { SessionType, TLDrawStatus } from '~types'
describe('Rotate session', () => {
const tlstate = new TLDrawState()
it('begins, updates and completes session', () => {
it('begins, updateSession', () => {
tlstate.loadDocument(mockDocument)
expect(tlstate.getShape('rect1').rotation).toBe(undefined)
tlstate
.select('rect1')
.startTransformSession([50, 0], 'rotate')
.updateTransformSession([100, 50])
tlstate.select('rect1').startSession(SessionType.Rotate, [50, 0]).updateSession([100, 50])
expect(tlstate.getShape('rect1').rotation).toBe(Math.PI / 2)
tlstate.updateTransformSession([50, 100])
tlstate.updateSession([50, 100])
expect(tlstate.getShape('rect1').rotation).toBe(Math.PI)
tlstate.updateTransformSession([0, 50])
tlstate.updateSession([0, 50])
expect(tlstate.getShape('rect1').rotation).toBe((Math.PI * 3) / 2)
tlstate.updateTransformSession([50, 0])
tlstate.updateSession([50, 0])
expect(tlstate.getShape('rect1').rotation).toBe(0)
tlstate.updateTransformSession([0, 50])
tlstate.updateSession([0, 50])
expect(tlstate.getShape('rect1').rotation).toBe((Math.PI * 3) / 2)
@ -52,8 +49,8 @@ describe('Rotate session', () => {
tlstate
.loadDocument(mockDocument)
.select('rect1')
.startTransformSession([50, 0], 'rotate')
.updateTransformSession([100, 50])
.startSession(SessionType.Rotate, [50, 0])
.updateSession([100, 50])
.cancel()
expect(tlstate.getShape('rect1').point).toStrictEqual([0, 0])
@ -64,10 +61,12 @@ describe('Rotate session', () => {
describe('when rotating a single shape while pressing shift', () => {
it('Clamps rotation to 15 degrees', () => {
const tlstate = new TLDrawState()
tlstate
.loadDocument(mockDocument)
.select('rect1')
.startTransformSession([0, 0], 'rotate')
.updateTransformSession([20, 10], true)
.startSession(SessionType.Rotate, [0, 0])
.updateSession([20, 10], true)
.completeSession()
expect(Math.round((tlstate.getShape('rect1').rotation || 0) * (180 / Math.PI)) % 15).toEqual(
@ -78,17 +77,19 @@ describe('Rotate session', () => {
it('Clamps rotation to 15 degrees when starting from a rotation', () => {
// Rect 1 is a little rotated
const tlstate = new TLDrawState()
tlstate
.loadDocument(mockDocument)
.select('rect1')
.startTransformSession([0, 0], 'rotate')
.updateTransformSession([5, 5])
.startSession(SessionType.Rotate, [0, 0])
.updateSession([5, 5])
.completeSession()
// Rect 1 clamp rotated, starting from a little rotation
tlstate
.select('rect1')
.startTransformSession([0, 0], 'rotate')
.updateTransformSession([100, 200], true)
.startSession(SessionType.Rotate, [0, 0])
.updateSession([100, 200], true)
.completeSession()
expect(Math.round((tlstate.getShape('rect1').rotation || 0) * (180 / Math.PI)) % 15).toEqual(
@ -98,8 +99,8 @@ describe('Rotate session', () => {
// Try again, too.
tlstate
.select('rect1')
.startTransformSession([0, 0], 'rotate')
.updateTransformSession([-100, 5000], true)
.startSession(SessionType.Rotate, [0, 0])
.updateSession([-100, 5000], true)
.completeSession()
expect(Math.round((tlstate.getShape('rect1').rotation || 0) * (180 / Math.PI)) % 15).toEqual(
@ -118,7 +119,7 @@ describe('Rotate session', () => {
)
)
tlstate.startTransformSession([50, 0], 'rotate').updateTransformSession([100, 50])
tlstate.startSession(SessionType.Rotate, [50, 0]).updateSession([100, 50])
const centerAfter = Vec.round(
Utils.getBoundsCenter(

View file

@ -1,13 +1,13 @@
import { Utils } from '@tldraw/core'
import { Vec } from '@tldraw/vec'
import { Session, TLDrawShape, TLDrawStatus } from '~types'
import { Session, SessionType, TLDrawShape, TLDrawStatus } from '~types'
import type { Data } from '~types'
import { TLDR } from '~state/tldr'
const centerCache = new WeakMap<string[], number[]>()
export class RotateSession implements Session {
id = 'rotate'
static type = SessionType.Rotate
status = TLDrawStatus.Transforming
delta = [0, 0]
origin: number[]

View file

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

View file

@ -1,40 +0,0 @@
import { TLDrawState } from '~state'
import { mockDocument } from '~test'
import { TLDR } from '~state/tldr'
import { TextShape, TLDrawShape, TLDrawShapeType } from '~types'
describe('Text session', () => {
const tlstate = new TLDrawState()
it('begins, updates and completes session', () => {
tlstate
.loadDocument(mockDocument)
.createShapes({
id: 'text1',
type: TLDrawShapeType.Text,
})
.select('text1')
.startTextSession('text1')
.updateTextSession('Hello world')
.completeSession()
.undo()
.redo()
expect(tlstate.getShape<TextShape>('text1').text).toStrictEqual('Hello world')
})
it('cancels session', () => {
tlstate
.loadDocument(mockDocument)
.createShapes({
id: 'text1',
type: TLDrawShapeType.Text,
})
.select('text1')
.startTextSession('text1')
.updateTextSession('Hello world')
.cancelSession()
expect(tlstate.getShape<TextShape>('text1').text).toStrictEqual('Hello world')
})
})

View file

@ -1,173 +0,0 @@
import { TextShape, TLDrawStatus } from '~types'
import type { Session } from '~types'
import type { Data } from '~types'
import { TLDR } from '~state/tldr'
export class TextSession implements Session {
id = 'text'
status = TLDrawStatus.EditingText
initialShape: TextShape
constructor(data: Data, id?: string) {
const pageId = data.appState.currentPageId
this.initialShape = TLDR.getShape(data, id || TLDR.getSelectedIds(data, pageId)[0], pageId)
}
start = (data: Data) => {
const pageId = data.appState.currentPageId
return {
document: {
pageStates: {
[pageId]: {
editingId: this.initialShape.id,
},
},
},
}
}
update = (data: Data, text: string) => {
const { initialShape } = this
const pageId = data.appState.currentPageId
// let nextShape: TextShape = {
// ...TLDR.getShape<TextShape>(data, initialShape.id, pageId),
// text,
// }
// nextShape = {
// ...nextShape,
// ...TLDR.getShapeUtils(nextShape).onStyleChange(nextShape),
// } as TextShape
return {
document: {
pages: {
[pageId]: {
shapes: {
[initialShape.id]: {
text,
},
},
},
},
},
}
}
cancel = (data: Data) => {
const { initialShape } = this
const pageId = data.appState.currentPageId
return {
document: {
pages: {
[pageId]: {
shapes: {
[initialShape.id]: TLDR.onSessionComplete(
TLDR.getShape(data, initialShape.id, pageId)
),
},
},
},
pageState: {
[pageId]: {
editingId: undefined,
},
},
},
}
}
complete(data: Data) {
const { initialShape } = this
const pageId = data.appState.currentPageId
const shape = TLDR.getShape<TextShape>(data, initialShape.id, pageId)
// TODO: Delete text shape if its content is empty
// TODO: ...and prevent `isCreating` from selecting the deleted shape
// if (initialShape.text.trim() === '' && shape.text.trim() === '') {
// // delete shape
// return {
// id: 'text',
// before: {
// document: {
// pages: {
// [pageId]: {
// shapes: {
// [initialShape.id]: undefined,
// },
// },
// },
// pageState: {
// [pageId]: {
// editingId: undefined,
// selectedIds: [initialShape.id],
// },
// },
// },
// },
// after: {
// document: {
// pages: {
// [pageId]: {
// shapes: {
// [initialShape.id]: undefined,
// },
// },
// },
// pageState: {
// [pageId]: {
// editingId: undefined,
// selectedIds: [],
// },
// },
// },
// },
// }
// }
if (shape.text === initialShape.text) return undefined
return {
id: 'text',
before: {
document: {
pages: {
[pageId]: {
shapes: {
[initialShape.id]: initialShape,
},
},
},
pageState: {
[pageId]: {
editingId: null,
},
},
},
},
after: {
document: {
pages: {
[pageId]: {
shapes: {
[initialShape.id]: TLDR.onSessionComplete(
TLDR.getShape(data, initialShape.id, pageId)
),
},
},
},
pageState: {
[pageId]: {
editingId: null,
selectedIds: [initialShape.id],
},
},
},
},
}
}
}

View file

@ -1,17 +1,17 @@
import { TLDrawState } from '~state'
import { mockDocument } from '~test'
import { TLBoundsCorner } from '@tldraw/core'
import { TLDrawStatus } from '~types'
import { SessionType, TLDrawStatus } from '~types'
describe('Transform single session', () => {
const tlstate = new TLDrawState()
it('begins, updates and completes session', () => {
it('begins, updateSession', () => {
tlstate
.loadDocument(mockDocument)
.select('rect1')
.startTransformSession([-10, -10], TLBoundsCorner.TopLeft)
.updateTransformSession([10, 10])
.startSession(SessionType.TransformSingle, [-10, -10], TLBoundsCorner.TopLeft)
.updateSession([10, 10])
.completeSession()
expect(tlstate.appState.status.current).toBe(TLDrawStatus.Idle)
@ -23,8 +23,8 @@ describe('Transform single session', () => {
tlstate
.loadDocument(mockDocument)
.select('rect1')
.startTransformSession([5, 5], TLBoundsCorner.TopLeft)
.updateTransformSession([10, 10])
.startSession(SessionType.TransformSingle, [5, 5], TLBoundsCorner.TopLeft)
.updateSession([10, 10])
.cancelSession()
expect(tlstate.getShape('rect1').point).toStrictEqual([0, 0])

View file

@ -1,12 +1,12 @@
import { TLBoundsCorner, TLBoundsEdge, Utils } from '@tldraw/core'
import { Vec } from '@tldraw/vec'
import { TLDrawShape, TLDrawStatus } from '~types'
import { SessionType, TLDrawShape, TLDrawStatus } from '~types'
import type { Session } from '~types'
import type { Data } from '~types'
import { TLDR } from '~state/tldr'
export class TransformSingleSession implements Session {
id = 'transform_single'
type = SessionType.TransformSingle
status = TLDrawStatus.Transforming
commandId: string
transformType: TLBoundsEdge | TLBoundsCorner
@ -29,7 +29,7 @@ export class TransformSingleSession implements Session {
start = () => void null
update = (data: Data, point: number[], isAspectRatioLocked = false) => {
update = (data: Data, point: number[], shiftKey: boolean) => {
const { transformType } = this
const { initialShapeBounds, initialShape, id } = this.snapshot
@ -45,7 +45,7 @@ export class TransformSingleSession implements Session {
transformType,
Vec.sub(point, this.origin),
shape.rotation,
isAspectRatioLocked || shape.isAspectRatioLocked || utils.isAspectRatioLocked
shiftKey || shape.isAspectRatioLocked || utils.isAspectRatioLocked
)
const change = TLDR.getShapeUtils(shape).transformSingle(shape, newBounds, {

View file

@ -2,7 +2,7 @@ import { TLDrawState } from '~state'
import { mockDocument } from '~test'
import { TLBoundsCorner, Utils } from '@tldraw/core'
import { TLDR } from '~state/tldr'
import { TLDrawStatus } from '~types'
import { SessionType, TLDrawStatus } from '~types'
function getShapeBounds(tlstate: TLDrawState, ...ids: string[]) {
return Utils.getCommonBounds(
@ -13,7 +13,7 @@ function getShapeBounds(tlstate: TLDrawState, ...ids: string[]) {
describe('Transform session', () => {
const tlstate = new TLDrawState()
it('begins, updates and completes session', () => {
it('begins, updateSession', () => {
tlstate.loadDocument(mockDocument)
expect(getShapeBounds(tlstate, 'rect1')).toMatchObject({
@ -27,8 +27,8 @@ describe('Transform session', () => {
tlstate
.select('rect1', 'rect2')
.startTransformSession([0, 0], TLBoundsCorner.TopLeft)
.updateTransformSession([10, 10])
.startSession(SessionType.Transform, [0, 0], TLBoundsCorner.TopLeft)
.updateSession([10, 10])
.completeSession()
expect(tlstate.appState.status.current).toBe(TLDrawStatus.Idle)
@ -60,8 +60,8 @@ describe('Transform session', () => {
tlstate
.loadDocument(mockDocument)
.select('rect1', 'rect2')
.startTransformSession([5, 5], TLBoundsCorner.TopLeft)
.updateTransformSession([10, 10])
.startSession(SessionType.Transform, [5, 5], TLBoundsCorner.TopLeft)
.updateSession([10, 10])
.cancelSession()
expect(tlstate.getShape('rect1').point).toStrictEqual([0, 0])
@ -72,8 +72,8 @@ describe('Transform session', () => {
tlstate
.loadDocument(mockDocument)
.select('rect1')
.startTransformSession([0, 0], TLBoundsCorner.TopLeft)
.updateTransformSession([10, 10])
.startSession(SessionType.Transform, [0, 0], TLBoundsCorner.TopLeft)
.updateSession([10, 10])
.completeSession()
expect(getShapeBounds(tlstate)).toMatchObject({
@ -90,8 +90,8 @@ describe('Transform session', () => {
tlstate
.loadDocument(mockDocument)
.select('rect1')
.startTransformSession([0, 0], TLBoundsCorner.TopLeft)
.updateTransformSession([20, 10], true)
.startSession(SessionType.Transform, [0, 0], TLBoundsCorner.TopLeft)
.updateSession([20, 10], true)
.completeSession()
expect(getShapeBounds(tlstate, 'rect1')).toMatchObject({
@ -108,8 +108,8 @@ describe('Transform session', () => {
tlstate
.loadDocument(mockDocument)
.select('rect1', 'rect2')
.startTransformSession([0, 0], TLBoundsCorner.TopLeft)
.updateTransformSession([10, 10])
.startSession(SessionType.Transform, [0, 0], TLBoundsCorner.TopLeft)
.updateSession([10, 10])
.completeSession()
expect(getShapeBounds(tlstate, 'rect1')).toMatchObject({
@ -135,8 +135,8 @@ describe('Transform session', () => {
tlstate
.loadDocument(mockDocument)
.select('rect1', 'rect2')
.startTransformSession([0, 0], TLBoundsCorner.TopLeft)
.updateTransformSession([20, 10], true)
.startSession(SessionType.Transform, [0, 0], TLBoundsCorner.TopLeft)
.updateSession([20, 10], true)
.completeSession()
expect(getShapeBounds(tlstate, 'rect1')).toMatchObject({
@ -194,8 +194,8 @@ describe('Transform session', () => {
.loadDocument(mockDocument)
.group(['rect1', 'rect2'], 'groupA')
.select('groupA')
.startTransformSession([0, 0], TLBoundsCorner.TopLeft)
.updateTransformSession([10, 10])
.startSession(SessionType.Transform, [0, 0], TLBoundsCorner.TopLeft)
.updateSession([10, 10])
.completeSession()
expect(getShapeBounds(tlstate, 'rect1')).toMatchObject({

View file

@ -1,11 +1,11 @@
import { TLBoundsCorner, TLBoundsEdge, Utils } from '@tldraw/core'
import { Vec } from '@tldraw/vec'
import { Session, TLDrawShape, TLDrawStatus } from '~types'
import { Session, SessionType, TLDrawShape, TLDrawStatus } from '~types'
import type { Data } from '~types'
import { TLDR } from '~state/tldr'
export class TransformSession implements Session {
id = 'transform'
static type = SessionType.Transform
status = TLDrawStatus.Transforming
scaleX = 1
scaleY = 1
@ -26,7 +26,7 @@ export class TransformSession implements Session {
start = () => void null
// eslint-disable-next-line @typescript-eslint/no-unused-vars
update = (data: Data, point: number[], isAspectRatioLocked = false, _altKey = false) => {
update = (data: Data, point: number[], shiftKey: boolean, altKey: boolean, metaKey: boolean) => {
const {
transformType,
snapshot: { shapeBounds, initialBounds, isAllAspectRatioLocked },
@ -41,7 +41,7 @@ export class TransformSession implements Session {
transformType,
Vec.sub(point, this.origin),
pageState.boundsRotation,
isAspectRatioLocked || isAllAspectRatioLocked
shiftKey || isAllAspectRatioLocked
)
// Now work backward to calculate a new bounding box for each of the shapes.

View file

@ -1,16 +1,16 @@
import { TLDrawState } from '~state'
import { mockDocument } from '~test'
import { GroupShape, TLDrawShapeType, TLDrawStatus } from '~types'
import { GroupShape, SessionType, TLDrawShapeType, TLDrawStatus } from '~types'
describe('Translate session', () => {
const tlstate = new TLDrawState()
it('begins, updates and completes session', () => {
it('begins, updateSession', () => {
tlstate
.loadDocument(mockDocument)
.select('rect1')
.startTranslateSession([5, 5])
.updateTranslateSession([10, 10])
.startSession(SessionType.Translate, [5, 5])
.updateSession([10, 10])
expect(tlstate.getShape('rect1').point).toStrictEqual([5, 5])
@ -33,8 +33,8 @@ describe('Translate session', () => {
tlstate
.loadDocument(mockDocument)
.select('rect1', 'rect2')
.startTranslateSession([5, 5])
.updateTranslateSession([10, 10])
.startSession(SessionType.Translate, [5, 5])
.updateSession([10, 10])
.cancelSession()
expect(tlstate.getShape('rect1').point).toStrictEqual([0, 0])
@ -44,8 +44,8 @@ describe('Translate session', () => {
tlstate
.loadDocument(mockDocument)
.select('rect1')
.startTranslateSession([10, 10])
.updateTranslateSession([20, 20])
.startSession(SessionType.Translate, [10, 10])
.updateSession([20, 20])
.completeSession()
expect(tlstate.getShape('rect1').point).toStrictEqual([10, 10])
@ -55,8 +55,8 @@ describe('Translate session', () => {
tlstate
.loadDocument(mockDocument)
.select('rect1')
.startTranslateSession([10, 10])
.updateTranslateSession([20, 20], true)
.startSession(SessionType.Translate, [10, 10])
.updateSession([20, 20], true)
.completeSession()
expect(tlstate.getShape('rect1').point).toStrictEqual([10, 0])
@ -66,8 +66,8 @@ describe('Translate session', () => {
tlstate
.loadDocument(mockDocument)
.select('rect1', 'rect2')
.startTranslateSession([10, 10])
.updateTranslateSession([20, 20])
.startSession(SessionType.Translate, [10, 10])
.updateSession([20, 20])
.completeSession()
expect(tlstate.getShape('rect1').point).toStrictEqual([10, 10])
@ -78,8 +78,8 @@ describe('Translate session', () => {
tlstate
.loadDocument(mockDocument)
.select('rect1', 'rect2')
.startTranslateSession([10, 10])
.updateTranslateSession([20, 20], false, true)
.startSession(SessionType.Translate, [10, 10])
.updateSession([20, 20], false, true)
.completeSession()
expect(tlstate.getShape('rect1').point).toStrictEqual([0, 0])
@ -100,8 +100,8 @@ describe('Translate session', () => {
tlstate
.loadDocument(mockDocument)
.select('rect1', 'rect2')
.startTranslateSession([10, 10])
.updateTranslateSession([20, 20], false, true)
.startSession(SessionType.Translate, [10, 10])
.updateSession([20, 20], false, true)
.completeSession()
expect(tlstate.getShape('rect1').point).toStrictEqual([0, 0])
@ -117,12 +117,12 @@ describe('Translate session', () => {
tlstate
.select('rect1', 'rect2')
.startTranslateSession([10, 10])
.updateTranslateSession([20, 20], false, true)
.startSession(SessionType.Translate, [10, 10])
.updateSession([20, 20], false, true)
expect(Object.keys(tlstate.getPage().shapes).length).toBe(5)
tlstate.updateTranslateSession([30, 30], false, false)
tlstate.updateSession([30, 30], false, false)
expect(Object.keys(tlstate.getPage().shapes).length).toBe(3)
@ -156,16 +156,16 @@ describe('Translate session', () => {
}
)
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([50, 50])
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([50, 50])
.completeSession()
expect(tlstate.bindings.length).toBe(1)
tlstate
.select('arrow1')
.startTranslateSession([10, 10])
.updateTranslateSession([30, 30])
.startSession(SessionType.Translate, [10, 10])
.updateSession([30, 30])
.completeSession()
// expect(tlstate.bindings.length).toBe(0)
@ -191,8 +191,8 @@ describe('Translate session', () => {
.select('rect1', 'rect2')
.group(['rect1', 'rect2'], 'groupA')
.select('rect1')
.startTranslateSession([10, 10])
.updateTranslateSession([20, 20], false, false)
.startSession(SessionType.Translate, [10, 10])
.updateSession([20, 20], false, false)
.completeSession()
expect(tlstate.getShape('groupA').point).toStrictEqual([10, 10])
@ -218,8 +218,8 @@ describe('Translate session', () => {
.select('rect1', 'rect2')
.group(['rect1', 'rect2'], 'groupA')
.select('rect1')
.startTranslateSession([10, 10])
.updateTranslateSession([20, 20], false, true)
.startSession(SessionType.Translate, [10, 10])
.updateSession([20, 20], false, true)
.completeSession()
const children = tlstate.getShape<GroupShape>('groupA').children
@ -257,8 +257,8 @@ describe('Translate session', () => {
.loadDocument(mockDocument)
.select('rect1', 'rect2')
.group(['rect1', 'rect2'], 'groupA')
.startTranslateSession([10, 10])
.updateTranslateSession([20, 20], false, false)
.startSession(SessionType.Translate, [10, 10])
.updateSession([20, 20], false, false)
.completeSession()
expect(tlstate.getShape('groupA').point).toStrictEqual([10, 10])
@ -283,8 +283,8 @@ describe('Translate session', () => {
.loadDocument(mockDocument)
.select('rect1', 'rect2')
.group()
.startTranslateSession([10, 10])
.updateTranslateSession([20, 20], false, true)
.startSession(SessionType.Translate, [10, 10])
.updateSession([20, 20], false, true)
.completeSession()
})
@ -293,10 +293,10 @@ describe('Translate session', () => {
.loadDocument(mockDocument)
.select('rect1', 'rect2')
.group()
.startTranslateSession([10, 10])
.updateTranslateSession([20, 20], false, true)
.updateTranslateSession([20, 20], false, false)
.updateTranslateSession([20, 20], false, true)
.startSession(SessionType.Translate, [10, 10])
.updateSession([20, 20], false, true)
.updateSession([20, 20], false, false)
.updateSession([20, 20], false, true)
.completeSession()
})
@ -306,8 +306,8 @@ describe('Translate session', () => {
.select('rect1', 'rect2')
.group(['rect1', 'rect2'], 'groupA')
.select('groupA', 'rect3')
.startTranslateSession([10, 10])
.updateTranslateSession([20, 20], false, true)
.startSession(SessionType.Translate, [10, 10])
.updateSession([20, 20], false, true)
.completeSession()
})
})

View file

@ -10,12 +10,13 @@ import {
TLDrawStatus,
ArrowShape,
GroupShape,
SessionType,
} from '~types'
import { TLDR } from '~state/tldr'
import type { Patch } from 'rko'
export class TranslateSession implements Session {
id = 'translate'
type = SessionType.Translate
status = TLDrawStatus.Translating
delta = [0, 0]
prev = [0, 0]
@ -48,7 +49,7 @@ export class TranslateSession implements Session {
}
}
update = (data: Data, point: number[], isAligned = false, isCloning = false) => {
update = (data: Data, point: number[], shiftKey: boolean, altKey: boolean) => {
const { selectedIds, initialParentChildren, clones, initialShapes, bindingsToDelete } =
this.snapshot
@ -59,7 +60,7 @@ export class TranslateSession implements Session {
const delta = Vec.sub(point, this.origin)
if (isAligned) {
if (shiftKey) {
if (Math.abs(delta[0]) < Math.abs(delta[1])) {
delta[0] = 0
} else {
@ -73,7 +74,7 @@ export class TranslateSession implements Session {
this.prev = delta
// If cloning...
if (isCloning) {
if (altKey) {
// Not Cloning -> Cloning
if (!this.isCloning) {
this.isCloning = true

View file

@ -1,6 +1,10 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { TLDrawState } from './tlstate'
import { mockDocument, TLStateUtils } from '~test'
import { ArrowShape, ColorStyle, TLDrawShapeType } from '~types'
import { ArrowShape, ColorStyle, SessionType, TLDrawShapeType } from '~types'
import type { TextTool } from './tool/TextTool'
import type { SelectTool } from './tool/SelectTool'
import { Shape } from '~../../core/src/components/shape/shape'
describe('TLDrawState', () => {
const tlstate = new TLDrawState()
@ -61,8 +65,8 @@ describe('TLDrawState', () => {
{ type: TLDrawShapeType.Arrow, id: 'arrow1', point: [200, 200] }
)
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([55, 55])
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([55, 55])
.completeSession()
expect(tlstate.bindings.length).toBe(1)
@ -87,8 +91,8 @@ describe('TLDrawState', () => {
{ type: TLDrawShapeType.Arrow, id: 'arrow1', point: [200, 200] }
)
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([55, 55])
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([55, 55])
.completeSession()
expect(tlstate.bindings.length).toBe(1)
@ -138,8 +142,8 @@ describe('TLDrawState', () => {
it('clears selection when clicking bounds', () => {
tlstate.loadDocument(mockDocument).deselectAll()
tlstate.startBrushSession([-10, -10])
tlstate.updateBrushSession([110, 110])
tlstate.startSession(SessionType.Brush, [-10, -10])
tlstate.updateSession([110, 110])
tlstate.completeSession()
expect(tlstate.selectedIds.length).toBe(3)
})
@ -301,8 +305,8 @@ describe('TLDrawState', () => {
}
)
.select('arrow')
.startHandleSession([200, 200], 'start', 'arrow')
.updateHandleSession([10, 10])
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([10, 10])
.completeSession()
.selectAll()
.style({ color: ColorStyle.Red })
@ -340,7 +344,7 @@ describe('TLDrawState', () => {
const tlu = new TLStateUtils(tlstate)
tlu.doubleClickShape('rect1')
expect(tlstate.selectedGroupId).toStrictEqual('groupA')
expect((tlstate.currentTool as SelectTool).selectedGroupId).toStrictEqual('groupA')
expect(tlstate.selectedIds).toStrictEqual(['rect1'])
})
@ -407,17 +411,35 @@ describe('TLDrawState', () => {
}
)
.selectTool(TLDrawShapeType.Rectangle)
.createActiveToolShape([0, 0], 'rect4')
expect(tlstate.getShape('rect4').childIndex).toBe(4)
const tlu = new TLStateUtils(tlstate)
tlstate
.group(['rect2', 'rect3', 'rect4'], 'groupA')
.selectTool(TLDrawShapeType.Rectangle)
.createActiveToolShape([0, 0], 'rect5')
const prevA = tlstate.shapes.map((shape) => shape.id)
tlu.pointCanvas({ x: 0, y: 0 })
tlu.movePointer({ x: 100, y: 100 })
tlu.stopPointing()
const newIdA = tlstate.shapes.map((shape) => shape.id).find((id) => !prevA.includes(id))!
const shapeA = tlstate.getShape(newIdA)
expect(shapeA.childIndex).toBe(4)
tlstate.group(['rect2', 'rect3', newIdA], 'groupA')
expect(tlstate.getShape('groupA').childIndex).toBe(2)
expect(tlstate.getShape('rect5').childIndex).toBe(3)
tlstate.deselectAll()
tlstate.selectTool(TLDrawShapeType.Rectangle)
const prevB = tlstate.shapes.map((shape) => shape.id)
tlu.pointCanvas({ x: 0, y: 0 })
tlu.movePointer({ x: 100, y: 100 })
tlu.stopPointing()
const newIdB = tlstate.shapes.map((shape) => shape.id).find((id) => !prevB.includes(id))!
const shapeB = tlstate.getShape(newIdB)
expect(shapeB.childIndex).toBe(3)
})
})

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,9 @@
import { TLDrawState } from '~state'
import { ArrowTool } from '.'
describe('ArrowTool', () => {
it('creates tool', () => {
const tlstate = new TLDrawState()
new ArrowTool(tlstate)
})
})

View file

@ -0,0 +1,65 @@
import Vec from '@tldraw/vec'
import { Utils, TLPointerEventHandler } from '@tldraw/core'
import { Arrow } from '~shape/shapes'
import { SessionType, TLDrawShapeType } from '~types'
import { BaseTool } from '../BaseTool'
enum Status {
Idle = 'idle',
Creating = 'creating',
}
export class ArrowTool extends BaseTool {
type = TLDrawShapeType.Arrow
status = Status.Idle
/* --------------------- Methods -------------------- */
private setStatus(status: Status) {
this.status = status
}
/* ----------------- Event Handlers ----------------- */
onPointerDown: TLPointerEventHandler = (info) => {
const pagePoint = Vec.round(this.state.getPagePoint(info.point))
const {
appState: { currentPageId, currentStyle },
} = this.state
const childIndex = this.getNextChildIndex()
const id = Utils.uniqueId()
const newShape = Arrow.create({
id,
parentId: currentPageId,
childIndex,
point: pagePoint,
style: { ...currentStyle },
})
this.state.createShapes(newShape)
this.state.startSession(SessionType.Arrow, pagePoint, 'end')
this.setStatus(Status.Creating)
}
onPointerMove: TLPointerEventHandler = (info) => {
if (this.status === Status.Creating) {
const pagePoint = Vec.round(this.state.getPagePoint(info.point))
this.state.updateSession(pagePoint, info.shiftKey, info.altKey, info.metaKey)
}
}
onPointerUp: TLPointerEventHandler = () => {
if (this.status === Status.Creating) {
this.state.completeSession()
}
this.setStatus(Status.Idle)
}
}

View file

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

View file

@ -0,0 +1,98 @@
import type {
TLBoundsEventHandler,
TLBoundsHandleEventHandler,
TLCanvasEventHandler,
TLKeyboardEventHandler,
TLPinchEventHandler,
TLPointerEventHandler,
TLWheelEventHandler,
} from '~../../core/src/types'
import type { TLDrawState } from '~state'
import type { TLDrawShapeType } from '~types'
export abstract class BaseTool {
abstract type: TLDrawShapeType | 'select'
state: TLDrawState
constructor(state: TLDrawState) {
this.state = state
}
getNextChildIndex = () => {
const {
shapes,
appState: { currentPageId },
} = this.state
return shapes.length === 0
? 1
: shapes
.filter((shape) => shape.parentId === currentPageId)
.sort((a, b) => b.childIndex - a.childIndex)[0].childIndex + 1
}
onCancel?: () => void
// Keyboard events
onKeyDown?: TLKeyboardEventHandler
onKeyUp?: TLKeyboardEventHandler
// Camera Events
onPinchStart?: TLPinchEventHandler
onPinchEnd?: TLPinchEventHandler
onPinch?: TLPinchEventHandler
onPan?: TLWheelEventHandler
onZoom?: TLWheelEventHandler
// Pointer Events
onPointerMove?: TLPointerEventHandler
onPointerUp?: TLPointerEventHandler
onPointerDown?: TLPointerEventHandler
// Canvas (background)
onPointCanvas?: TLCanvasEventHandler
onDoubleClickCanvas?: TLCanvasEventHandler
onRightPointCanvas?: TLCanvasEventHandler
onDragCanvas?: TLCanvasEventHandler
onReleaseCanvas?: TLCanvasEventHandler
// Shape
onPointShape?: TLPointerEventHandler
onDoubleClickShape?: TLPointerEventHandler
onRightPointShape?: TLPointerEventHandler
onDragShape?: TLPointerEventHandler
onHoverShape?: TLPointerEventHandler
onUnhoverShape?: TLPointerEventHandler
onReleaseShape?: TLPointerEventHandler
// Bounds (bounding box background)
onPointBounds?: TLBoundsEventHandler
onDoubleClickBounds?: TLBoundsEventHandler
onRightPointBounds?: TLBoundsEventHandler
onDragBounds?: TLBoundsEventHandler
onHoverBounds?: TLBoundsEventHandler
onUnhoverBounds?: TLBoundsEventHandler
onReleaseBounds?: TLBoundsEventHandler
// Bounds handles (corners, edges)
onPointBoundsHandle?: TLBoundsHandleEventHandler
onDoubleClickBoundsHandle?: TLBoundsHandleEventHandler
onRightPointBoundsHandle?: TLBoundsHandleEventHandler
onDragBoundsHandle?: TLBoundsHandleEventHandler
onHoverBoundsHandle?: TLBoundsHandleEventHandler
onUnhoverBoundsHandle?: TLBoundsHandleEventHandler
onReleaseBoundsHandle?: TLBoundsHandleEventHandler
// Handles (ie the handles of a selected arrow)
onPointHandle?: TLPointerEventHandler
onDoubleClickHandle?: TLPointerEventHandler
onRightPointHandle?: TLPointerEventHandler
onDragHandle?: TLPointerEventHandler
onHoverHandle?: TLPointerEventHandler
onUnhoverHandle?: TLPointerEventHandler
onReleaseHandle?: TLPointerEventHandler
// Misc
onShapeBlur?: () => void
}

View file

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

View file

@ -0,0 +1,9 @@
import { TLDrawState } from '~state'
import { DrawTool } from '.'
describe('DrawTool', () => {
it('creates tool', () => {
const tlstate = new TLDrawState()
new DrawTool(tlstate)
})
})

View file

@ -0,0 +1,72 @@
import Vec from '@tldraw/vec'
import type { TLPointerEventHandler } from '~../../core/src/types'
import Utils from '~../../core/src/utils'
import { Draw } from '~shape/shapes'
import { SessionType, TLDrawShapeType } from '~types'
import { BaseTool } from '../BaseTool'
enum Status {
Idle = 'idle',
Creating = 'creating',
}
export class DrawTool extends BaseTool {
type = TLDrawShapeType.Draw
status = Status.Idle
/* --------------------- Methods -------------------- */
private setStatus(status: Status) {
this.status = status
}
/* ----------------- Event Handlers ----------------- */
onPointerDown: TLPointerEventHandler = (info) => {
const pagePoint = Vec.round(this.state.getPagePoint(info.point))
const {
shapes,
appState: { currentPageId, currentStyle },
} = this.state
const childIndex =
shapes.length === 0
? 1
: shapes
.filter((shape) => shape.parentId === currentPageId)
.sort((a, b) => b.childIndex - a.childIndex)[0].childIndex + 1
const id = Utils.uniqueId()
const newShape = Draw.create({
id,
parentId: currentPageId,
childIndex,
point: pagePoint,
style: { ...currentStyle },
})
this.state.createShapes(newShape)
this.state.startSession(SessionType.Draw, pagePoint, id)
this.setStatus(Status.Creating)
}
onPointerMove: TLPointerEventHandler = (info) => {
if (this.status === Status.Creating) {
const pagePoint = Vec.round(this.state.getPagePoint(info.point))
this.state.updateSession(pagePoint, info.shiftKey, info.altKey, info.metaKey)
}
}
onPointerUp: TLPointerEventHandler = () => {
if (this.status === Status.Creating) {
this.state.completeSession()
}
this.setStatus(Status.Idle)
}
}

View file

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

View file

@ -0,0 +1,9 @@
import { TLDrawState } from '~state'
import { EllipseTool } from '.'
describe('EllipseTool', () => {
it('creates tool', () => {
const tlstate = new TLDrawState()
new EllipseTool(tlstate)
})
})

View file

@ -0,0 +1,64 @@
import Vec from '@tldraw/vec'
import { Utils, TLPointerEventHandler, TLBoundsCorner } from '@tldraw/core'
import { Ellipse } from '~shape/shapes'
import { SessionType, TLDrawShapeType } from '~types'
import { BaseTool } from '../BaseTool'
enum Status {
Idle = 'idle',
Creating = 'creating',
}
export class EllipseTool extends BaseTool {
type = TLDrawShapeType.Ellipse
status = Status.Idle
/* --------------------- Methods -------------------- */
private setStatus(status: Status) {
this.status = status
}
/* ----------------- Event Handlers ----------------- */
onPointerDown: TLPointerEventHandler = (info) => {
const pagePoint = Vec.round(this.state.getPagePoint(info.point))
const {
appState: { currentPageId, currentStyle },
} = this.state
const childIndex = this.getNextChildIndex()
const id = Utils.uniqueId()
const newShape = Ellipse.create({
id,
parentId: currentPageId,
childIndex,
point: pagePoint,
style: { ...currentStyle },
})
this.state.createShapes(newShape)
this.state.startSession(SessionType.Transform, pagePoint, TLBoundsCorner.BottomRight)
this.setStatus(Status.Creating)
}
onPointerMove: TLPointerEventHandler = (info) => {
if (this.status === Status.Creating) {
const pagePoint = Vec.round(this.state.getPagePoint(info.point))
this.state.updateSession(pagePoint, info.shiftKey, info.altKey, info.metaKey)
}
}
onPointerUp: TLPointerEventHandler = () => {
if (this.status === Status.Creating) {
this.state.completeSession()
}
this.setStatus(Status.Idle)
}
}

View file

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

View file

@ -0,0 +1,9 @@
import { TLDrawState } from '~state'
import { RectangleTool } from '.'
describe('RectangleTool', () => {
it('creates tool', () => {
const tlstate = new TLDrawState()
new RectangleTool(tlstate)
})
})

View file

@ -0,0 +1,65 @@
import Vec from '@tldraw/vec'
import { Utils, TLPointerEventHandler, TLBoundsCorner } from '@tldraw/core'
import { Rectangle } from '~shape/shapes'
import { SessionType, TLDrawShapeType } from '~types'
import { BaseTool } from '../BaseTool'
enum Status {
Idle = 'idle',
Creating = 'creating',
}
export class RectangleTool extends BaseTool {
type = TLDrawShapeType.Rectangle
status = Status.Idle
/* --------------------- Methods -------------------- */
private setStatus(status: Status) {
this.status = status
}
/* ----------------- Event Handlers ----------------- */
onPointerDown: TLPointerEventHandler = (info) => {
const pagePoint = Vec.round(this.state.getPagePoint(info.point))
const {
appState: { currentPageId, currentStyle },
} = this.state
const childIndex = this.getNextChildIndex()
const id = Utils.uniqueId()
const newShape = Rectangle.create({
id,
parentId: currentPageId,
childIndex,
point: pagePoint,
style: { ...currentStyle },
})
this.state.createShapes(newShape)
this.state.startSession(SessionType.Transform, pagePoint, TLBoundsCorner.BottomRight)
this.setStatus(Status.Creating)
}
onPointerMove: TLPointerEventHandler = (info) => {
if (this.status === Status.Creating) {
const pagePoint = Vec.round(this.state.getPagePoint(info.point))
this.state.updateSession(pagePoint, info.shiftKey, info.altKey, info.metaKey)
}
}
onPointerUp: TLPointerEventHandler = () => {
if (this.status === Status.Creating) {
this.state.completeSession()
}
this.setStatus(Status.Idle)
}
}

View file

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

View file

@ -0,0 +1,9 @@
import { TLDrawState } from '~state'
import { SelectTool } from '.'
describe('SelectTool', () => {
it('creates tool', () => {
const tlstate = new TLDrawState()
new SelectTool(tlstate)
})
})

View file

@ -0,0 +1,442 @@
import type {
TLBoundsCorner,
TLBoundsEdge,
TLBoundsEventHandler,
TLBoundsHandleEventHandler,
TLCanvasEventHandler,
TLPointerEventHandler,
TLKeyboardEventHandler,
} from '@tldraw/core'
import { SessionType, TLDrawShapeType } from '~types'
import { BaseTool } from '../BaseTool'
import Vec from '@tldraw/vec'
import { TLDR } from '~state/tldr'
enum Status {
Idle = 'idle',
PointingCanvas = 'pointingCanvas',
PointingHandle = 'pointingHandle',
PointingBounds = 'pointingBounds',
PointingBoundsHandle = 'pointingBoundsHandle',
TranslatingHandle = 'translatingHandle',
Translating = 'translating',
Transforming = 'transforming',
Rotating = 'rotating',
Pinching = 'pinching',
Brushing = 'brushing',
Creating = 'creating',
EditingText = 'editing-text',
}
export class SelectTool extends BaseTool {
type = 'select' as const
status: Status = Status.Idle
pointedId?: string
selectedGroupId?: string
pointedHandleId?: 'start' | 'end'
pointedBoundsHandle?: TLBoundsCorner | TLBoundsEdge | 'rotate'
/* --------------------- Methods -------------------- */
private setStatus(status: Status) {
this.status = status
}
private deselect(id: string) {
this.state.select(...this.state.selectedIds.filter((oid) => oid !== id))
}
private select(id: string) {
this.state.select(id)
}
private pushSelect(id: string) {
const shape = this.state.getShape(id)
this.state.select(...this.state.selectedIds.filter((oid) => oid !== shape.parentId), id)
}
private deselectAll() {
this.state.deselectAll()
}
/* ----------------- Event Handlers ----------------- */
onCancel = () => {
this.deselectAll()
// TODO: Make all cancel sessions have no arguments
this.state.cancelSession()
this.setStatus(Status.Idle)
}
onKeyDown: TLKeyboardEventHandler = (key, info) => {
if (key === 'Escape') {
this.onCancel()
return
}
if (key === 'Meta' || key === 'Control') {
// TODO: Make all sessions have all of these arguments
this.state.updateSession(
this.state.getPagePoint(info.point),
info.shiftKey,
info.altKey,
info.metaKey
)
return
}
}
onKeyUp: TLKeyboardEventHandler = () => {
/* noop */
}
// Pointer Events (generic)
onPointerMove: TLPointerEventHandler = (info) => {
const point = this.state.getPagePoint(info.origin)
if (this.status === Status.PointingBoundsHandle) {
if (!this.pointedBoundsHandle) throw Error('No pointed bounds handle')
if (Vec.dist(info.origin, info.point) > 4) {
if (this.pointedBoundsHandle === 'rotate') {
// Stat a rotate session
this.setStatus(Status.Rotating)
this.state.startSession(SessionType.Rotate, point)
} else {
// Stat a transform session
this.setStatus(Status.Transforming)
const idsToTransform = this.state.selectedIds.flatMap((id) =>
TLDR.getDocumentBranch(this.state.state, id, this.state.currentPageId)
)
if (idsToTransform.length === 1) {
// if only one shape is selected, transform single
this.state.startSession(SessionType.TransformSingle, point, this.pointedBoundsHandle)
} else {
// otherwise, transform
this.state.startSession(SessionType.Transform, point, this.pointedBoundsHandle)
}
}
}
return
}
if (this.status === Status.PointingCanvas) {
if (Vec.dist(info.origin, info.point) > 4) {
const point = this.state.getPagePoint(info.point)
this.state.startSession(SessionType.Brush, point)
this.setStatus(Status.Brushing)
}
return
}
if (this.status === Status.PointingBounds) {
if (Vec.dist(info.origin, info.point) > 4) {
this.setStatus(Status.Translating)
const point = this.state.getPagePoint(info.origin)
this.state.startSession(SessionType.Translate, point)
}
return
}
if (this.status === Status.PointingHandle) {
if (!this.pointedHandleId) throw Error('No pointed handle')
if (Vec.dist(info.origin, info.point) > 4) {
this.setStatus(Status.TranslatingHandle)
const selectedShape = this.state.getShape(this.state.selectedIds[0])
if (!selectedShape) return
const point = this.state.getPagePoint(info.origin)
if (selectedShape.type === TLDrawShapeType.Arrow) {
this.state.startSession(SessionType.Arrow, point, this.pointedHandleId)
} else {
this.state.startSession(SessionType.Handle, point, this.pointedHandleId)
}
}
return
}
if (this.state.session) {
return this.state.updateSession(
this.state.getPagePoint(info.point),
info.shiftKey,
info.altKey,
info.metaKey
)
}
return
}
onPointerDown: TLPointerEventHandler = () => {
if (this.status === Status.Idle) {
return
}
if (this.status === Status.EditingText) {
this.state.completeSession()
return
}
}
onPointerUp: TLPointerEventHandler = (info) => {
if (this.status === Status.PointingBounds) {
if (info.target === 'bounds') {
// If we just clicked the selecting bounds's background,
// clear the selection
this.deselectAll()
} else if (this.state.isSelected(info.target)) {
// If we're holding shift...
if (info.shiftKey) {
// unless we just shift-selected the shape, remove it from
// the selected shapes
if (this.pointedId !== info.target) {
this.deselect(info.target)
}
} else {
// If we have other selected shapes, select this one instead
if (this.pointedId !== info.target && this.state.selectedIds.length > 1) {
this.select(info.target)
}
}
} else if (this.pointedId === info.target) {
// If the target is not selected and was just pointed
// on pointer down...
if (info.shiftKey) {
this.pushSelect(info.target)
} else {
this.select(info.target)
}
}
}
// Complete the current session, if any; and reset the status
this.state.completeSession()
this.setStatus(Status.Idle)
this.pointedBoundsHandle = undefined
this.pointedHandleId = undefined
this.pointedId = undefined
}
// Canvas
onPointCanvas: TLCanvasEventHandler = (info) => {
// Unless the user is holding shift or meta, clear the current selection
if (!info.shiftKey) {
this.deselectAll()
}
this.setStatus(Status.PointingCanvas)
}
onDoubleClickCanvas: TLCanvasEventHandler = (info) => {
const pagePoint = this.state.getPagePoint(info.point)
this.state.selectTool(TLDrawShapeType.Text)
const tool = this.state.tools[TLDrawShapeType.Text]
this.setStatus(Status.Idle)
tool.createTextShapeAtPoint(pagePoint)
}
// Shape
onPointShape: TLPointerEventHandler = (info) => {
const { hoveredId } = this.state.pageState
// While holding command and shift, select or deselect
// the shape, ignoring any group that may contain it. Yikes!
if (
(this.status === Status.Idle || this.status === Status.PointingBounds) &&
info.metaKey &&
info.shiftKey &&
hoveredId
) {
this.pointedId = hoveredId
if (this.state.isSelected(hoveredId)) {
this.deselect(hoveredId)
} else {
this.pushSelect(hoveredId)
this.setStatus(Status.PointingBounds)
}
return
}
if (this.status === Status.PointingBounds) {
// The pointed id should be the shape's group, if it belongs
// to a group, or else the shape itself, if it is on the page.
const { parentId } = this.state.getShape(info.target)
this.pointedId = parentId === this.state.currentPageId ? info.target : parentId
return
}
if (this.status === Status.Idle) {
this.setStatus(Status.PointingBounds)
if (info.metaKey) {
if (!info.shiftKey) {
this.deselectAll()
}
const point = this.state.getPagePoint(info.point)
this.state.startSession(SessionType.Brush, point)
this.setStatus(Status.Brushing)
return
}
// If we've clicked on a shape that is inside of a group,
// then select the group rather than the shape.
let shapeIdToSelect: string
const { parentId } = this.state.getShape(info.target)
// If the pointed shape is a child of the page, select the
// target shape and clear the selected group id.
if (parentId === this.state.currentPageId) {
shapeIdToSelect = info.target
this.selectedGroupId = undefined
} else {
// If the parent is some other group...
if (parentId === this.selectedGroupId) {
// If that group is the selected group, then select
// the target shape.
shapeIdToSelect = info.target
} else {
// Otherwise, select the group and clear the selected
// group id.
shapeIdToSelect = parentId
this.selectedGroupId = undefined
}
}
if (!this.state.isSelected(shapeIdToSelect)) {
// Set the pointed ID to the shape that was clicked.
this.pointedId = shapeIdToSelect
// If the shape is not selected: then if the user is pressing shift,
// add the shape to the current selection; otherwise, set the shape as
// the only selected shape.
if (info.shiftKey) {
this.pushSelect(shapeIdToSelect)
} else {
this.select(shapeIdToSelect)
}
}
}
}
onDoubleClickShape: TLPointerEventHandler = (info) => {
// if (this.status !== Status.Idle) return
const shape = this.state.getShape(info.target)
const utils = TLDR.getShapeUtils(shape.type)
if (utils.canEdit) {
this.state.setEditingId(info.target)
// this.state.startTextSession(info.target)
}
// If the shape is the child of a group, then drill
// into the group?
if (shape.parentId !== this.state.currentPageId) {
this.selectedGroupId = shape.parentId
}
this.state.select(info.target)
}
onRightPointShape: TLPointerEventHandler = (info) => {
if (!this.state.isSelected(info.target)) {
this.state.select(info.target)
}
}
onHoverShape: TLPointerEventHandler = (info) => {
this.state.setHoveredId(info.target)
}
onUnhoverShape: TLPointerEventHandler = (info) => {
const { currentPageId: oldCurrentPageId } = this.state
// Wait a frame; and if we haven't changed the hovered id,
// clear the current hovered id
requestAnimationFrame(() => {
if (
oldCurrentPageId === this.state.currentPageId &&
this.state.pageState.hoveredId === info.target
) {
this.state.setHoveredId(undefined)
}
})
}
/* --------------------- Bounds --------------------- */
onPointBounds: TLBoundsEventHandler = (info) => {
if (info.metaKey) {
if (!info.shiftKey) {
this.deselectAll()
}
const point = this.state.getPagePoint(info.point)
this.state.startSession(SessionType.Brush, point)
this.setStatus(Status.Brushing)
return
}
this.setStatus(Status.PointingBounds)
}
onReleaseBounds: TLBoundsEventHandler = () => {
if (this.status === Status.Translating || this.status === Status.Brushing) {
this.state.completeSession()
}
this.setStatus(Status.Idle)
}
/* ----------------- Bounds Handles ----------------- */
onPointBoundsHandle: TLBoundsHandleEventHandler = (info) => {
this.pointedBoundsHandle = info.target
this.setStatus(Status.PointingBoundsHandle)
}
onDoubleClickBoundsHandle: TLBoundsHandleEventHandler = () => {
if (this.state.selectedIds.length === 1) {
this.state.resetBounds(this.state.selectedIds)
}
}
onReleaseBoundsHandle: TLBoundsHandleEventHandler = () => {
this.setStatus(Status.Idle)
}
/* --------------------- Handles -------------------- */
onPointHandle: TLPointerEventHandler = (info) => {
this.pointedHandleId = info.target as 'start' | 'end'
this.setStatus(Status.PointingHandle)
}
onDoubleClickHandle: TLPointerEventHandler = (info) => {
this.state.toggleDecoration(info.target)
}
onReleaseHandle: TLPointerEventHandler = () => {
this.setStatus(Status.Idle)
}
}

View file

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

View file

@ -0,0 +1,9 @@
import { TLDrawState } from '~state'
import { StickyTool } from '.'
describe('StickyTool', () => {
it('creates tool', () => {
const tlstate = new TLDrawState()
new StickyTool(tlstate)
})
})

View file

@ -0,0 +1,6 @@
import { TLDrawShapeType } from '~types'
import { BaseTool } from '../BaseTool'
export class StickyTool extends BaseTool {
type = TLDrawShapeType.PostIt
}

View file

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

View file

@ -0,0 +1,9 @@
import { TLDrawState } from '~state'
import { TextTool } from '.'
describe('TextTool', () => {
it('creates tool', () => {
const tlstate = new TLDrawState()
new TextTool(tlstate)
})
})

View file

@ -0,0 +1,86 @@
import Vec from '@tldraw/vec'
import { Utils, TLPointerEventHandler } from '@tldraw/core'
import { Text } from '~shape/shapes'
import { TLDrawShapeType } from '~types'
import { BaseTool } from '../BaseTool'
enum Status {
Idle = 'idle',
Creating = 'creating',
}
export class TextTool extends BaseTool {
type = TLDrawShapeType.Text
status = Status.Idle
/* --------------------- Methods -------------------- */
private setStatus(status: Status) {
this.status = status
}
stopEditingShape = () => {
this.setStatus(Status.Idle)
if (!this.state.appState.isToolLocked) {
this.state.selectTool('select')
}
}
createTextShapeAtPoint = (point: number[]) => {
const {
shapes,
appState: { currentPageId, currentStyle },
} = this.state
const childIndex =
shapes.length === 0
? 1
: shapes
.filter((shape) => shape.parentId === currentPageId)
.sort((a, b) => b.childIndex - a.childIndex)[0].childIndex + 1
const id = Utils.uniqueId()
const newShape = Text.create({
id,
parentId: currentPageId,
childIndex,
point,
style: { ...currentStyle },
})
const bounds = Text.getBounds(newShape)
newShape.point = Vec.sub(newShape.point, [bounds.width / 2, bounds.height / 2])
this.state.createShapes(newShape)
this.state.setEditingId(id)
this.setStatus(Status.Creating)
}
/* ----------------- Event Handlers ----------------- */
onPointerDown: TLPointerEventHandler = (info) => {
if (this.status === Status.Idle) {
const pagePoint = Vec.round(this.state.getPagePoint(info.point))
this.createTextShapeAtPoint(pagePoint)
return
}
if (this.status === Status.Creating) {
this.stopEditingShape()
}
}
onPointShape: TLPointerEventHandler = (info) => {
const shape = this.state.getShape(info.target)
if (shape.type === TLDrawShapeType.Text) {
this.setStatus(Status.Idle)
this.state.setEditingId(shape.id)
}
}
}

View file

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

View file

@ -0,0 +1,58 @@
import type { TLDrawState } from '~state'
import { TLDrawShapeType } from '~types'
import { ArrowTool } from './ArrowTool'
import { DrawTool } from './DrawTool'
import { EllipseTool } from './EllipseTool'
import { RectangleTool } from './RectangleTool'
import { SelectTool } from './SelectTool'
import { StickyTool } from './StickyTool'
import { TextTool } from './TextTool'
export type ToolType =
| 'select'
| TLDrawShapeType.Text
| TLDrawShapeType.Draw
| TLDrawShapeType.Ellipse
| TLDrawShapeType.Rectangle
| TLDrawShapeType.Arrow
| TLDrawShapeType.PostIt
export interface ToolsMap {
select: typeof SelectTool
[TLDrawShapeType.Text]: typeof TextTool
[TLDrawShapeType.Draw]: typeof DrawTool
[TLDrawShapeType.Ellipse]: typeof EllipseTool
[TLDrawShapeType.Rectangle]: typeof RectangleTool
[TLDrawShapeType.Arrow]: typeof ArrowTool
[TLDrawShapeType.PostIt]: typeof StickyTool
}
export type ToolOfType<K extends ToolType> = ToolsMap[K]
export type ArgsOfType<K extends ToolType> = ConstructorParameters<ToolOfType<K>>
export const tools: { [K in ToolType]: ToolsMap[K] } = {
select: SelectTool,
[TLDrawShapeType.Text]: TextTool,
[TLDrawShapeType.Draw]: DrawTool,
[TLDrawShapeType.Ellipse]: EllipseTool,
[TLDrawShapeType.Rectangle]: RectangleTool,
[TLDrawShapeType.Arrow]: ArrowTool,
[TLDrawShapeType.PostIt]: StickyTool,
}
export const getTool = <K extends ToolType>(type: K): ToolOfType<K> => {
return tools[type]
}
export function createTools(state: TLDrawState) {
return {
select: new SelectTool(state),
[TLDrawShapeType.Text]: new TextTool(state),
[TLDrawShapeType.Draw]: new DrawTool(state),
[TLDrawShapeType.Ellipse]: new EllipseTool(state),
[TLDrawShapeType.Rectangle]: new RectangleTool(state),
[TLDrawShapeType.Arrow]: new ArrowTool(state),
[TLDrawShapeType.PostIt]: new StickyTool(state),
}
}

View file

@ -17,6 +17,11 @@ export class TLStateUtils {
this.tlstate = tlstate
}
movePointer = (options: PointerOptions = {}) => {
const { tlstate } = this
tlstate.onPointerMove(inputs.pointerMove(this.getPoint(options), ''), {} as React.PointerEvent)
}
hoverShape = (id: string, options: PointerOptions = {}) => {
const { tlstate } = this
tlstate.onHoverShape(inputs.pointerDown(this.getPoint(options), id), {} as React.PointerEvent)
@ -27,6 +32,10 @@ export class TLStateUtils {
inputs.pointerDown(this.getPoint(options), 'canvas'),
{} as React.PointerEvent
)
this.tlstate.onPointerDown(
inputs.pointerDown(this.getPoint(options), 'canvas'),
{} as React.PointerEvent
)
return this
}
@ -35,6 +44,10 @@ export class TLStateUtils {
inputs.pointerDown(this.getPoint(options), id),
{} as React.PointerEvent
)
this.tlstate.onPointerDown(
inputs.pointerDown(this.getPoint(options), 'canvas'),
{} as React.PointerEvent
)
return this
}
@ -43,6 +56,10 @@ export class TLStateUtils {
inputs.pointerDown(this.getPoint(options), id),
{} as React.PointerEvent
)
this.tlstate.onPointerDown(
inputs.pointerDown(this.getPoint(options), 'canvas'),
{} as React.PointerEvent
)
return this
}
@ -51,6 +68,10 @@ export class TLStateUtils {
inputs.pointerDown(this.getPoint(options), 'bounds'),
{} as React.PointerEvent
)
this.tlstate.onPointerDown(
inputs.pointerDown(this.getPoint(options), 'canvas'),
{} as React.PointerEvent
)
return this
}
@ -62,6 +83,10 @@ export class TLStateUtils {
inputs.pointerDown(this.getPoint(options), 'bounds'),
{} as React.PointerEvent
)
this.tlstate.onPointerDown(
inputs.pointerDown(this.getPoint(options), 'canvas'),
{} as React.PointerEvent
)
return this
}

View file

@ -59,7 +59,6 @@ export interface Data {
pages: Pick<TLPage<TLDrawShape, TLDrawBinding>, 'id' | 'name' | 'childIndex'>[]
hoveredId?: string
activeTool: TLDrawShapeType | 'select'
activeToolType?: TLDrawToolType | 'select'
isToolLocked: boolean
isStyleOpen: boolean
isEmptyCanvas: boolean
@ -87,13 +86,30 @@ export interface SelectHistory {
stack: string[][]
}
export interface Session {
id: string
status: TLDrawStatus
start: (data: Readonly<Data>, ...args: any[]) => TLDrawPatch | undefined
update: (data: Readonly<Data>, ...args: any[]) => TLDrawPatch | undefined
complete: (data: Readonly<Data>, ...args: any[]) => TLDrawPatch | TLDrawCommand | undefined
cancel: (data: Readonly<Data>, ...args: any[]) => TLDrawPatch | undefined
export enum SessionType {
Transform = 'transform',
Translate = 'translate',
TransformSingle = 'transformSingle',
Brush = 'brush',
Arrow = 'arrow',
Draw = 'draw',
Rotate = 'rotate',
Handle = 'handle',
}
export abstract class Session {
static type: SessionType
abstract status: TLDrawStatus
abstract start: (data: Readonly<Data>) => TLDrawPatch | undefined
abstract update: (
data: Readonly<Data>,
point: number[],
shiftKey: boolean,
altKey: boolean,
metaKey: boolean
) => TLDrawPatch | undefined
abstract complete: (data: Readonly<Data>) => TLDrawPatch | TLDrawCommand | undefined
abstract cancel: (data: Readonly<Data>) => TLDrawPatch | undefined
}
export enum TLDrawStatus {
@ -114,6 +130,8 @@ export enum TLDrawStatus {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ParametersExceptFirst<F> = F extends (arg0: any, ...rest: infer R) => any ? R : never
export type ExceptFirst<T extends unknown[]> = T extends [any, ...infer U] ? U : never
export enum MoveType {
Backward = 'backward',
Forward = 'forward',
@ -145,15 +163,6 @@ export enum FlipType {
Vertical = 'vertical',
}
export enum TLDrawToolType {
Draw = 'draw',
Bounds = 'bounds',
Point = 'point',
Handle = 'handle',
Points = 'points',
Text = 'text',
}
export enum TLDrawShapeType {
PostIt = 'post-it',
Ellipse = 'ellipse',
@ -229,14 +238,7 @@ export type TLDrawShape =
| GroupShape
| PostItShape
export type TLDrawShapeUtil<T extends TLDrawShape> = TLShapeUtil<
T,
any,
TLDrawMeta,
{
toolType: TLDrawToolType
}
>
export type TLDrawShapeUtil<T extends TLDrawShape> = TLShapeUtil<T, any, TLDrawMeta>
export type ArrowBinding = TLBinding<{
handleId: keyof ArrowShape['handles']