[fix] bound shape size undo (#191)

* fix text centering, double click grouped text to select rather than edit

* Fix selecting grouped text

* Writes tests for select tool, fixes undo behavior while in session

* reduces binding distance, adds constants for binding distance and cloning distances

* adjust text sizes

* Update arrow.session.spec.ts
This commit is contained in:
Steve Ruiz 2021-10-22 12:05:23 +01:00 committed by GitHub
parent efbded7a06
commit ff50aa6ad5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 418 additions and 412 deletions

View file

@ -191,10 +191,6 @@ export const ShapeUtil = function <T extends TLShape, E extends Element, M = any
return
},
onStyleChange() {
return
},
onBindingChange() {
return
},

View file

@ -1,322 +0,0 @@
import type React from 'react';
export declare type Patch<T> = Partial<{
[P in keyof T]: T | Partial<T> | Patch<T[P]>;
}>;
declare type ForwardedRef<T> = ((instance: T | null) => void) | React.MutableRefObject<T | null> | null;
export interface TLPage<T extends TLShape, B extends TLBinding> {
id: string;
name?: string;
childIndex?: number;
shapes: Record<string, T>;
bindings: Record<string, B>;
}
export interface TLPageState {
id: string;
selectedIds: string[];
camera: {
point: number[];
zoom: number;
};
brush?: TLBounds | null;
pointedId?: string | null;
hoveredId?: string | null;
editingId?: string | null;
bindingId?: string | null;
boundsRotation?: number;
currentParentId?: string | null;
}
export interface TLUser<T extends TLShape> {
id: string;
color: string;
point: number[];
selectedIds: string[];
activeShapes: T[];
}
export declare type TLUsers<T extends TLShape, U extends TLUser<T> = TLUser<T>> = Record<string, U>;
export declare type TLSnapLine = number[][];
export interface TLHandle {
id: string;
index: number;
point: number[];
canBind?: boolean;
bindingId?: string;
}
export interface TLShape {
id: string;
type: string;
parentId: string;
childIndex: number;
name: string;
point: number[];
rotation?: number;
children?: string[];
handles?: Record<string, TLHandle>;
isLocked?: boolean;
isHidden?: boolean;
isEditing?: boolean;
isGenerated?: boolean;
isAspectRatioLocked?: boolean;
}
export declare type TLShapeUtils<T extends TLShape = any, E extends Element = any, M = any, K = any> = Record<string, TLShapeUtil<T, E, M, K>>;
export interface TLRenderInfo<T extends TLShape, E = any, M = any> {
shape: T;
isEditing: boolean;
isBinding: boolean;
isHovered: boolean;
isSelected: boolean;
isCurrentParent: boolean;
meta: M extends any ? M : never;
onShapeChange?: TLCallbacks<T>['onShapeChange'];
onShapeBlur?: TLCallbacks<T>['onShapeBlur'];
events: {
onPointerDown: (e: React.PointerEvent<E>) => void;
onPointerUp: (e: React.PointerEvent<E>) => void;
onPointerEnter: (e: React.PointerEvent<E>) => void;
onPointerMove: (e: React.PointerEvent<E>) => void;
onPointerLeave: (e: React.PointerEvent<E>) => void;
};
}
export interface TLShapeProps<T extends TLShape, E = any, M = any> extends TLRenderInfo<T, E, M> {
ref: ForwardedRef<E>;
shape: T;
}
export interface TLTool {
id: string;
name: string;
}
export interface TLBinding<M = any> {
id: string;
type: string;
toId: string;
fromId: string;
meta: M;
}
export interface TLTheme {
accent?: string;
brushFill?: string;
brushStroke?: string;
selectFill?: string;
selectStroke?: string;
background?: string;
foreground?: string;
}
export declare type TLWheelEventHandler = (info: TLPointerInfo<string>, e: React.WheelEvent<Element> | WheelEvent) => void;
export declare type TLPinchEventHandler = (info: TLPointerInfo<string>, e: React.WheelEvent<Element> | WheelEvent | React.TouchEvent<Element> | TouchEvent | React.PointerEvent<Element> | PointerEventInit) => void;
export declare type TLShapeChangeHandler<T, K = any> = (shape: {
id: string;
} & Partial<T>, info?: K) => void;
export declare type TLShapeBlurHandler<K = any> = (info?: K) => void;
export declare type TLKeyboardEventHandler = (key: string, info: TLKeyboardInfo, e: KeyboardEvent) => void;
export declare type TLPointerEventHandler = (info: TLPointerInfo<string>, e: React.PointerEvent) => void;
export declare type TLShapeCloneHandler = (info: TLPointerInfo<'top' | 'right' | 'bottom' | 'left' | 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'>, e: React.PointerEvent) => void;
export declare type TLShapeLinkHandler = (info: TLPointerInfo<'link'>, 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' | 'center' | 'left' | 'right'>, e: React.PointerEvent) => void;
export interface TLCallbacks<T extends TLShape> {
onPinchStart: TLPinchEventHandler;
onPinchEnd: TLPinchEventHandler;
onPinch: TLPinchEventHandler;
onPan: TLWheelEventHandler;
onZoom: TLWheelEventHandler;
onPointerMove: TLPointerEventHandler;
onPointerUp: TLPointerEventHandler;
onPointerDown: TLPointerEventHandler;
onPointCanvas: TLCanvasEventHandler;
onDoubleClickCanvas: TLCanvasEventHandler;
onRightPointCanvas: TLCanvasEventHandler;
onDragCanvas: TLCanvasEventHandler;
onReleaseCanvas: TLCanvasEventHandler;
onPointShape: TLPointerEventHandler;
onDoubleClickShape: TLPointerEventHandler;
onRightPointShape: TLPointerEventHandler;
onDragShape: TLPointerEventHandler;
onHoverShape: TLPointerEventHandler;
onUnhoverShape: TLPointerEventHandler;
onReleaseShape: TLPointerEventHandler;
onPointBounds: TLBoundsEventHandler;
onDoubleClickBounds: TLBoundsEventHandler;
onRightPointBounds: TLBoundsEventHandler;
onDragBounds: TLBoundsEventHandler;
onHoverBounds: TLBoundsEventHandler;
onUnhoverBounds: TLBoundsEventHandler;
onReleaseBounds: TLBoundsEventHandler;
onPointBoundsHandle: TLBoundsHandleEventHandler;
onDoubleClickBoundsHandle: TLBoundsHandleEventHandler;
onRightPointBoundsHandle: TLBoundsHandleEventHandler;
onDragBoundsHandle: TLBoundsHandleEventHandler;
onHoverBoundsHandle: TLBoundsHandleEventHandler;
onUnhoverBoundsHandle: TLBoundsHandleEventHandler;
onReleaseBoundsHandle: TLBoundsHandleEventHandler;
onPointHandle: TLPointerEventHandler;
onDoubleClickHandle: TLPointerEventHandler;
onRightPointHandle: TLPointerEventHandler;
onDragHandle: TLPointerEventHandler;
onHoverHandle: TLPointerEventHandler;
onUnhoverHandle: TLPointerEventHandler;
onReleaseHandle: TLPointerEventHandler;
onShapeChange: TLShapeChangeHandler<T, any>;
onShapeBlur: TLShapeBlurHandler<any>;
onShapeClone: TLShapeCloneHandler;
onRenderCountChange: (ids: string[]) => void;
onError: (error: Error) => void;
onBoundsChange: (bounds: TLBounds) => void;
onKeyDown: TLKeyboardEventHandler;
onKeyUp: TLKeyboardEventHandler;
}
export interface TLBounds {
minX: number;
minY: number;
maxX: number;
maxY: number;
width: number;
height: number;
rotation?: number;
}
export interface TLBoundsWithCenter extends TLBounds {
midX: number;
midY: number;
}
export declare type TLIntersection = {
didIntersect: boolean;
message: string;
points: number[][];
};
export declare enum TLBoundsEdge {
Top = "top_edge",
Right = "right_edge",
Bottom = "bottom_edge",
Left = "left_edge"
}
export declare enum TLBoundsCorner {
TopLeft = "top_left_corner",
TopRight = "top_right_corner",
BottomRight = "bottom_right_corner",
BottomLeft = "bottom_left_corner"
}
export interface TLPointerInfo<T extends string = string> {
target: T;
pointerId: number;
origin: number[];
point: number[];
delta: number[];
pressure: number;
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
altKey: boolean;
spaceKey: boolean;
}
export interface TLKeyboardInfo {
origin: number[];
point: number[];
key: string;
keys: string[];
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
altKey: boolean;
}
export interface TLTransformInfo<T extends TLShape> {
type: TLBoundsEdge | TLBoundsCorner;
initialShape: T;
scaleX: number;
scaleY: number;
transformOrigin: number[];
}
export interface TLBezierCurveSegment {
start: number[];
tangentStart: number[];
normalStart: number[];
pressureStart: number;
end: number[];
tangentEnd: number[];
normalEnd: number[];
pressureEnd: number;
}
export declare enum SnapPoints {
minX = "minX",
midX = "midX",
maxX = "maxX",
minY = "minY",
midY = "midY",
maxY = "maxY"
}
export declare type Snap = {
id: SnapPoints;
isSnapped: false;
} | {
id: SnapPoints;
isSnapped: true;
to: number;
B: TLBoundsWithCenter;
distance: 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: ForwardedRef<E>): React.ReactElement<TLRenderInfo<T, E, M>, E['tagName']>;
Indicator(this: TLShapeUtil<T, E, M>, props: {
shape: T;
meta: M;
isHovered: boolean;
isSelected: boolean;
}): 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;
canClone: boolean;
canBind: boolean;
isStateful: boolean;
showBounds: boolean;
getRotatedBounds(this: TLShapeUtil<T, E, M>, shape: T): TLBounds;
hitTest(this: TLShapeUtil<T, E, M>, shape: T, point: number[]): boolean;
hitTestBounds(this: TLShapeUtil<T, E, M>, shape: T, bounds: TLBounds): boolean;
shouldRender(this: TLShapeUtil<T, E, M>, prev: T, next: T): boolean;
getCenter(this: TLShapeUtil<T, E, M>, shape: T): number[];
getRef(this: TLShapeUtil<T, E, M>, shape: T): React.RefObject<E>;
getBindingPoint<K extends TLShape>(this: TLShapeUtil<T, E, M>, shape: T, fromShape: K, point: number[], origin: number[], direction: number[], padding: number, bindAnywhere: boolean): {
point: number[];
distance: number;
} | undefined;
create: (this: TLShapeUtil<T, E, M>, props: {
id: string;
} & Partial<T>) => T;
mutate: (this: TLShapeUtil<T, E, M>, shape: T, props: Partial<T>) => Partial<T>;
transform: (this: TLShapeUtil<T, E, M>, shape: T, bounds: TLBounds, info: TLTransformInfo<T>) => Partial<T> | void;
transformSingle: (this: TLShapeUtil<T, E, M>, shape: T, bounds: TLBounds, info: TLTransformInfo<T>) => Partial<T> | void;
updateChildren: <K extends TLShape>(this: TLShapeUtil<T, E, M>, shape: T, children: K[]) => Partial<K>[] | void;
onChildrenChange: (this: TLShapeUtil<T, E, M>, shape: T, children: TLShape[]) => Partial<T> | void;
onBindingChange: (this: TLShapeUtil<T, E, M>, shape: T, binding: TLBinding, target: TLShape, targetBounds: TLBounds, center: number[]) => Partial<T> | void;
onHandleChange: (this: TLShapeUtil<T, E, M>, shape: T, handle: Partial<T['handles']>, info: Partial<TLPointerInfo>) => Partial<T> | void;
onRightPointHandle: (this: TLShapeUtil<T, E, M>, shape: T, handle: Partial<T['handles']>, info: Partial<TLPointerInfo>) => Partial<T> | void;
onDoubleClickHandle: (this: TLShapeUtil<T, E, M>, shape: T, handle: Partial<T['handles']>, info: Partial<TLPointerInfo>) => Partial<T> | void;
onDoubleClickBoundsHandle: (this: TLShapeUtil<T, E, M>, shape: T) => Partial<T> | void;
onSessionComplete: (this: TLShapeUtil<T, E, M>, shape: T) => Partial<T> | void;
onStyleChange: (this: TLShapeUtil<T, E, M>, shape: T) => Partial<T> | void;
_Component: React.ForwardRefExoticComponent<any>;
};
export interface IShapeTreeNode<T extends TLShape, M = any> {
shape: T;
children?: IShapeTreeNode<TLShape, M>[];
isEditing: boolean;
isBinding: boolean;
isHovered: boolean;
isSelected: boolean;
isCurrentParent: boolean;
meta?: M extends any ? M : never;
}
export declare type MappedByType<K extends string, T extends {
type: K;
}> = {
[P in T['type']]: T extends any ? (P extends T['type'] ? T : never) : never;
};
export declare type RequiredKeys<T> = {
[K in keyof T]-?: Record<string, unknown> extends Pick<T, K> ? never : K;
}[keyof T];
export {};
//# sourceMappingURL=types.d.ts.map

File diff suppressed because one or more lines are too long

View file

@ -164,7 +164,7 @@ export type TLCanvasEventHandler = (info: TLPointerInfo<'canvas'>, e: React.Poin
export type TLBoundsEventHandler = (info: TLPointerInfo<'bounds'>, e: React.PointerEvent) => void
export type TLBoundsHandleEventHandler = (
info: TLPointerInfo<TLBoundsCorner | TLBoundsEdge | 'rotate' | 'center' | 'left' | 'right'>,
info: TLPointerInfo<TLBoundsHandle>,
e: React.PointerEvent
) => void
@ -272,6 +272,8 @@ export enum TLBoundsCorner {
BottomLeft = 'bottom_left_corner',
}
export type TLBoundsHandle = TLBoundsCorner | TLBoundsEdge | 'rotate' | 'center' | 'left' | 'right'
export interface TLPointerInfo<T extends string = string> {
target: T
pointerId: number
@ -461,8 +463,6 @@ export type TLShapeUtil<
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>
}

View file

@ -0,0 +1,2 @@
export const BINDING_DISTANCE = 24
export const CLONING_DISTANCE = 32

View file

@ -120,7 +120,13 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
useHotkeys(
'command+z,ctrl+z',
() => {
if (canHandleEvent()) tlstate.undo()
if (canHandleEvent()) {
if (tlstate.session) {
tlstate.cancelSession()
} else {
tlstate.undo()
}
}
},
undefined,
[tlstate]
@ -129,7 +135,13 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
useHotkeys(
'ctrl+shift-z,command+shift+z',
() => {
if (canHandleEvent()) tlstate.redo()
if (canHandleEvent()) {
if (tlstate.session) {
tlstate.cancelSession()
} else {
tlstate.redo()
}
}
},
undefined,
[tlstate]

View file

@ -71,9 +71,9 @@ const strokeWidths = {
}
const fontSizes = {
[SizeStyle.Small]: 32,
[SizeStyle.Medium]: 64,
[SizeStyle.Large]: 128,
[SizeStyle.Small]: 28,
[SizeStyle.Medium]: 48,
[SizeStyle.Large]: 96,
auto: 'auto',
}

View file

@ -21,6 +21,7 @@ import {
intersectRayEllipse,
} from '@tldraw/intersect'
import { EASINGS } from '~state/utils'
import { BINDING_DISTANCE } from '~constants'
export const Arrow = new ShapeUtil<ArrowShape, SVGSVGElement, TLDrawMeta>(() => ({
type: TLDrawShapeType.Arrow,
@ -394,7 +395,7 @@ export const Arrow = new ShapeUtil<ArrowShape, SVGSVGElement, TLDrawMeta>(() =>
onBindingChange(shape, binding: ArrowBinding, target, targetBounds, center) {
const handle = shape.handles[binding.meta.handleId as keyof ArrowShape['handles']]
const expandedBounds = Utils.expandBounds(targetBounds, 32)
const expandedBounds = Utils.expandBounds(targetBounds, BINDING_DISTANCE)
// The anchor is the "actual" point in the target shape
// (Remember that the binding.point is normalized)

View file

@ -10,6 +10,7 @@ import {
intersectRayEllipse,
} from '@tldraw/intersect'
import { EASINGS } from '~state/utils'
import { BINDING_DISTANCE } from '~constants'
export const Ellipse = new ShapeUtil<EllipseShape, SVGSVGElement, TLDrawMeta>(() => ({
type: TLDrawShapeType.Ellipse,
@ -186,7 +187,15 @@ export const Ellipse = new ShapeUtil<EllipseShape, SVGSVGElement, TLDrawMeta>(()
let bindingPoint: number[]
let distance: number
if (!Utils.pointInEllipse(point, center, shape.radius[0] + 32, shape.radius[1] + 32)) return
if (
!Utils.pointInEllipse(
point,
center,
shape.radius[0] + BINDING_DISTANCE,
shape.radius[1] + BINDING_DISTANCE
)
)
return
if (anywhere) {
if (Vec.dist(point, this.getCenter(shape)) < 12) {
@ -238,7 +247,7 @@ export const Ellipse = new ShapeUtil<EllipseShape, SVGSVGElement, TLDrawMeta>(()
Utils.pointInEllipse(point, center, shape.radius[0], shape.radius[1], shape.rotation || 0)
) {
// Pad the arrow out by 16 points
distance = 16
distance = BINDING_DISTANCE / 2
} else {
// Find the distance between the point and the ellipse
const innerIntersection = intersectLineSegmentEllipse(
@ -254,7 +263,7 @@ export const Ellipse = new ShapeUtil<EllipseShape, SVGSVGElement, TLDrawMeta>(()
return undefined
}
distance = Math.max(16, Vec.dist(point, innerIntersection))
distance = Math.max(BINDING_DISTANCE / 2, Vec.dist(point, innerIntersection))
}
}

View file

@ -4,6 +4,7 @@ import { defaultStyle } from '~shape/shape-styles'
import { GroupShape, TLDrawShapeType, ColorStyle, TLDrawMeta } from '~types'
import { getBoundsRectangle } from '../shared'
import css from '~styles'
import { BINDING_DISTANCE } from '~constants'
export const Group = new ShapeUtil<GroupShape, SVGSVGElement, TLDrawMeta>(() => ({
type: TLDrawShapeType.Group,
@ -46,10 +47,10 @@ export const Group = new ShapeUtil<GroupShape, SVGSVGElement, TLDrawMeta>(() =>
{isBinding && (
<rect
className="tl-binding-indicator"
x={-32}
y={-32}
width={size[0] + 64}
height={size[1] + 64}
x={-BINDING_DISTANCE}
y={-BINDING_DISTANCE}
width={size[0] + BINDING_DISTANCE * 2}
height={size[1] + BINDING_DISTANCE * 2}
/>
)}
<rect x={0} y={0} width={size[0]} height={size[1]} fill="transparent" pointerEvents="all" />

View file

@ -5,6 +5,7 @@ import getStroke, { getStrokePoints } from 'perfect-freehand'
import { defaultStyle, getShapeStyle } from '~shape/shape-styles'
import { RectangleShape, DashStyle, TLDrawShapeType, TLDrawMeta } from '~types'
import { getBoundsRectangle, transformRectangle, transformSingleRectangle } from '../shared'
import { BINDING_DISTANCE } from '~constants'
export const Rectangle = new ShapeUtil<RectangleShape, SVGSVGElement, TLDrawMeta>(() => ({
type: TLDrawShapeType.Rectangle,
@ -40,10 +41,10 @@ export const Rectangle = new ShapeUtil<RectangleShape, SVGSVGElement, TLDrawMeta
{isBinding && (
<rect
className="tl-binding-indicator"
x={strokeWidth / 2 - 32}
y={strokeWidth / 2 - 32}
width={Math.max(0, size[0] - strokeWidth / 2) + 64}
height={Math.max(0, size[1] - strokeWidth / 2) + 64}
x={strokeWidth / 2 - BINDING_DISTANCE}
y={strokeWidth / 2 - BINDING_DISTANCE}
width={Math.max(0, size[0] - strokeWidth / 2) + BINDING_DISTANCE * 2}
height={Math.max(0, size[1] - strokeWidth / 2) + BINDING_DISTANCE * 2}
/>
)}
<path

View file

@ -6,6 +6,7 @@ import { getShapeStyle, getFontStyle, defaultStyle } from '~shape/shape-styles'
import { TextShape, TLDrawShapeType, TLDrawMeta } from '~types'
import css from '~styles'
import { TextAreaUtils } from '../shared'
import { BINDING_DISTANCE } from '~constants'
const LETTER_SPACING = -1.5
@ -169,6 +170,19 @@ export const Text = new ShapeUtil<TextShape, HTMLDivElement, TLDrawMeta>(() => (
color: styles.stroke,
}}
>
{isBinding && (
<div
className="tl-binding-indicator"
style={{
position: 'absolute',
top: -BINDING_DISTANCE,
left: -BINDING_DISTANCE,
width: `calc(100% + ${BINDING_DISTANCE * 2}px)`,
height: `calc(100% + ${BINDING_DISTANCE * 2}px)`,
backgroundColor: 'var(--tl-selectFill)',
}}
/>
)}
{isEditing ? (
<textarea
className={textArea({ isBinding })}
@ -289,18 +303,6 @@ export const Text = new ShapeUtil<TextShape, HTMLDivElement, TLDrawMeta>(() => (
point: Vec.round(Vec.add(shape.point, Vec.sub(center, newCenter))),
}
},
onStyleChange(shape) {
const center = this.getCenter(shape)
this.boundsCache.delete(shape)
const newCenter = this.getCenter(shape)
return {
point: Vec.round(Vec.add(shape.point, Vec.sub(center, newCenter))),
}
},
}))
/* -------------------------------------------------- */

View file

@ -1,6 +1,7 @@
import { TLDrawState } from '~state'
import { TLDR } from '~state/tldr'
import { mockDocument } from '~test'
import { SizeStyle } from '~types'
import { SizeStyle, TLDrawShapeType } from '~types'
describe('Style command', () => {
const tlstate = new TLDrawState()
@ -46,4 +47,32 @@ describe('Style command', () => {
expect(tlstate.getShape('rect2').style.size).toEqual(SizeStyle.Small)
})
})
describe('When styling text', () => {
it('recenters the shape if the size changed', () => {
const tlstate = new TLDrawState().createShapes({
id: 'text1',
type: TLDrawShapeType.Text,
text: 'Hello world',
})
const centerA = TLDR.getShapeUtils(TLDrawShapeType.Text).getCenter(tlstate.getShape('text1'))
tlstate.select('text1').style({ size: SizeStyle.Large })
const centerB = TLDR.getShapeUtils(TLDrawShapeType.Text).getCenter(tlstate.getShape('text1'))
tlstate.style({ size: SizeStyle.Small })
const centerC = TLDR.getShapeUtils(TLDrawShapeType.Text).getCenter(tlstate.getShape('text1'))
tlstate.style({ size: SizeStyle.Medium })
const centerD = TLDR.getShapeUtils(TLDrawShapeType.Text).getCenter(tlstate.getShape('text1'))
expect(centerA).toEqual(centerB)
expect(centerA).toEqual(centerC)
expect(centerB).toEqual(centerD)
})
})
})

View file

@ -1,17 +1,55 @@
import type { ShapeStyles, TLDrawCommand, Data } from '~types'
import { ShapeStyles, TLDrawCommand, Data, TLDrawShape, TLDrawShapeType, TextShape } from '~types'
import { TLDR } from '~state/tldr'
import type { Patch } from 'rko'
import Vec from '@tldraw/vec'
export function style(data: Data, ids: string[], changes: Partial<ShapeStyles>): TLDrawCommand {
const { currentPageId } = data.appState
const shapeIdsToMutate = ids.flatMap((id) => TLDR.getDocumentBranch(data, id, currentPageId))
const { before, after } = TLDR.mutateShapes(
data,
shapeIdsToMutate,
(shape) => ({ style: { ...shape.style, ...changes } }),
currentPageId
)
// const { before, after } = TLDR.mutateShapes(
// data,
// shapeIdsToMutate,
// (shape) => ({ style: { ...shape.style, ...changes } }),
// currentPageId
// )
const beforeShapes: Record<string, Patch<TLDrawShape>> = {}
const afterShapes: Record<string, Patch<TLDrawShape>> = {}
shapeIdsToMutate
.map((id) => TLDR.getShape(data, id, currentPageId))
.filter((shape) => !shape.isLocked)
.forEach((shape) => {
beforeShapes[shape.id] = {
style: {
...Object.fromEntries(
Object.keys(changes).map((key) => [key, shape.style[key as keyof typeof shape.style]])
),
},
}
afterShapes[shape.id] = {
style: changes,
}
if (shape.type === TLDrawShapeType.Text) {
beforeShapes[shape.id].point = shape.point
afterShapes[shape.id].point = Vec.round(
Vec.add(
shape.point,
Vec.sub(
TLDR.getShapeUtils(shape).getCenter(shape),
TLDR.getShapeUtils(shape).getCenter({
...shape,
style: { ...shape.style, ...changes },
} as TextShape)
)
)
)
}
})
return {
id: 'style',
@ -19,7 +57,7 @@ export function style(data: Data, ids: string[], changes: Partial<ShapeStyles>):
document: {
pages: {
[currentPageId]: {
shapes: before,
shapes: beforeShapes,
},
},
},
@ -31,7 +69,7 @@ export function style(data: Data, ids: string[], changes: Partial<ShapeStyles>):
document: {
pages: {
[currentPageId]: {
shapes: after,
shapes: afterShapes,
},
},
},

View file

@ -3,18 +3,14 @@ import { mockDocument } from '~test'
import { ArrowShape, SessionType, TLDrawShapeType, TLDrawStatus } from '~types'
describe('Arrow session', () => {
const tlstate = new TLDrawState()
tlstate
const restoreDoc = new TLDrawState()
.loadDocument(mockDocument)
.selectAll()
.delete()
.createShapes(
{ type: TLDrawShapeType.Rectangle, id: 'target1', point: [0, 0], size: [100, 100] },
{ type: TLDrawShapeType.Arrow, id: 'arrow1', point: [200, 200] }
)
const restoreDoc = tlstate.document
).document
it('begins, updateSession', () => {
const tlstate = new TLDrawState()
@ -80,7 +76,7 @@ describe('Arrow session', () => {
.loadDocument(restoreDoc)
.select('arrow1')
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([132, -32])
.updateSession([124, -24])
expect(tlstate.bindings[0].meta.point).toStrictEqual([1, 0])
})
@ -101,7 +97,7 @@ describe('Arrow session', () => {
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([91, 9])
expect(tlstate.bindings[0].meta.point).toStrictEqual([0.68, 0.13])
expect(tlstate.bindings[0].meta.point).toStrictEqual([0.71, 0.11])
tlstate.updateSession([91, 9], false, false, true)
})
@ -111,13 +107,9 @@ describe('Arrow session', () => {
.loadDocument(restoreDoc)
.select('arrow1')
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([91, 9])
.updateSession([91, 9], false, false, true)
expect(tlstate.bindings[0].meta.point).toStrictEqual([0.68, 0.13])
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.78, 0.22])
})
it('ignores binding when alt is held', () => {

View file

@ -11,6 +11,7 @@ import {
import { Vec } from '@tldraw/vec'
import { Utils, TLBounds } from '@tldraw/core'
import { TLDR } from '~state/tldr'
import { BINDING_DISTANCE } from '~constants'
export class ArrowSession extends Session {
static type = SessionType.Arrow
@ -437,7 +438,7 @@ export class ArrowSession extends Session {
point,
origin,
direction,
32,
BINDING_DISTANCE,
bindAnywhere
)

View file

@ -1,4 +1,6 @@
import { TLDrawState } from '~state'
import { mockDocument, TLStateUtils } from '~test'
import { SessionType, TLDrawShapeType } from '~types'
import { SelectTool } from '.'
describe('SelectTool', () => {
@ -9,7 +11,211 @@ describe('SelectTool', () => {
})
describe('When double clicking link controls', () => {
it.todo('selects all linked shapes when center is double clicked')
it.todo('selects all upstream linked shapes when left is double clicked')
it.todo('selects all downstream linked shapes when right is double clicked')
const doc = new TLDrawState()
.createShapes(
{
id: 'rect1',
type: TLDrawShapeType.Rectangle,
point: [0, 0],
size: [100, 100],
},
{
id: 'rect2',
type: TLDrawShapeType.Rectangle,
point: [100, 0],
size: [100, 100],
},
{
id: 'rect3',
type: TLDrawShapeType.Rectangle,
point: [200, 0],
size: [100, 100],
},
{
id: 'arrow1',
type: TLDrawShapeType.Arrow,
point: [200, 200],
},
{
id: 'arrow2',
type: TLDrawShapeType.Arrow,
point: [200, 200],
}
)
.select('arrow1')
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([50, 50])
.completeSession()
.startSession(SessionType.Arrow, [200, 200], 'end')
.updateSession([150, 50])
.completeSession()
.select('arrow2')
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([150, 50])
.completeSession()
.startSession(SessionType.Arrow, [200, 200], 'end')
.updateSession([250, 50])
.completeSession()
.deselectAll().document
it('moves all linked shapes when center is dragged', () => {
const tlstate = new TLDrawState().loadDocument(doc).select('rect2')
const tlu = new TLStateUtils(tlstate)
tlu
.pointBoundsHandle('center')
.movePointer({ x: 100, y: 100 })
.expectShapesToBeAtPoints({
rect1: [100, 100],
rect2: [200, 100],
rect3: [300, 100],
})
tlstate.completeSession().undo()
tlu.expectShapesToBeAtPoints({
rect1: [0, 0],
rect2: [100, 0],
rect3: [200, 0],
})
})
it('moves all upstream shapes when center is dragged', () => {
const tlstate = new TLDrawState().loadDocument(doc).select('rect2')
const tlu = new TLStateUtils(tlstate)
tlu.pointBoundsHandle('left').movePointer({ x: 100, y: 100 })
expect(tlstate.getShape('rect1').point).toEqual([100, 100])
expect(tlstate.getShape('rect2').point).toEqual([200, 100])
expect(tlstate.getShape('rect3').point).toEqual([200, 0])
})
it('moves all downstream shapes when center is dragged', () => {
const tlstate = new TLDrawState().loadDocument(doc).select('rect2')
const tlu = new TLStateUtils(tlstate)
tlu.pointBoundsHandle('right').movePointer({ x: 100, y: 100 })
expect(tlstate.getShape('rect1').point).toEqual([0, 0])
expect(tlstate.getShape('rect2').point).toEqual([200, 100])
expect(tlstate.getShape('rect3').point).toEqual([300, 100])
})
it('selects all linked shapes when center is double clicked', () => {
const tlstate = new TLDrawState().loadDocument(doc).select('rect2')
const tlu = new TLStateUtils(tlstate)
tlu.doubleClickBoundHandle('center').expectSelectedIdsToBe(['rect2', 'rect1', 'rect3'])
})
it('selects all linked shapes and arrows when center is double clicked while holding shift', () => {
const tlstate = new TLDrawState().loadDocument(doc).select('rect2')
const tlu = new TLStateUtils(tlstate)
tlu
.doubleClickBoundHandle('center', { shiftKey: true })
.expectSelectedIdsToBe(['rect2', 'rect1', 'rect3', 'arrow1', 'arrow2'])
})
it('selects all upstream linked shapes when left is double clicked', () => {
const tlstate = new TLDrawState().loadDocument(doc).select('rect2')
const tlu = new TLStateUtils(tlstate)
tlu.doubleClickBoundHandle('left').expectSelectedIdsToBe(['rect1', 'rect2'])
})
it('selects all upstream linked shapes and arrows when left is double clicked with shift', () => {
const tlstate = new TLDrawState().loadDocument(doc).select('rect2')
const tlu = new TLStateUtils(tlstate)
tlu
.doubleClickBoundHandle('left', { shiftKey: true })
.expectSelectedIdsToBe(['rect1', 'rect2', 'arrow1'])
})
it('selects all downstream linked shapes when right is double clicked', () => {
const tlstate = new TLDrawState().loadDocument(doc).select('rect2')
const tlu = new TLStateUtils(tlstate)
tlu.doubleClickBoundHandle('right').expectSelectedIdsToBe(['rect2', 'rect3'])
})
it('selects all downstream linked shapes and arrows when right is double clicked with shift', () => {
const tlstate = new TLDrawState().loadDocument(doc).select('rect2')
const tlu = new TLStateUtils(tlstate)
tlu
.doubleClickBoundHandle('right', { shiftKey: true })
.expectSelectedIdsToBe(['rect2', 'rect3', 'arrow2'])
})
})
describe('When selecting grouped shapes', () => {
it('Selects the group on single click', () => {
const tlstate = new TLDrawState().loadDocument(mockDocument).group(['rect1', 'rect2'], 'groupA')
new TLStateUtils(tlstate).clickShape('rect1')
expect(tlstate.selectedIds).toStrictEqual(['groupA'])
})
it('Drills in and selects the child on double click', () => {
const tlstate = new TLDrawState().loadDocument(mockDocument).group(['rect1', 'rect2'], 'groupA')
new TLStateUtils(tlstate).doubleClickShape('rect1')
expect(tlstate.selectedIds).toStrictEqual(['rect1'])
})
it('Selects a sibling on single click after drilling', () => {
const tlstate = new TLDrawState().loadDocument(mockDocument).group(['rect1', 'rect2'], 'groupA')
new TLStateUtils(tlstate).doubleClickShape('rect1').clickShape('rect2')
expect(tlstate.selectedIds).toStrictEqual(['rect2'])
})
it('Selects the group again after selecting a different shape', () => {
const tlstate = new TLDrawState()
.loadDocument(mockDocument)
.selectAll()
.group(['rect1', 'rect2'], 'groupA')
new TLStateUtils(tlstate).doubleClickShape('rect1').clickShape('rect3').clickShape('rect1')
expect(tlstate.selectedIds).toStrictEqual(['groupA'])
})
it('Selects grouped text on double click', () => {
const tlstate = new TLDrawState()
.loadDocument(mockDocument)
.createShapes({
id: 'text1',
type: TLDrawShapeType.Text,
text: 'Hello world',
})
.group(['rect1', 'rect2', 'text1'], 'groupA')
new TLStateUtils(tlstate).doubleClickShape('text1')
expect(tlstate.selectedIds).toStrictEqual(['text1'])
expect(tlstate.pageState.editingId).toBeUndefined()
})
it('Edits grouped text on double click after selecting', () => {
const tlstate = new TLDrawState()
.loadDocument(mockDocument)
.createShapes({
id: 'text1',
type: TLDrawShapeType.Text,
text: 'Hello world',
})
.group(['rect1', 'rect2', 'text1'], 'groupA')
new TLStateUtils(tlstate).doubleClickShape('text1').doubleClickShape('text1')
expect(tlstate.selectedIds).toStrictEqual(['text1'])
expect(tlstate.pageState.editingId).toBe('text1')
})
})

View file

@ -13,6 +13,7 @@ import { SessionType, TLDrawShapeType } from '~types'
import { BaseTool } from '../BaseTool'
import Vec from '@tldraw/vec'
import { TLDR } from '~state/tldr'
import { CLONING_DISTANCE } from '~constants'
enum Status {
Idle = 'idle',
@ -123,14 +124,23 @@ export class SelectTool extends BaseTool<Status> {
const center = utils.getCenter(shape)
let point = {
top: [bounds.minX, bounds.minY - (bounds.height + 32)],
right: [bounds.maxX + 32, bounds.minY],
bottom: [bounds.minX, bounds.maxY + 32],
left: [bounds.minX - (bounds.width + 32), bounds.minY],
topLeft: [bounds.minX - (bounds.width + 32), bounds.minY - (bounds.height + 32)],
topRight: [bounds.maxX + 32, bounds.minY - (bounds.height + 32)],
bottomLeft: [bounds.minX - (bounds.width + 32), bounds.maxY + 32],
bottomRight: [bounds.maxX + 32, bounds.maxY + 32],
top: [bounds.minX, bounds.minY - (bounds.height + CLONING_DISTANCE)],
right: [bounds.maxX + CLONING_DISTANCE, bounds.minY],
bottom: [bounds.minX, bounds.maxY + CLONING_DISTANCE],
left: [bounds.minX - (bounds.width + CLONING_DISTANCE), bounds.minY],
topLeft: [
bounds.minX - (bounds.width + CLONING_DISTANCE),
bounds.minY - (bounds.height + CLONING_DISTANCE),
],
topRight: [
bounds.maxX + CLONING_DISTANCE,
bounds.minY - (bounds.height + CLONING_DISTANCE),
],
bottomLeft: [
bounds.minX - (bounds.width + CLONING_DISTANCE),
bounds.maxY + CLONING_DISTANCE,
],
bottomRight: [bounds.maxX + CLONING_DISTANCE, bounds.maxY + CLONING_DISTANCE],
}[side]
if (shape.rotation !== 0) {
@ -247,15 +257,14 @@ export class SelectTool extends BaseTool<Status> {
if (this.pointedBoundsHandle === 'rotate') {
// Stat a rotate session
this.setStatus(Status.Rotating)
this.state.startSession(SessionType.Rotate, point)
} else if (
this.pointedBoundsHandle === 'center' ||
this.pointedBoundsHandle === 'left' ||
this.pointedBoundsHandle === 'right'
) {
this.setStatus(Status.Translating)
const point = this.state.getPagePoint(info.origin)
this.setStatus(Status.Translating)
this.state.startSession(SessionType.Translate, point, false, this.pointedBoundsHandle)
} else {
// Stat a transform session
@ -273,6 +282,14 @@ export class SelectTool extends BaseTool<Status> {
this.state.startSession(SessionType.Transform, point, this.pointedBoundsHandle)
}
}
// Also update the session with the current point
this.state.updateSession(
this.state.getPagePoint(info.point),
info.shiftKey,
info.altKey,
info.metaKey
)
}
return
}
@ -529,19 +546,18 @@ export class SelectTool extends BaseTool<Status> {
}
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) {
// If we can edit the shape (and if we can select the shape) then
// start editing
if (
TLDR.getShapeUtils(shape.type).canEdit &&
(shape.parentId === this.state.currentPageId || shape.parentId === this.selectedGroupId)
) {
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 the shape is the child of a group, then drill into the group?
if (shape.parentId !== this.state.currentPageId) {
this.selectedGroupId = shape.parentId
}

View file

@ -1,4 +1,4 @@
import { inputs, TLBoundsEdge, TLBoundsCorner } from '@tldraw/core'
import { inputs, TLBoundsEdge, TLBoundsCorner, TLBoundsHandle } from '@tldraw/core'
import type { TLDrawState } from '~state'
interface PointerOptions {
@ -20,11 +20,13 @@ export class TLStateUtils {
movePointer = (options: PointerOptions = {}) => {
const { tlstate } = this
tlstate.onPointerMove(inputs.pointerMove(this.getPoint(options), ''), {} as React.PointerEvent)
return this
}
hoverShape = (id: string, options: PointerOptions = {}) => {
const { tlstate } = this
tlstate.onHoverShape(inputs.pointerDown(this.getPoint(options), id), {} as React.PointerEvent)
return this
}
pointCanvas = (options: PointerOptions = {}) => {
@ -75,12 +77,21 @@ export class TLStateUtils {
return this
}
pointBoundsHandle = (
id: TLBoundsCorner | TLBoundsEdge | 'rotate',
options: PointerOptions = {}
) => {
this.tlstate.onPointBounds(
inputs.pointerDown(this.getPoint(options), 'bounds'),
pointBoundsHandle = (id: TLBoundsHandle, options: PointerOptions = {}) => {
this.tlstate.onPointBoundsHandle(
inputs.pointerDown(this.getPoint(options), id),
{} as React.PointerEvent
)
this.tlstate.onPointerDown(
inputs.pointerDown(this.getPoint(options), 'canvas'),
{} as React.PointerEvent
)
return this
}
doubleClickBoundHandle = (id: TLBoundsHandle, options: PointerOptions = {}) => {
this.tlstate.onDoubleClickBoundsHandle(
inputs.pointerDown(this.getPoint(options), id),
{} as React.PointerEvent
)
this.tlstate.onPointerDown(
@ -138,4 +149,16 @@ export class TLStateUtils {
clientY: y,
} as PointerEvent
}
expectSelectedIdsToBe = (b: string[]) => {
expect(new Set(this.tlstate.selectedIds)).toEqual(new Set(b))
return this
}
expectShapesToBeAtPoints = (shapes: Record<string, number[]>) => {
Object.entries(shapes).forEach(([id, point]) => {
expect(this.tlstate.getShape(id).point).toEqual(point)
})
return this
}
}