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} meta={meta}
events={events} events={events}
onShapeChange={callbacks.onShapeChange} onShapeChange={callbacks.onShapeChange}
onShapeBlur={callbacks.onShapeBlur}
/> />
</Container> </Container>
) )

View file

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

View file

@ -1,264 +1,357 @@
import type React from 'react'; import type React from 'react'
import type { ForwardedRef } from 'react'; import { MutableRefObject } from 'react-router/node_modules/@types/react'
export declare type Patch<T> = Partial<{ 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> { export interface TLPage<T extends TLShape, B extends TLBinding> {
id: string; id: string
name?: string; name?: string
childIndex?: number; childIndex?: number
shapes: Record<string, T>; shapes: Record<string, T>
bindings: Record<string, B>; bindings: Record<string, B>
} }
export interface TLPageState { export interface TLPageState {
id: string; id: string
selectedIds: string[]; selectedIds: string[]
camera: { camera: {
point: number[]; point: number[]
zoom: number; zoom: number
}; }
brush?: TLBounds; brush?: TLBounds
pointedId?: string | null; pointedId?: string | null
hoveredId?: string | null; hoveredId?: string | null
editingId?: string | null; editingId?: string | null
bindingId?: string | null; bindingId?: string | null
boundsRotation?: number; boundsRotation?: number
currentParentId?: string | null; currentParentId?: string | null
} }
export interface TLHandle { export interface TLHandle {
id: string; id: string
index: number; index: number
point: number[]; point: number[]
canBind?: boolean; canBind?: boolean
bindingId?: string; bindingId?: string
} }
export interface TLShape { export interface TLShape {
id: string; id: string
type: string; type: string
parentId: string; parentId: string
childIndex: number; childIndex: number
name: string; name: string
point: number[]; point: number[]
rotation?: number; rotation?: number
children?: string[]; children?: string[]
handles?: Record<string, TLHandle>; handles?: Record<string, TLHandle>
isLocked?: boolean; isLocked?: boolean
isHidden?: boolean; isHidden?: boolean
isEditing?: boolean; isEditing?: boolean
isGenerated?: boolean; isGenerated?: boolean
isAspectRatioLocked?: 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> { export interface TLRenderInfo<T extends TLShape, E = any, M = any> {
shape: T; shape: T
isEditing: boolean; isEditing: boolean
isBinding: boolean; isBinding: boolean
isHovered: boolean; isHovered: boolean
isSelected: boolean; isSelected: boolean
isCurrentParent: boolean; isCurrentParent: boolean
meta: M extends any ? M : never; meta: M extends any ? M : never
onShapeChange?: TLCallbacks<T>['onShapeChange']; onShapeChange?: TLCallbacks<T>['onShapeChange']
onShapeBlur?: TLCallbacks<T>['onShapeBlur']; onShapeBlur?: TLCallbacks<T>['onShapeBlur']
events: { events: {
onPointerDown: (e: React.PointerEvent<E>) => void; onPointerDown: (e: React.PointerEvent<E>) => void
onPointerUp: (e: React.PointerEvent<E>) => void; onPointerUp: (e: React.PointerEvent<E>) => void
onPointerEnter: (e: React.PointerEvent<E>) => void; onPointerEnter: (e: React.PointerEvent<E>) => void
onPointerMove: (e: React.PointerEvent<E>) => void; onPointerMove: (e: React.PointerEvent<E>) => void
onPointerLeave: (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> { export interface TLShapeProps<T extends TLShape, E = any, M = any> extends TLRenderInfo<T, E, M> {
ref: ForwardedRef<E>; ref: MutableRefObject<E>
shape: T; shape: T
} }
export interface TLTool { export interface TLTool {
id: string; id: string
name: string; name: string
} }
export interface TLBinding<M = any> { export interface TLBinding<M = any> {
id: string; id: string
type: string; type: string
toId: string; toId: string
fromId: string; fromId: string
meta: M; meta: M
} }
export interface TLTheme { export interface TLTheme {
brushFill?: string; brushFill?: string
brushStroke?: string; brushStroke?: string
selectFill?: string; selectFill?: string
selectStroke?: string; selectStroke?: string
background?: string; background?: string
foreground?: string; foreground?: string
} }
export declare type TLWheelEventHandler = (info: TLPointerInfo<string>, e: React.WheelEvent<Element> | WheelEvent) => void; export declare type TLWheelEventHandler = (
export declare type TLPinchEventHandler = (info: TLPointerInfo<string>, e: React.WheelEvent<Element> | WheelEvent | React.TouchEvent<Element> | TouchEvent | React.PointerEvent<Element> | PointerEventInit) => void; info: TLPointerInfo<string>,
export declare type TLPointerEventHandler = (info: TLPointerInfo<string>, e: React.PointerEvent) => void; e: React.WheelEvent<Element> | WheelEvent
export declare type TLCanvasEventHandler = (info: TLPointerInfo<'canvas'>, e: React.PointerEvent) => void; ) => void
export declare type TLBoundsEventHandler = (info: TLPointerInfo<'bounds'>, e: React.PointerEvent) => void; export declare type TLPinchEventHandler = (
export declare type TLBoundsHandleEventHandler = (info: TLPointerInfo<TLBoundsCorner | TLBoundsEdge | 'rotate'>, e: React.PointerEvent) => void; 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> { export interface TLCallbacks<T extends TLShape> {
onPinchStart: TLPinchEventHandler; onPinchStart: TLPinchEventHandler
onPinchEnd: TLPinchEventHandler; onPinchEnd: TLPinchEventHandler
onPinch: TLPinchEventHandler; onPinch: TLPinchEventHandler
onPan: TLWheelEventHandler; onPan: TLWheelEventHandler
onZoom: TLWheelEventHandler; onZoom: TLWheelEventHandler
onPointerMove: TLPointerEventHandler; onPointerMove: TLPointerEventHandler
onPointerUp: TLPointerEventHandler; onPointerUp: TLPointerEventHandler
onPointerDown: TLPointerEventHandler; onPointerDown: TLPointerEventHandler
onPointCanvas: TLCanvasEventHandler; onPointCanvas: TLCanvasEventHandler
onDoubleClickCanvas: TLCanvasEventHandler; onDoubleClickCanvas: TLCanvasEventHandler
onRightPointCanvas: TLCanvasEventHandler; onRightPointCanvas: TLCanvasEventHandler
onDragCanvas: TLCanvasEventHandler; onDragCanvas: TLCanvasEventHandler
onReleaseCanvas: TLCanvasEventHandler; onReleaseCanvas: TLCanvasEventHandler
onPointShape: TLPointerEventHandler; onPointShape: TLPointerEventHandler
onDoubleClickShape: TLPointerEventHandler; onDoubleClickShape: TLPointerEventHandler
onRightPointShape: TLPointerEventHandler; onRightPointShape: TLPointerEventHandler
onDragShape: TLPointerEventHandler; onDragShape: TLPointerEventHandler
onHoverShape: TLPointerEventHandler; onHoverShape: TLPointerEventHandler
onUnhoverShape: TLPointerEventHandler; onUnhoverShape: TLPointerEventHandler
onReleaseShape: TLPointerEventHandler; onReleaseShape: TLPointerEventHandler
onPointBounds: TLBoundsEventHandler; onPointBounds: TLBoundsEventHandler
onDoubleClickBounds: TLBoundsEventHandler; onDoubleClickBounds: TLBoundsEventHandler
onRightPointBounds: TLBoundsEventHandler; onRightPointBounds: TLBoundsEventHandler
onDragBounds: TLBoundsEventHandler; onDragBounds: TLBoundsEventHandler
onHoverBounds: TLBoundsEventHandler; onHoverBounds: TLBoundsEventHandler
onUnhoverBounds: TLBoundsEventHandler; onUnhoverBounds: TLBoundsEventHandler
onReleaseBounds: TLBoundsEventHandler; onReleaseBounds: TLBoundsEventHandler
onPointBoundsHandle: TLBoundsHandleEventHandler; onPointBoundsHandle: TLBoundsHandleEventHandler
onDoubleClickBoundsHandle: TLBoundsHandleEventHandler; onDoubleClickBoundsHandle: TLBoundsHandleEventHandler
onRightPointBoundsHandle: TLBoundsHandleEventHandler; onRightPointBoundsHandle: TLBoundsHandleEventHandler
onDragBoundsHandle: TLBoundsHandleEventHandler; onDragBoundsHandle: TLBoundsHandleEventHandler
onHoverBoundsHandle: TLBoundsHandleEventHandler; onHoverBoundsHandle: TLBoundsHandleEventHandler
onUnhoverBoundsHandle: TLBoundsHandleEventHandler; onUnhoverBoundsHandle: TLBoundsHandleEventHandler
onReleaseBoundsHandle: TLBoundsHandleEventHandler; onReleaseBoundsHandle: TLBoundsHandleEventHandler
onPointHandle: TLPointerEventHandler; onPointHandle: TLPointerEventHandler
onDoubleClickHandle: TLPointerEventHandler; onDoubleClickHandle: TLPointerEventHandler
onRightPointHandle: TLPointerEventHandler; onRightPointHandle: TLPointerEventHandler
onDragHandle: TLPointerEventHandler; onDragHandle: TLPointerEventHandler
onHoverHandle: TLPointerEventHandler; onHoverHandle: TLPointerEventHandler
onUnhoverHandle: TLPointerEventHandler; onUnhoverHandle: TLPointerEventHandler
onReleaseHandle: TLPointerEventHandler; onReleaseHandle: TLPointerEventHandler
onRenderCountChange: (ids: string[]) => void; onRenderCountChange: (ids: string[]) => void
onShapeChange: (shape: { onShapeChange: (
id: string; shape: {
} & Partial<T>) => void; id: string
onShapeBlur: () => void; } & Partial<T>
onError: (error: Error) => void; ) => void
onShapeBlur: () => void
onError: (error: Error) => void
} }
export interface TLBounds { export interface TLBounds {
minX: number; minX: number
minY: number; minY: number
maxX: number; maxX: number
maxY: number; maxY: number
width: number; width: number
height: number; height: number
rotation?: number; rotation?: number
} }
export declare type TLIntersection = { export declare type TLIntersection = {
didIntersect: boolean; didIntersect: boolean
message: string; message: string
points: number[][]; points: number[][]
}; }
export declare enum TLBoundsEdge { export declare enum TLBoundsEdge {
Top = "top_edge", Top = 'top_edge',
Right = "right_edge", Right = 'right_edge',
Bottom = "bottom_edge", Bottom = 'bottom_edge',
Left = "left_edge" Left = 'left_edge',
} }
export declare enum TLBoundsCorner { export declare enum TLBoundsCorner {
TopLeft = "top_left_corner", TopLeft = 'top_left_corner',
TopRight = "top_right_corner", TopRight = 'top_right_corner',
BottomRight = "bottom_right_corner", BottomRight = 'bottom_right_corner',
BottomLeft = "bottom_left_corner" BottomLeft = 'bottom_left_corner',
} }
export interface TLPointerInfo<T extends string = string> { export interface TLPointerInfo<T extends string = string> {
target: T; target: T
pointerId: number; pointerId: number
origin: number[]; origin: number[]
point: number[]; point: number[]
delta: number[]; delta: number[]
pressure: number; pressure: number
shiftKey: boolean; shiftKey: boolean
ctrlKey: boolean; ctrlKey: boolean
metaKey: boolean; metaKey: boolean
altKey: boolean; altKey: boolean
} }
export interface TLKeyboardInfo { export interface TLKeyboardInfo {
origin: number[]; origin: number[]
point: number[]; point: number[]
key: string; key: string
keys: string[]; keys: string[]
shiftKey: boolean; shiftKey: boolean
ctrlKey: boolean; ctrlKey: boolean
metaKey: boolean; metaKey: boolean
altKey: boolean; altKey: boolean
} }
export interface TLTransformInfo<T extends TLShape> { export interface TLTransformInfo<T extends TLShape> {
type: TLBoundsEdge | TLBoundsCorner; type: TLBoundsEdge | TLBoundsCorner
initialShape: T; initialShape: T
scaleX: number; scaleX: number
scaleY: number; scaleY: number
transformOrigin: number[]; transformOrigin: number[]
} }
export interface TLBezierCurveSegment { export interface TLBezierCurveSegment {
start: number[]; start: number[]
tangentStart: number[]; tangentStart: number[]
normalStart: number[]; normalStart: number[]
pressureStart: number; pressureStart: number
end: number[]; end: number[]
tangentEnd: number[]; tangentEnd: number[]
normalEnd: number[]; normalEnd: number[]
pressureEnd: 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> { export interface IShapeTreeNode<T extends TLShape, M = any> {
shape: T; shape: T
children?: IShapeTreeNode<TLShape, M>[]; children?: IShapeTreeNode<TLShape, M>[]
isEditing: boolean; isEditing: boolean
isBinding: boolean; isBinding: boolean
isHovered: boolean; isHovered: boolean
isSelected: boolean; isSelected: boolean
isCurrentParent: boolean; isCurrentParent: boolean
meta?: M extends any ? M : never; 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 // Hide bounds when not using the select tool, or when the only selected shape has handles
const hideBounds = 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 // Hide bounds when not using the select tool, or when in session
const hideHandles = isInSession || !isSelecting const hideHandles = isInSession || !isSelecting

View file

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

View file

@ -14,5 +14,6 @@ export const tldrawShapeUtils: Record<TLDrawShapeType, any> = {
} }
export function getShapeUtils<T extends TLDrawShape>(type: T['type']) { 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> return tldrawShapeUtils[type] as TLDrawShapeUtil<T>
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,52 +1,61 @@
import { TLDrawState } from '~state' import { TLDrawState } from '~state'
import { mockDocument } from '~test' import { mockDocument } from '~test'
import { TLDrawStatus } from '~types' import { SessionType, TLDrawStatus } from '~types'
describe('Brush session', () => { describe('Brush session', () => {
it('begins, updateSession', () => {
const tlstate = new TLDrawState() const tlstate = new TLDrawState()
tlstate.loadDocument(mockDocument) .loadDocument(mockDocument)
.deselectAll()
it('begins, updates and completes session', () => { .startSession(SessionType.Brush, [-10, -10])
tlstate.deselectAll() .updateSession([10, 10])
tlstate.startBrushSession([-10, -10]) .completeSession()
tlstate.updateBrushSession([10, 10])
tlstate.completeSession()
expect(tlstate.appState.status.current).toBe(TLDrawStatus.Idle) expect(tlstate.appState.status.current).toBe(TLDrawStatus.Idle)
expect(tlstate.selectedIds.length).toBe(1) expect(tlstate.selectedIds.length).toBe(1)
}) })
it('selects multiple shapes', () => { it('selects multiple shapes', () => {
tlstate.deselectAll() const tlstate = new TLDrawState()
tlstate.startBrushSession([-10, -10]) .loadDocument(mockDocument)
tlstate.updateBrushSession([110, 110]) .deselectAll()
tlstate.completeSession() .startSession(SessionType.Brush, [-10, -10])
.updateSession([110, 110])
.completeSession()
expect(tlstate.selectedIds.length).toBe(3) expect(tlstate.selectedIds.length).toBe(3)
}) })
it('does not de-select original shapes', () => { it('does not de-select original shapes', () => {
tlstate.deselectAll() const tlstate = new TLDrawState()
tlstate .loadDocument(mockDocument)
.deselectAll()
.select('rect1') .select('rect1')
.startBrushSession([300, 300]) .startSession(SessionType.Brush, [300, 300])
.updateBrushSession([301, 301]) .updateSession([301, 301])
.completeSession() .completeSession()
expect(tlstate.selectedIds.length).toBe(1) expect(tlstate.selectedIds.length).toBe(1)
}) })
it('does not select hidden shapes', () => { // it('does not select hidden shapes', () => {
tlstate.toggleHidden(['rect1']) // const tlstate = new TLDrawState()
tlstate.deselectAll() // .loadDocument(mockDocument)
tlstate.startBrushSession([-10, -10]) // .deselectAll()
tlstate.updateBrushSession([10, 10]) // .toggleHidden(['rect1'])
tlstate.completeSession() // .deselectAll()
expect(tlstate.selectedIds.length).toBe(0) // .startSession(SessionType.Brush, [-10, -10])
}) // .updateSession([10, 10])
// .completeSession()
// })
it('when command is held, require the entire shape to be selected', () => { it('when command is held, require the entire shape to be selected', () => {
tlstate.loadDocument(mockDocument) const tlstate = new TLDrawState()
tlstate.deselectAll() .loadDocument(mockDocument)
tlstate.startBrushSession([-10, -10]) .deselectAll()
tlstate.updateBrushSession([10, 10]) .loadDocument(mockDocument)
tlstate.completeSession() .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 { Utils } from '@tldraw/core'
import { Vec } from '@tldraw/vec' 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' import { TLDR } from '~state/tldr'
export class BrushSession implements Session { export class BrushSession implements Session {
id = 'brush' static type = SessionType.Brush
status = TLDrawStatus.Brushing status = TLDrawStatus.Brushing
origin: number[] origin: number[]
snapshot: BrushSnapshot snapshot: BrushSnapshot
@ -16,7 +16,13 @@ export class BrushSession implements Session {
start = () => void null 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 { snapshot, origin } = this
const { currentPageId } = data.appState const { currentPageId } = data.appState
@ -36,7 +42,7 @@ export class BrushSession implements Session {
if (!hits.has(selectId)) { if (!hits.has(selectId)) {
if ( if (
containMode metaKey
? Utils.boundsContain(brush, util.getBounds(shape)) ? Utils.boundsContain(brush, util.getBounds(shape))
: util.hitTestBounds(shape, brush) : util.hitTestBounds(shape, brush)
) { ) {

View file

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

View file

@ -1,10 +1,10 @@
import { Utils } from '@tldraw/core' import { Utils } from '@tldraw/core'
import { Vec } from '@tldraw/vec' 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' import { TLDR } from '~state/tldr'
export class DrawSession implements Session { export class DrawSession implements Session {
id = 'draw' static type = SessionType.Draw
status = TLDrawStatus.Creating status = TLDrawStatus.Creating
topLeft: number[] topLeft: number[]
origin: number[] origin: number[]
@ -12,17 +12,16 @@ export class DrawSession implements Session {
last: number[] last: number[]
points: number[][] points: number[][]
shiftedPoints: number[][] = [] shiftedPoints: number[][] = []
snapshot: DrawSnapshot shapeId: string
isLocked?: boolean isLocked?: boolean
lockedDirection?: 'horizontal' | 'vertical' lockedDirection?: 'horizontal' | 'vertical'
constructor(data: Data, id: string, point: number[]) { constructor(data: Data, point: number[], id: string) {
this.origin = point this.origin = point
this.previous = point this.previous = point
this.last = point this.last = point
this.topLeft = point this.topLeft = point
this.shapeId = id
this.snapshot = getDrawSnapshot(data, id)
// Add a first point but don't update the shape yet. We'll update // 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 // when the draw session ends; if the user hasn't added additional
@ -32,8 +31,8 @@ export class DrawSession implements Session {
start = () => void null start = () => void null
update = (data: Data, point: number[], pressure: number, isLocked = false) => { update = (data: Data, point: number[], shiftKey: boolean) => {
const { snapshot } = this const { shapeId } = this
// Even if we're not locked yet, we base the future locking direction // Even if we're not locked yet, we base the future locking direction
// on the first dimension to reach a threshold, or the bigger dimension // 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 // Drawing while holding shift will "lock" the pen to either the
// x or y axis, depending on the locking direction. // x or y axis, depending on the locking direction.
if (isLocked) { if (shiftKey) {
if (!this.isLocked && this.points.length > 2) { if (!this.isLocked && this.points.length > 2) {
// If we're locking before knowing what direction we're in, set it // If we're locking before knowing what direction we're in, set it
// early based on the bigger dimension. // early based on the bigger dimension.
@ -67,7 +66,7 @@ export class DrawSession implements Session {
} }
this.previous = returning this.previous = returning
this.points.push(returning.concat(pressure)) this.points.push(returning.concat(point[2]))
} }
} else if (this.isLocked) { } else if (this.isLocked) {
this.isLocked = false this.isLocked = false
@ -82,7 +81,7 @@ export class DrawSession implements Session {
} }
// The new adjusted point // 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. // Don't add duplicate points.
if (Vec.isEqual(this.last, newPoint)) return if (Vec.isEqual(this.last, newPoint)) return
@ -129,7 +128,7 @@ export class DrawSession implements Session {
pages: { pages: {
[data.appState.currentPageId]: { [data.appState.currentPageId]: {
shapes: { shapes: {
[snapshot.id]: { [shapeId]: {
point: this.topLeft, point: this.topLeft,
points, points,
}, },
@ -138,7 +137,7 @@ export class DrawSession implements Session {
}, },
pageStates: { pageStates: {
[data.appState.currentPageId]: { [data.appState.currentPageId]: {
selectedIds: [snapshot.id], selectedIds: [shapeId],
}, },
}, },
}, },
@ -146,7 +145,7 @@ export class DrawSession implements Session {
} }
cancel = (data: Data) => { cancel = (data: Data) => {
const { snapshot } = this const { shapeId } = this
const pageId = data.appState.currentPageId const pageId = data.appState.currentPageId
return { return {
@ -154,7 +153,7 @@ export class DrawSession implements Session {
pages: { pages: {
[pageId]: { [pageId]: {
shapes: { shapes: {
[snapshot.id]: undefined, [shapeId]: undefined,
}, },
}, },
}, },
@ -168,7 +167,7 @@ export class DrawSession implements Session {
} }
complete = (data: Data) => { complete = (data: Data) => {
const { snapshot } = this const { shapeId } = this
const pageId = data.appState.currentPageId const pageId = data.appState.currentPageId
return { return {
@ -178,7 +177,7 @@ export class DrawSession implements Session {
pages: { pages: {
[pageId]: { [pageId]: {
shapes: { shapes: {
[snapshot.id]: undefined, [shapeId]: undefined,
}, },
}, },
}, },
@ -194,7 +193,7 @@ export class DrawSession implements Session {
pages: { pages: {
[pageId]: { [pageId]: {
shapes: { 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 { TLDrawState } from '~state'
import { mockDocument } from '~test' import { mockDocument } from '~test'
import { TLDR } from '~state/tldr' import { SessionType, TLDrawShapeType, TLDrawStatus } from '~types'
import { TLDrawShape, TLDrawShapeType, TLDrawStatus } from '~types'
describe('Handle session', () => { describe('Handle session', () => {
const tlstate = new TLDrawState() const tlstate = new TLDrawState()
it('begins, updates and completes session', () => { it('begins, updateSession', () => {
tlstate tlstate
.loadDocument(mockDocument) .loadDocument(mockDocument)
.createShapes({ .createShapes({
@ -14,8 +13,8 @@ describe('Handle session', () => {
type: TLDrawShapeType.Arrow, type: TLDrawShapeType.Arrow,
}) })
.select('arrow1') .select('arrow1')
.startHandleSession([-10, -10], 'end') .startSession(SessionType.Arrow, [-10, -10], 'end')
.updateHandleSession([10, 10]) .updateSession([10, 10])
.completeSession() .completeSession()
expect(tlstate.appState.status.current).toBe(TLDrawStatus.Idle) expect(tlstate.appState.status.current).toBe(TLDrawStatus.Idle)
@ -31,8 +30,8 @@ describe('Handle session', () => {
id: 'arrow1', id: 'arrow1',
}) })
.select('arrow1') .select('arrow1')
.startHandleSession([-10, -10], 'end') .startSession(SessionType.Arrow, [-10, -10], 'end')
.updateHandleSession([10, 10]) .updateSession([10, 10])
.cancelSession() .cancelSession()
expect(tlstate.getShape('rect1').point).toStrictEqual([0, 0]) expect(tlstate.getShape('rect1').point).toStrictEqual([0, 0])

View file

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

View file

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

View file

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

View file

@ -1,13 +1,13 @@
import { Utils } from '@tldraw/core' import { Utils } from '@tldraw/core'
import { Vec } from '@tldraw/vec' import { Vec } from '@tldraw/vec'
import { Session, TLDrawShape, TLDrawStatus } from '~types' import { Session, SessionType, TLDrawShape, TLDrawStatus } from '~types'
import type { Data } from '~types' import type { Data } from '~types'
import { TLDR } from '~state/tldr' import { TLDR } from '~state/tldr'
const centerCache = new WeakMap<string[], number[]>() const centerCache = new WeakMap<string[], number[]>()
export class RotateSession implements Session { export class RotateSession implements Session {
id = 'rotate' static type = SessionType.Rotate
status = TLDrawStatus.Transforming status = TLDrawStatus.Transforming
delta = [0, 0] delta = [0, 0]
origin: number[] 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 { TLDrawState } from '~state'
import { mockDocument } from '~test' import { mockDocument } from '~test'
import { TLBoundsCorner } from '@tldraw/core' import { TLBoundsCorner } from '@tldraw/core'
import { TLDrawStatus } from '~types' import { SessionType, TLDrawStatus } from '~types'
describe('Transform single session', () => { describe('Transform single session', () => {
const tlstate = new TLDrawState() const tlstate = new TLDrawState()
it('begins, updates and completes session', () => { it('begins, updateSession', () => {
tlstate tlstate
.loadDocument(mockDocument) .loadDocument(mockDocument)
.select('rect1') .select('rect1')
.startTransformSession([-10, -10], TLBoundsCorner.TopLeft) .startSession(SessionType.TransformSingle, [-10, -10], TLBoundsCorner.TopLeft)
.updateTransformSession([10, 10]) .updateSession([10, 10])
.completeSession() .completeSession()
expect(tlstate.appState.status.current).toBe(TLDrawStatus.Idle) expect(tlstate.appState.status.current).toBe(TLDrawStatus.Idle)
@ -23,8 +23,8 @@ describe('Transform single session', () => {
tlstate tlstate
.loadDocument(mockDocument) .loadDocument(mockDocument)
.select('rect1') .select('rect1')
.startTransformSession([5, 5], TLBoundsCorner.TopLeft) .startSession(SessionType.TransformSingle, [5, 5], TLBoundsCorner.TopLeft)
.updateTransformSession([10, 10]) .updateSession([10, 10])
.cancelSession() .cancelSession()
expect(tlstate.getShape('rect1').point).toStrictEqual([0, 0]) expect(tlstate.getShape('rect1').point).toStrictEqual([0, 0])

View file

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

View file

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

View file

@ -1,11 +1,11 @@
import { TLBoundsCorner, TLBoundsEdge, Utils } from '@tldraw/core' import { TLBoundsCorner, TLBoundsEdge, Utils } from '@tldraw/core'
import { Vec } from '@tldraw/vec' import { Vec } from '@tldraw/vec'
import { Session, TLDrawShape, TLDrawStatus } from '~types' import { Session, SessionType, TLDrawShape, TLDrawStatus } from '~types'
import type { Data } from '~types' import type { Data } from '~types'
import { TLDR } from '~state/tldr' import { TLDR } from '~state/tldr'
export class TransformSession implements Session { export class TransformSession implements Session {
id = 'transform' static type = SessionType.Transform
status = TLDrawStatus.Transforming status = TLDrawStatus.Transforming
scaleX = 1 scaleX = 1
scaleY = 1 scaleY = 1
@ -26,7 +26,7 @@ export class TransformSession implements Session {
start = () => void null start = () => void null
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
update = (data: Data, point: number[], isAspectRatioLocked = false, _altKey = false) => { update = (data: Data, point: number[], shiftKey: boolean, altKey: boolean, metaKey: boolean) => {
const { const {
transformType, transformType,
snapshot: { shapeBounds, initialBounds, isAllAspectRatioLocked }, snapshot: { shapeBounds, initialBounds, isAllAspectRatioLocked },
@ -41,7 +41,7 @@ export class TransformSession implements Session {
transformType, transformType,
Vec.sub(point, this.origin), Vec.sub(point, this.origin),
pageState.boundsRotation, pageState.boundsRotation,
isAspectRatioLocked || isAllAspectRatioLocked shiftKey || isAllAspectRatioLocked
) )
// Now work backward to calculate a new bounding box for each of the shapes. // 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 { TLDrawState } from '~state'
import { mockDocument } from '~test' import { mockDocument } from '~test'
import { GroupShape, TLDrawShapeType, TLDrawStatus } from '~types' import { GroupShape, SessionType, TLDrawShapeType, TLDrawStatus } from '~types'
describe('Translate session', () => { describe('Translate session', () => {
const tlstate = new TLDrawState() const tlstate = new TLDrawState()
it('begins, updates and completes session', () => { it('begins, updateSession', () => {
tlstate tlstate
.loadDocument(mockDocument) .loadDocument(mockDocument)
.select('rect1') .select('rect1')
.startTranslateSession([5, 5]) .startSession(SessionType.Translate, [5, 5])
.updateTranslateSession([10, 10]) .updateSession([10, 10])
expect(tlstate.getShape('rect1').point).toStrictEqual([5, 5]) expect(tlstate.getShape('rect1').point).toStrictEqual([5, 5])
@ -33,8 +33,8 @@ describe('Translate session', () => {
tlstate tlstate
.loadDocument(mockDocument) .loadDocument(mockDocument)
.select('rect1', 'rect2') .select('rect1', 'rect2')
.startTranslateSession([5, 5]) .startSession(SessionType.Translate, [5, 5])
.updateTranslateSession([10, 10]) .updateSession([10, 10])
.cancelSession() .cancelSession()
expect(tlstate.getShape('rect1').point).toStrictEqual([0, 0]) expect(tlstate.getShape('rect1').point).toStrictEqual([0, 0])
@ -44,8 +44,8 @@ describe('Translate session', () => {
tlstate tlstate
.loadDocument(mockDocument) .loadDocument(mockDocument)
.select('rect1') .select('rect1')
.startTranslateSession([10, 10]) .startSession(SessionType.Translate, [10, 10])
.updateTranslateSession([20, 20]) .updateSession([20, 20])
.completeSession() .completeSession()
expect(tlstate.getShape('rect1').point).toStrictEqual([10, 10]) expect(tlstate.getShape('rect1').point).toStrictEqual([10, 10])
@ -55,8 +55,8 @@ describe('Translate session', () => {
tlstate tlstate
.loadDocument(mockDocument) .loadDocument(mockDocument)
.select('rect1') .select('rect1')
.startTranslateSession([10, 10]) .startSession(SessionType.Translate, [10, 10])
.updateTranslateSession([20, 20], true) .updateSession([20, 20], true)
.completeSession() .completeSession()
expect(tlstate.getShape('rect1').point).toStrictEqual([10, 0]) expect(tlstate.getShape('rect1').point).toStrictEqual([10, 0])
@ -66,8 +66,8 @@ describe('Translate session', () => {
tlstate tlstate
.loadDocument(mockDocument) .loadDocument(mockDocument)
.select('rect1', 'rect2') .select('rect1', 'rect2')
.startTranslateSession([10, 10]) .startSession(SessionType.Translate, [10, 10])
.updateTranslateSession([20, 20]) .updateSession([20, 20])
.completeSession() .completeSession()
expect(tlstate.getShape('rect1').point).toStrictEqual([10, 10]) expect(tlstate.getShape('rect1').point).toStrictEqual([10, 10])
@ -78,8 +78,8 @@ describe('Translate session', () => {
tlstate tlstate
.loadDocument(mockDocument) .loadDocument(mockDocument)
.select('rect1', 'rect2') .select('rect1', 'rect2')
.startTranslateSession([10, 10]) .startSession(SessionType.Translate, [10, 10])
.updateTranslateSession([20, 20], false, true) .updateSession([20, 20], false, true)
.completeSession() .completeSession()
expect(tlstate.getShape('rect1').point).toStrictEqual([0, 0]) expect(tlstate.getShape('rect1').point).toStrictEqual([0, 0])
@ -100,8 +100,8 @@ describe('Translate session', () => {
tlstate tlstate
.loadDocument(mockDocument) .loadDocument(mockDocument)
.select('rect1', 'rect2') .select('rect1', 'rect2')
.startTranslateSession([10, 10]) .startSession(SessionType.Translate, [10, 10])
.updateTranslateSession([20, 20], false, true) .updateSession([20, 20], false, true)
.completeSession() .completeSession()
expect(tlstate.getShape('rect1').point).toStrictEqual([0, 0]) expect(tlstate.getShape('rect1').point).toStrictEqual([0, 0])
@ -117,12 +117,12 @@ describe('Translate session', () => {
tlstate tlstate
.select('rect1', 'rect2') .select('rect1', 'rect2')
.startTranslateSession([10, 10]) .startSession(SessionType.Translate, [10, 10])
.updateTranslateSession([20, 20], false, true) .updateSession([20, 20], false, true)
expect(Object.keys(tlstate.getPage().shapes).length).toBe(5) 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) expect(Object.keys(tlstate.getPage().shapes).length).toBe(3)
@ -156,16 +156,16 @@ describe('Translate session', () => {
} }
) )
.select('arrow1') .select('arrow1')
.startHandleSession([200, 200], 'start') .startSession(SessionType.Arrow, [200, 200], 'start')
.updateHandleSession([50, 50]) .updateSession([50, 50])
.completeSession() .completeSession()
expect(tlstate.bindings.length).toBe(1) expect(tlstate.bindings.length).toBe(1)
tlstate tlstate
.select('arrow1') .select('arrow1')
.startTranslateSession([10, 10]) .startSession(SessionType.Translate, [10, 10])
.updateTranslateSession([30, 30]) .updateSession([30, 30])
.completeSession() .completeSession()
// expect(tlstate.bindings.length).toBe(0) // expect(tlstate.bindings.length).toBe(0)
@ -191,8 +191,8 @@ describe('Translate session', () => {
.select('rect1', 'rect2') .select('rect1', 'rect2')
.group(['rect1', 'rect2'], 'groupA') .group(['rect1', 'rect2'], 'groupA')
.select('rect1') .select('rect1')
.startTranslateSession([10, 10]) .startSession(SessionType.Translate, [10, 10])
.updateTranslateSession([20, 20], false, false) .updateSession([20, 20], false, false)
.completeSession() .completeSession()
expect(tlstate.getShape('groupA').point).toStrictEqual([10, 10]) expect(tlstate.getShape('groupA').point).toStrictEqual([10, 10])
@ -218,8 +218,8 @@ describe('Translate session', () => {
.select('rect1', 'rect2') .select('rect1', 'rect2')
.group(['rect1', 'rect2'], 'groupA') .group(['rect1', 'rect2'], 'groupA')
.select('rect1') .select('rect1')
.startTranslateSession([10, 10]) .startSession(SessionType.Translate, [10, 10])
.updateTranslateSession([20, 20], false, true) .updateSession([20, 20], false, true)
.completeSession() .completeSession()
const children = tlstate.getShape<GroupShape>('groupA').children const children = tlstate.getShape<GroupShape>('groupA').children
@ -257,8 +257,8 @@ describe('Translate session', () => {
.loadDocument(mockDocument) .loadDocument(mockDocument)
.select('rect1', 'rect2') .select('rect1', 'rect2')
.group(['rect1', 'rect2'], 'groupA') .group(['rect1', 'rect2'], 'groupA')
.startTranslateSession([10, 10]) .startSession(SessionType.Translate, [10, 10])
.updateTranslateSession([20, 20], false, false) .updateSession([20, 20], false, false)
.completeSession() .completeSession()
expect(tlstate.getShape('groupA').point).toStrictEqual([10, 10]) expect(tlstate.getShape('groupA').point).toStrictEqual([10, 10])
@ -283,8 +283,8 @@ describe('Translate session', () => {
.loadDocument(mockDocument) .loadDocument(mockDocument)
.select('rect1', 'rect2') .select('rect1', 'rect2')
.group() .group()
.startTranslateSession([10, 10]) .startSession(SessionType.Translate, [10, 10])
.updateTranslateSession([20, 20], false, true) .updateSession([20, 20], false, true)
.completeSession() .completeSession()
}) })
@ -293,10 +293,10 @@ describe('Translate session', () => {
.loadDocument(mockDocument) .loadDocument(mockDocument)
.select('rect1', 'rect2') .select('rect1', 'rect2')
.group() .group()
.startTranslateSession([10, 10]) .startSession(SessionType.Translate, [10, 10])
.updateTranslateSession([20, 20], false, true) .updateSession([20, 20], false, true)
.updateTranslateSession([20, 20], false, false) .updateSession([20, 20], false, false)
.updateTranslateSession([20, 20], false, true) .updateSession([20, 20], false, true)
.completeSession() .completeSession()
}) })
@ -306,8 +306,8 @@ describe('Translate session', () => {
.select('rect1', 'rect2') .select('rect1', 'rect2')
.group(['rect1', 'rect2'], 'groupA') .group(['rect1', 'rect2'], 'groupA')
.select('groupA', 'rect3') .select('groupA', 'rect3')
.startTranslateSession([10, 10]) .startSession(SessionType.Translate, [10, 10])
.updateTranslateSession([20, 20], false, true) .updateSession([20, 20], false, true)
.completeSession() .completeSession()
}) })
}) })

View file

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

View file

@ -1,6 +1,10 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { TLDrawState } from './tlstate' import { TLDrawState } from './tlstate'
import { mockDocument, TLStateUtils } from '~test' 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', () => { describe('TLDrawState', () => {
const tlstate = new TLDrawState() const tlstate = new TLDrawState()
@ -61,8 +65,8 @@ describe('TLDrawState', () => {
{ type: TLDrawShapeType.Arrow, id: 'arrow1', point: [200, 200] } { type: TLDrawShapeType.Arrow, id: 'arrow1', point: [200, 200] }
) )
.select('arrow1') .select('arrow1')
.startHandleSession([200, 200], 'start') .startSession(SessionType.Arrow, [200, 200], 'start')
.updateHandleSession([55, 55]) .updateSession([55, 55])
.completeSession() .completeSession()
expect(tlstate.bindings.length).toBe(1) expect(tlstate.bindings.length).toBe(1)
@ -87,8 +91,8 @@ describe('TLDrawState', () => {
{ type: TLDrawShapeType.Arrow, id: 'arrow1', point: [200, 200] } { type: TLDrawShapeType.Arrow, id: 'arrow1', point: [200, 200] }
) )
.select('arrow1') .select('arrow1')
.startHandleSession([200, 200], 'start') .startSession(SessionType.Arrow, [200, 200], 'start')
.updateHandleSession([55, 55]) .updateSession([55, 55])
.completeSession() .completeSession()
expect(tlstate.bindings.length).toBe(1) expect(tlstate.bindings.length).toBe(1)
@ -138,8 +142,8 @@ describe('TLDrawState', () => {
it('clears selection when clicking bounds', () => { it('clears selection when clicking bounds', () => {
tlstate.loadDocument(mockDocument).deselectAll() tlstate.loadDocument(mockDocument).deselectAll()
tlstate.startBrushSession([-10, -10]) tlstate.startSession(SessionType.Brush, [-10, -10])
tlstate.updateBrushSession([110, 110]) tlstate.updateSession([110, 110])
tlstate.completeSession() tlstate.completeSession()
expect(tlstate.selectedIds.length).toBe(3) expect(tlstate.selectedIds.length).toBe(3)
}) })
@ -301,8 +305,8 @@ describe('TLDrawState', () => {
} }
) )
.select('arrow') .select('arrow')
.startHandleSession([200, 200], 'start', 'arrow') .startSession(SessionType.Arrow, [200, 200], 'start')
.updateHandleSession([10, 10]) .updateSession([10, 10])
.completeSession() .completeSession()
.selectAll() .selectAll()
.style({ color: ColorStyle.Red }) .style({ color: ColorStyle.Red })
@ -340,7 +344,7 @@ describe('TLDrawState', () => {
const tlu = new TLStateUtils(tlstate) const tlu = new TLStateUtils(tlstate)
tlu.doubleClickShape('rect1') tlu.doubleClickShape('rect1')
expect(tlstate.selectedGroupId).toStrictEqual('groupA') expect((tlstate.currentTool as SelectTool).selectedGroupId).toStrictEqual('groupA')
expect(tlstate.selectedIds).toStrictEqual(['rect1']) expect(tlstate.selectedIds).toStrictEqual(['rect1'])
}) })
@ -407,17 +411,35 @@ describe('TLDrawState', () => {
} }
) )
.selectTool(TLDrawShapeType.Rectangle) .selectTool(TLDrawShapeType.Rectangle)
.createActiveToolShape([0, 0], 'rect4')
expect(tlstate.getShape('rect4').childIndex).toBe(4) const tlu = new TLStateUtils(tlstate)
tlstate const prevA = tlstate.shapes.map((shape) => shape.id)
.group(['rect2', 'rect3', 'rect4'], 'groupA')
.selectTool(TLDrawShapeType.Rectangle) tlu.pointCanvas({ x: 0, y: 0 })
.createActiveToolShape([0, 0], 'rect5') 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('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 this.tlstate = tlstate
} }
movePointer = (options: PointerOptions = {}) => {
const { tlstate } = this
tlstate.onPointerMove(inputs.pointerMove(this.getPoint(options), ''), {} as React.PointerEvent)
}
hoverShape = (id: string, options: PointerOptions = {}) => { hoverShape = (id: string, options: PointerOptions = {}) => {
const { tlstate } = this const { tlstate } = this
tlstate.onHoverShape(inputs.pointerDown(this.getPoint(options), id), {} as React.PointerEvent) tlstate.onHoverShape(inputs.pointerDown(this.getPoint(options), id), {} as React.PointerEvent)
@ -27,6 +32,10 @@ export class TLStateUtils {
inputs.pointerDown(this.getPoint(options), 'canvas'), inputs.pointerDown(this.getPoint(options), 'canvas'),
{} as React.PointerEvent {} as React.PointerEvent
) )
this.tlstate.onPointerDown(
inputs.pointerDown(this.getPoint(options), 'canvas'),
{} as React.PointerEvent
)
return this return this
} }
@ -35,6 +44,10 @@ export class TLStateUtils {
inputs.pointerDown(this.getPoint(options), id), inputs.pointerDown(this.getPoint(options), id),
{} as React.PointerEvent {} as React.PointerEvent
) )
this.tlstate.onPointerDown(
inputs.pointerDown(this.getPoint(options), 'canvas'),
{} as React.PointerEvent
)
return this return this
} }
@ -43,6 +56,10 @@ export class TLStateUtils {
inputs.pointerDown(this.getPoint(options), id), inputs.pointerDown(this.getPoint(options), id),
{} as React.PointerEvent {} as React.PointerEvent
) )
this.tlstate.onPointerDown(
inputs.pointerDown(this.getPoint(options), 'canvas'),
{} as React.PointerEvent
)
return this return this
} }
@ -51,6 +68,10 @@ export class TLStateUtils {
inputs.pointerDown(this.getPoint(options), 'bounds'), inputs.pointerDown(this.getPoint(options), 'bounds'),
{} as React.PointerEvent {} as React.PointerEvent
) )
this.tlstate.onPointerDown(
inputs.pointerDown(this.getPoint(options), 'canvas'),
{} as React.PointerEvent
)
return this return this
} }
@ -62,6 +83,10 @@ export class TLStateUtils {
inputs.pointerDown(this.getPoint(options), 'bounds'), inputs.pointerDown(this.getPoint(options), 'bounds'),
{} as React.PointerEvent {} as React.PointerEvent
) )
this.tlstate.onPointerDown(
inputs.pointerDown(this.getPoint(options), 'canvas'),
{} as React.PointerEvent
)
return this return this
} }

View file

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