Starts on groups, fixes duplicate bugs with bindings

This commit is contained in:
Steve Ruiz 2021-09-02 13:51:39 +01:00
parent e738018448
commit a1a213f9b4
31 changed files with 23054 additions and 495 deletions

View file

@ -4,10 +4,18 @@ const esbuild = require('esbuild')
async function main() { async function main() {
if (fs.existsSync('./dist')) { if (fs.existsSync('./dist')) {
fs.rmdirSync('./dist', { recursive: true }) fs.rmSync('./dist', { recursive: true }, (e) => {
if (e) {
throw e
}
})
} }
fs.mkdirSync('./dist') fs.mkdir('./dist', (e) => {
if (e) {
throw e
}
})
esbuild.build({ esbuild.build({
entryPoints: ['./src/index.ts'], entryPoints: ['./src/index.ts'],

View file

@ -12,6 +12,8 @@ export function EditingTextShape({
utils, utils,
isEditing, isEditing,
isBinding, isBinding,
isHovered,
isSelected,
isCurrentParent, isCurrentParent,
meta, meta,
}: EditingShapeProps<TLShape>) { }: EditingShapeProps<TLShape>) {
@ -24,6 +26,8 @@ export function EditingTextShape({
return utils.render(shape, { return utils.render(shape, {
ref, ref,
isEditing, isEditing,
isHovered,
isSelected,
isCurrentParent, isCurrentParent,
isBinding, isBinding,
onTextChange, onTextChange,

View file

@ -12,18 +12,25 @@ export const RenderedShape = React.memo(
utils, utils,
isEditing, isEditing,
isBinding, isBinding,
isHovered,
isSelected,
isCurrentParent, isCurrentParent,
meta, meta,
}: RenderedShapeProps<TLShape>) { }: RenderedShapeProps<TLShape>) {
return utils.render(shape, { return utils.render(shape, {
isEditing, isEditing,
isBinding, isBinding,
isHovered,
isSelected,
isCurrentParent, isCurrentParent,
meta, meta,
}) })
}, },
(prev, next) => { (prev, next) => {
// If these have changed, then definitely render
if ( if (
prev.isHovered !== next.isHovered ||
prev.isSelected !== next.isSelected ||
prev.isEditing !== next.isEditing || prev.isEditing !== next.isEditing ||
prev.isBinding !== next.isBinding || prev.isBinding !== next.isBinding ||
prev.meta !== next.meta || prev.meta !== next.meta ||
@ -32,6 +39,8 @@ export const RenderedShape = React.memo(
return false return false
} }
// If not, and if the shape has changed, ask the shape's class
// whether it should render
if (next.shape !== prev.shape) { if (next.shape !== prev.shape) {
return !next.utils.shouldRender(next.shape, prev.shape) return !next.utils.shouldRender(next.shape, prev.shape)
} }

View file

@ -8,6 +8,8 @@ export const ShapeNode = React.memo(
children, children,
isEditing, isEditing,
isBinding, isBinding,
isHovered,
isSelected,
isCurrentParent, isCurrentParent,
meta, meta,
}: IShapeTreeNode<M>) => { }: IShapeTreeNode<M>) => {
@ -17,6 +19,8 @@ export const ShapeNode = React.memo(
shape={shape} shape={shape}
isEditing={isEditing} isEditing={isEditing}
isBinding={isBinding} isBinding={isBinding}
isHovered={isHovered}
isSelected={isSelected}
isCurrentParent={isCurrentParent} isCurrentParent={isCurrentParent}
meta={meta} meta={meta}
/> />

View file

@ -9,6 +9,8 @@ describe('shape', () => {
shape={mockUtils.box.create({})} shape={mockUtils.box.create({})}
isEditing={false} isEditing={false}
isBinding={false} isBinding={false}
isHovered={false}
isSelected={false}
isCurrentParent={false} isCurrentParent={false}
/> />
) )

View file

@ -9,6 +9,8 @@ export const Shape = React.memo(
shape, shape,
isEditing, isEditing,
isBinding, isBinding,
isHovered,
isSelected,
isCurrentParent, isCurrentParent,
meta, meta,
}: IShapeTreeNode<M>) => { }: IShapeTreeNode<M>) => {
@ -33,6 +35,8 @@ export const Shape = React.memo(
isBinding={false} isBinding={false}
isCurrentParent={false} isCurrentParent={false}
isEditing={true} isEditing={true}
isHovered={isHovered}
isSelected={isSelected}
utils={utils} utils={utils}
meta={meta} meta={meta}
/> />
@ -43,6 +47,8 @@ export const Shape = React.memo(
isBinding={isBinding} isBinding={isBinding}
isCurrentParent={isCurrentParent} isCurrentParent={isCurrentParent}
isEditing={isEditing} isEditing={isEditing}
isHovered={isHovered}
isSelected={isSelected}
meta={meta} meta={meta}
/> />
)} )}

View file

@ -14,11 +14,11 @@ function addToShapeTree<T extends TLShape, M extends Record<string, unknown>>(
shape: TLShape, shape: TLShape,
branch: IShapeTreeNode<M>[], branch: IShapeTreeNode<M>[],
shapes: TLPage<T, TLBinding>['shapes'], shapes: TLPage<T, TLBinding>['shapes'],
selectedIds: string[],
pageState: { pageState: {
bindingTargetId?: string bindingTargetId?: string
bindingId?: string bindingId?: string
hoveredId?: string hoveredId?: string
selectedIds: string[]
currentParentId?: string currentParentId?: string
editingId?: string editingId?: string
editingBindingId?: string editingBindingId?: string
@ -29,6 +29,11 @@ function addToShapeTree<T extends TLShape, M extends Record<string, unknown>>(
shape, shape,
isCurrentParent: pageState.currentParentId === shape.id, isCurrentParent: pageState.currentParentId === shape.id,
isEditing: pageState.editingId === shape.id, isEditing: pageState.editingId === shape.id,
isSelected: pageState.selectedIds.includes(shape.id),
isHovered: pageState.hoveredId
? pageState.hoveredId === shape.id ||
(shape.children ? shape.children.includes(pageState.hoveredId) : false)
: false,
isBinding: pageState.bindingTargetId === shape.id, isBinding: pageState.bindingTargetId === shape.id,
meta, meta,
} }
@ -42,7 +47,7 @@ function addToShapeTree<T extends TLShape, M extends Record<string, unknown>>(
.sort((a, b) => a.childIndex - b.childIndex) .sort((a, b) => a.childIndex - b.childIndex)
.forEach((childShape) => .forEach((childShape) =>
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
addToShapeTree(childShape, node.children!, shapes, selectedIds, pageState, meta) addToShapeTree(childShape, node.children!, shapes, pageState, meta)
) )
} }
} }
@ -84,14 +89,11 @@ export function useShapeTree<T extends TLShape, M extends Record<string, unknown
if (shape.parentId !== page.id) return false if (shape.parentId !== page.id) return false
// Don't hide selected shapes (this breaks certain drag interactions) // Don't hide selected shapes (this breaks certain drag interactions)
if (pageState.selectedIds.includes(shape.id)) return true if (selectedIds.includes(shape.id)) return true
const shapeBounds = shapeUtils[shape.type as T['type']].getBounds(shape) const shapeBounds = shapeUtils[shape.type as T['type']].getBounds(shape)
return ( return Utils.boundsContain(viewport, shapeBounds) || Utils.boundsCollide(viewport, shapeBounds)
// TODO: Some shapes should always render (lines, rays)
Utils.boundsContain(viewport, shapeBounds) || Utils.boundsCollide(viewport, shapeBounds)
)
}) })
// Call onChange callback when number of rendering shapes changes // Call onChange callback when number of rendering shapes changes
@ -112,7 +114,7 @@ export function useShapeTree<T extends TLShape, M extends Record<string, unknown
shapesToRender shapesToRender
.sort((a, b) => a.childIndex - b.childIndex) .sort((a, b) => a.childIndex - b.childIndex)
.forEach((shape) => .forEach((shape) =>
addToShapeTree(shape, tree, page.shapes, selectedIds, { ...pageState, bindingTargetId }, meta) addToShapeTree(shape, tree, page.shapes, { ...pageState, bindingTargetId }, meta)
) )
return tree return tree

View file

@ -58,6 +58,8 @@ export interface TLRenderInfo<M = any, T extends SVGElement | HTMLElement = any>
ref?: React.RefObject<T> ref?: React.RefObject<T>
isEditing: boolean isEditing: boolean
isBinding: boolean isBinding: boolean
isHovered: boolean
isSelected: boolean
isCurrentParent: boolean isCurrentParent: boolean
onTextChange?: TLCallbacks['onTextChange'] onTextChange?: TLCallbacks['onTextChange']
onTextBlur?: TLCallbacks['onTextBlur'] onTextBlur?: TLCallbacks['onTextBlur']
@ -262,7 +264,7 @@ export abstract class TLShapeUtil<T extends TLShape> {
abstract defaultProps: T abstract defaultProps: T
abstract render(shape: T, info: TLRenderInfo): JSX.Element abstract render(shape: T, info: TLRenderInfo): JSX.Element | null
abstract renderIndicator(shape: T): JSX.Element | null abstract renderIndicator(shape: T): JSX.Element | null
@ -374,6 +376,8 @@ export interface IShapeTreeNode<M extends Record<string, unknown>> {
children?: IShapeTreeNode<M>[] children?: IShapeTreeNode<M>[]
isEditing: boolean isEditing: boolean
isBinding: boolean isBinding: boolean
isHovered: boolean
isSelected: boolean
isCurrentParent: boolean isCurrentParent: boolean
meta?: M meta?: M
} }

View file

@ -4,10 +4,18 @@ const esbuild = require('esbuild')
async function main() { async function main() {
if (fs.existsSync('./dist')) { if (fs.existsSync('./dist')) {
fs.rmdirSync('./dist', { recursive: true }) fs.rmSync('./dist', { recursive: true }, (e) => {
if (e) {
throw e
}
})
} }
fs.mkdirSync('./dist') fs.mkdir('./dist', (e) => {
if (e) {
throw e
}
})
esbuild.build({ esbuild.build({
entryPoints: ['./src/index.ts'], entryPoints: ['./src/index.ts'],

View file

@ -106,8 +106,9 @@ export function useKeyboardShortcuts(tlstate: TLDrawState) {
// Duplicate // Duplicate
useHotkeys('ctrl+d,command+d', () => { useHotkeys('ctrl+d,command+d', (e) => {
tlstate.duplicate() tlstate.duplicate()
e.preventDefault()
}) })
// Flip // Flip
@ -182,6 +183,18 @@ export function useKeyboardShortcuts(tlstate: TLDrawState) {
tlstate.paste() tlstate.paste()
}) })
// Group & Ungroup
useHotkeys('command+g,ctrl+g', (e) => {
tlstate.group()
e.preventDefault()
})
useHotkeys('command+shift+g,ctrl+shift+g', (e) => {
tlstate.ungroup()
e.preventDefault()
})
// Move // Move
useHotkeys('[', () => { useHotkeys('[', () => {
@ -201,7 +214,7 @@ export function useKeyboardShortcuts(tlstate: TLDrawState) {
}) })
useHotkeys('command+shift+backspace', (e) => { useHotkeys('command+shift+backspace', (e) => {
tlstate.reset() tlstate.resetDocument()
e.preventDefault() e.preventDefault()
}) })
} }

View file

@ -1,4 +1,4 @@
import { Rectangle, Ellipse, Arrow, Draw, Text } from './shapes' import { Rectangle, Ellipse, Arrow, Draw, Text, Group } from './shapes'
import { TLDrawShapeType, TLDrawShape, TLDrawShapeUtil, TLDrawShapeUtils } from '~types' import { TLDrawShapeType, TLDrawShape, TLDrawShapeUtil, TLDrawShapeUtils } from '~types'
export const tldrawShapeUtils: TLDrawShapeUtils = { export const tldrawShapeUtils: TLDrawShapeUtils = {
@ -7,6 +7,7 @@ export const tldrawShapeUtils: TLDrawShapeUtils = {
[TLDrawShapeType.Draw]: new Draw(), [TLDrawShapeType.Draw]: new Draw(),
[TLDrawShapeType.Arrow]: new Arrow(), [TLDrawShapeType.Arrow]: new Arrow(),
[TLDrawShapeType.Text]: new Text(), [TLDrawShapeType.Text]: new Text(),
[TLDrawShapeType.Group]: new Group(),
} }
export type ShapeByType<T extends keyof TLDrawShapeUtils> = TLDrawShapeUtils[T] export type ShapeByType<T extends keyof TLDrawShapeUtils> = TLDrawShapeUtils[T]

View file

@ -0,0 +1,7 @@
import { Group } from './group'
describe('Group shape', () => {
it('Creates an instance', () => {
new Group()
})
})

View file

@ -0,0 +1,240 @@
import * as React from 'react'
import { TLBounds, Utils, Vec, TLTransformInfo, Intersect } from '@tldraw/core'
import { defaultStyle, getPerfectDashProps } from '~shape/shape-styles'
import {
GroupShape,
TLDrawShapeUtil,
TLDrawShapeType,
TLDrawToolType,
TLDrawRenderInfo,
TLDrawShape,
ColorStyle,
DashStyle,
} from '~types'
// TODO
// [ ] - Find bounds based on common bounds of descendants
export class Group extends TLDrawShapeUtil<GroupShape> {
type = TLDrawShapeType.Group as const
toolType = TLDrawToolType.Bounds
canBind = true
pathCache = new WeakMap<number[], string>([])
defaultProps: GroupShape = {
id: 'id',
type: TLDrawShapeType.Group as const,
name: 'Group',
parentId: 'page',
childIndex: 1,
point: [0, 0],
size: [100, 100],
rotation: 0,
children: [],
style: defaultStyle,
}
shouldRender(prev: GroupShape, next: GroupShape) {
return next.size !== prev.size || next.style !== prev.style
}
render(shape: GroupShape, { isBinding, isHovered }: TLDrawRenderInfo) {
const { id, size } = shape
const sw = 2
const w = Math.max(0, size[0] - sw / 2)
const h = Math.max(0, size[1] - sw / 2)
const strokes: [number[], number[], number][] = [
[[sw / 2, sw / 2], [w, sw / 2], w - sw / 2],
[[w, sw / 2], [w, h], h - sw / 2],
[[w, h], [sw / 2, h], w - sw / 2],
[[sw / 2, h], [sw / 2, sw / 2], h - sw / 2],
]
const paths = strokes.map(([start, end, length], i) => {
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
length,
sw,
DashStyle.Dotted
)
return (
<line
key={id + '_' + i}
x1={start[0]}
y1={start[1]}
x2={end[0]}
y2={end[1]}
stroke={ColorStyle.Black}
strokeWidth={isHovered ? sw : 0}
strokeLinecap="round"
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
)
})
return (
<>
{isBinding && (
<rect
className="tl-binding-indicator"
x={-32}
y={-32}
width={size[0] + 64}
height={size[1] + 64}
/>
)}
<rect x={0} y={0} width={size[0]} height={size[1]} fill="transparent" pointerEvents="all" />
<g pointerEvents="stroke">{paths}</g>
</>
)
}
renderIndicator(shape: GroupShape) {
const [width, height] = shape.size
const sw = 2
return (
<rect
x={sw / 2}
y={sw / 2}
rx={1}
ry={1}
width={Math.max(1, width - sw)}
height={Math.max(1, height - sw)}
/>
)
}
getBounds(shape: GroupShape) {
const bounds = Utils.getFromCache(this.boundsCache, shape, () => {
const [width, height] = shape.size
return {
minX: 0,
maxX: width,
minY: 0,
maxY: height,
width,
height,
}
})
return Utils.translateBounds(bounds, shape.point)
}
getRotatedBounds(shape: GroupShape) {
return Utils.getBoundsFromPoints(Utils.getRotatedCorners(this.getBounds(shape), shape.rotation))
}
getCenter(shape: GroupShape): number[] {
return Utils.getBoundsCenter(this.getBounds(shape))
}
getBindingPoint(
shape: GroupShape,
point: number[],
origin: number[],
direction: number[],
padding: number,
anywhere: boolean
) {
const bounds = this.getBounds(shape)
const expandedBounds = Utils.expandBounds(bounds, padding)
let bindingPoint: number[]
let distance: number
// The point must be inside of the expanded bounding box
if (!Utils.pointInBounds(point, expandedBounds)) return
// The point is inside of the shape, so we'll assume the user is
// indicating a specific point inside of the shape.
if (anywhere) {
if (Vec.dist(point, this.getCenter(shape)) < 12) {
bindingPoint = [0.5, 0.5]
} else {
bindingPoint = Vec.divV(Vec.sub(point, [expandedBounds.minX, expandedBounds.minY]), [
expandedBounds.width,
expandedBounds.height,
])
}
distance = 0
} else {
// Find furthest intersection between ray from
// origin through point and expanded bounds.
// TODO: Make this a ray vs rounded rect intersection
const intersection = Intersect.ray
.bounds(origin, direction, expandedBounds)
.filter((int) => int.didIntersect)
.map((int) => int.points[0])
.sort((a, b) => Vec.dist(b, origin) - Vec.dist(a, origin))[0]
// The anchor is a point between the handle and the intersection
const anchor = Vec.med(point, intersection)
// If we're close to the center, snap to the center
if (Vec.distanceToLineSegment(point, anchor, this.getCenter(shape)) < 12) {
bindingPoint = [0.5, 0.5]
} else {
// Or else calculate a normalized point
bindingPoint = Vec.divV(Vec.sub(anchor, [expandedBounds.minX, expandedBounds.minY]), [
expandedBounds.width,
expandedBounds.height,
])
}
if (Utils.pointInBounds(point, bounds)) {
distance = 16
} else {
// If the binding point was close to the shape's center, snap to the center
// Find the distance between the point and the real bounds of the shape
distance = Math.max(
16,
Utils.getBoundsSides(bounds)
.map((side) => Vec.distanceToLineSegment(side[1][0], side[1][1], point))
.sort((a, b) => a - b)[0]
)
}
}
return {
point: Vec.clampV(bindingPoint, 0, 1),
distance,
}
}
hitTest(shape: GroupShape, point: number[]) {
return Utils.pointInBounds(point, this.getBounds(shape))
}
hitTestBounds(shape: GroupShape, bounds: TLBounds) {
const rotatedCorners = Utils.getRotatedCorners(this.getBounds(shape), shape.rotation)
return (
rotatedCorners.every((point) => Utils.pointInBounds(point, bounds)) ||
Intersect.polyline.bounds(rotatedCorners, bounds).length > 0
)
}
transform(
shape: GroupShape,
bounds: TLBounds,
{ initialShape, transformOrigin, scaleX, scaleY }: TLTransformInfo<GroupShape>
) {
return {}
}
transformSingle(_shape: GroupShape, bounds: TLBounds) {
return {
size: Vec.round([bounds.width, bounds.height]),
point: Vec.round([bounds.minX, bounds.minY]),
}
}
}

View file

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

View file

@ -3,3 +3,4 @@ export * from './arrow'
export * from './rectangle' export * from './rectangle'
export * from './ellipse' export * from './ellipse'
export * from './text' export * from './text'
export * from './group'

View file

@ -1,8 +1,7 @@
import type { Data, TLDrawCommand } from '~types' import type { Data, TLDrawCommand } from '~types'
import { Utils } from '@tldraw/core' import { Utils } from '@tldraw/core'
export function createPage(data: Data): TLDrawCommand { export function createPage(data: Data, pageId = Utils.uniqueId()): TLDrawCommand {
const newId = Utils.uniqueId()
const { currentPageId } = data.appState const { currentPageId } = data.appState
return { return {
@ -13,27 +12,27 @@ export function createPage(data: Data): TLDrawCommand {
}, },
document: { document: {
pages: { pages: {
[newId]: undefined, [pageId]: undefined,
}, },
pageStates: { pageStates: {
[newId]: undefined, [pageId]: undefined,
}, },
}, },
}, },
after: { after: {
appState: { appState: {
currentPageId: newId, currentPageId: pageId,
}, },
document: { document: {
pages: { pages: {
[newId]: { id: newId, shapes: {}, bindings: {} }, [pageId]: { id: pageId, shapes: {}, bindings: {} },
}, },
pageStates: { pageStates: {
[newId]: { [pageId]: {
id: newId, id: pageId,
selectedIds: [], selectedIds: [],
camera: { point: [-window.innerWidth / 2, -window.innerHeight / 2], zoom: 1 }, camera: { point: [-window.innerWidth / 2, -window.innerHeight / 2], zoom: 1 },
currentParentId: newId, currentParentId: pageId,
editingId: undefined, editingId: undefined,
bindingId: undefined, bindingId: undefined,
hoveredId: undefined, hoveredId: undefined,

View file

@ -85,4 +85,14 @@ describe('Delete command', () => {
expect(Object.values(tlstate.page.bindings)[0]).toBe(undefined) expect(Object.values(tlstate.page.bindings)[0]).toBe(undefined)
expect(tlstate.getShape('arrow1').handles?.start.bindingId).toBe(undefined) expect(tlstate.getShape('arrow1').handles?.start.bindingId).toBe(undefined)
}) })
describe('when deleting grouped shapes', () => {
it('updates the group', () => {
tlstate
.loadDocument(mockDocument)
.group(['rect1', 'rect2'], 'newGroup')
.select('rect1')
.delete()
})
})
}) })

View file

@ -1,11 +1,13 @@
import { TLDR } from '~state/tldr' import { TLDR } from '~state/tldr'
import type { Data, TLDrawCommand, PagePartial } from '~types' import type { Data, TLDrawCommand, PagePartial, TLDrawShape, GroupShape } from '~types'
// - [ ] Update parents and possibly delete parents // - [ ] Update parents and possibly delete parents
export function deleteShapes(data: Data, ids: string[]): TLDrawCommand { export function deleteShapes(
const { currentPageId } = data.appState data: Data,
ids: string[],
pageId = data.appState.currentPageId
): TLDrawCommand {
const before: PagePartial = { const before: PagePartial = {
shapes: {}, shapes: {},
bindings: {}, bindings: {},
@ -16,13 +18,32 @@ export function deleteShapes(data: Data, ids: string[]): TLDrawCommand {
bindings: {}, bindings: {},
} }
const parentsToUpdate: GroupShape[] = []
const deletedIds = [...ids]
// These are the shapes we're definitely going to delete // These are the shapes we're definitely going to delete
ids.forEach((id) => { ids.forEach((id) => {
before.shapes[id] = TLDR.getShape(data, id, currentPageId) const shape = TLDR.getShape(data, id, pageId)
before.shapes[id] = shape
after.shapes[id] = undefined after.shapes[id] = undefined
if (shape.parentId !== pageId) {
parentsToUpdate.push(TLDR.getShape(data, shape.parentId, pageId))
}
}) })
const page = TLDR.getPage(data, currentPageId) parentsToUpdate.forEach((parent) => {
if (ids.includes(parent.id)) return
deletedIds.push(parent.id)
before.shapes[parent.id] = { children: parent.children }
after.shapes[parent.id] = { children: parent.children.filter((id) => !ids.includes(id)) }
})
// Recursively check for empty parents?
const page = TLDR.getPage(data, pageId)
// We also need to delete bindings that reference the deleted shapes // We also need to delete bindings that reference the deleted shapes
Object.values(page.bindings).forEach((binding) => { Object.values(page.bindings).forEach((binding) => {
@ -34,7 +55,7 @@ export function deleteShapes(data: Data, ids: string[]): TLDrawCommand {
after.bindings[binding.id] = undefined after.bindings[binding.id] = undefined
// Let's also look each the bound shape... // Let's also look each the bound shape...
const shape = TLDR.getShape(data, id, currentPageId) const shape = TLDR.getShape(data, id, pageId)
// If the bound shape has a handle that references the deleted binding... // If the bound shape has a handle that references the deleted binding...
if (shape.handles) { if (shape.handles) {
@ -52,7 +73,7 @@ export function deleteShapes(data: Data, ids: string[]): TLDrawCommand {
// Unless we're currently deleting the shape, remove the // Unless we're currently deleting the shape, remove the
// binding reference from the after patch // binding reference from the after patch
if (!ids.includes(id)) { if (!deletedIds.includes(id)) {
after.shapes[id] = { after.shapes[id] = {
...after.shapes[id], ...after.shapes[id],
handles: { ...after.shapes[id]?.handles, [handle.id]: { bindingId: undefined } }, handles: { ...after.shapes[id]?.handles, [handle.id]: { bindingId: undefined } },
@ -69,20 +90,20 @@ export function deleteShapes(data: Data, ids: string[]): TLDrawCommand {
before: { before: {
document: { document: {
pages: { pages: {
[currentPageId]: before, [pageId]: before,
}, },
pageStates: { pageStates: {
[currentPageId]: { selectedIds: TLDR.getSelectedIds(data, currentPageId) }, [pageId]: { selectedIds: TLDR.getSelectedIds(data, pageId) },
}, },
}, },
}, },
after: { after: {
document: { document: {
pages: { pages: {
[currentPageId]: after, [pageId]: after,
}, },
pageStates: { pageStates: {
[currentPageId]: { selectedIds: [] }, [pageId]: { selectedIds: [] },
}, },
}, },
}, },

View file

@ -10,7 +10,7 @@ export function duplicatePage(data: Data, pageId: string): TLDrawCommand {
const nextPage = { const nextPage = {
...page, ...page,
id: newId, id: newId,
...Object.fromEntries( shapes: Object.fromEntries(
Object.entries(page.shapes).map(([id, shape]) => { Object.entries(page.shapes).map(([id, shape]) => {
return [ return [
id, id,

View file

@ -1,5 +1,7 @@
/* 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'
describe('Duplicate command', () => { describe('Duplicate command', () => {
const tlstate = new TLDrawState() const tlstate = new TLDrawState()
@ -21,4 +23,101 @@ describe('Duplicate command', () => {
expect(Object.keys(tlstate.getPage().shapes).length).toBe(4) expect(Object.keys(tlstate.getPage().shapes).length).toBe(4)
}) })
describe('when duplicating a bound shape', () => {
it('removed the binding when the target is not selected', () => {
tlstate.resetDocument().createShapes(
{
id: 'target1',
type: TLDrawShapeType.Rectangle,
point: [0, 0],
size: [100, 100],
},
{
type: TLDrawShapeType.Arrow,
id: 'arrow1',
point: [200, 200],
}
)
const beforeShapeIds = Object.keys(tlstate.page.shapes)
tlstate
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([50, 50])
.completeSession()
const beforeArrow = tlstate.getShape<ArrowShape>('arrow1')
expect(beforeArrow.handles.start.bindingId).toBeTruthy()
tlstate.select('arrow1').duplicate()
const afterShapeIds = Object.keys(tlstate.page.shapes)
const newShapeIds = afterShapeIds.filter((id) => !beforeShapeIds.includes(id))
expect(newShapeIds.length).toBe(1)
const duplicatedArrow = tlstate.getShape<ArrowShape>(newShapeIds[0])
expect(duplicatedArrow.handles.start.bindingId).toBeUndefined()
})
it('duplicates the binding when the target is selected', () => {
tlstate.resetDocument().createShapes(
{
id: 'target1',
type: TLDrawShapeType.Rectangle,
point: [0, 0],
size: [100, 100],
},
{
type: TLDrawShapeType.Arrow,
id: 'arrow1',
point: [200, 200],
}
)
const beforeShapeIds = Object.keys(tlstate.page.shapes)
tlstate
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([50, 50])
.completeSession()
const oldBindingId = tlstate.getShape<ArrowShape>('arrow1').handles.start.bindingId
expect(oldBindingId).toBeTruthy()
tlstate.select('arrow1', 'target1').duplicate()
const afterShapeIds = Object.keys(tlstate.page.shapes)
const newShapeIds = afterShapeIds.filter((id) => !beforeShapeIds.includes(id))
expect(newShapeIds.length).toBe(2)
const newBindingId = tlstate.getShape<ArrowShape>(newShapeIds[0]).handles.start.bindingId
expect(newBindingId).toBeTruthy()
tlstate.undo()
expect(tlstate.getBinding(newBindingId!)).toBeUndefined()
expect(tlstate.getShape<ArrowShape>(newShapeIds[0])).toBeUndefined()
tlstate.redo()
expect(tlstate.getBinding(newBindingId!)).toBeTruthy()
expect(tlstate.getShape<ArrowShape>(newShapeIds[0]).handles.start.bindingId).toBe(
newBindingId
)
})
it.todo('updates the arrow when bound on both sides')
it.todo('snaps the bend to zero when dragging the bend handle toward the center')
})
}) })

View file

@ -1,35 +1,86 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Utils, Vec } from '@tldraw/core' import { Utils, Vec } from '@tldraw/core'
import { TLDR } from '~state/tldr' import { TLDR } from '~state/tldr'
import type { Data, TLDrawCommand } from '~types' import type { Data, PagePartial, TLDrawCommand } from '~types'
export function duplicate(data: Data, ids: string[]): TLDrawCommand { export function duplicate(data: Data, ids: string[]): TLDrawCommand {
const { currentPageId } = data.appState const { currentPageId } = data.appState
const delta = Vec.div([16, 16], TLDR.getCamera(data, currentPageId).zoom) const delta = Vec.div([16, 16], TLDR.getCamera(data, currentPageId).zoom)
const after = Object.fromEntries( const before: PagePartial = {
TLDR.getSelectedIds(data, currentPageId) shapes: {},
.map((id) => TLDR.getShape(data, id, currentPageId)) bindings: {},
.map((shape) => { }
const id = Utils.uniqueId()
return [ const after: PagePartial = {
id, shapes: {},
{ bindings: {},
...Utils.deepClone(shape), }
id,
point: Vec.round(Vec.add(shape.point, delta)), const shapes = TLDR.getSelectedIds(data, currentPageId).map((id) =>
}, TLDR.getShape(data, id, currentPageId)
]
})
) )
const before = Object.fromEntries(Object.keys(after).map((id) => [id, undefined])) const cloneMap: Record<string, string> = {}
shapes.forEach((shape) => {
const id = Utils.uniqueId()
before.shapes[id] = undefined
after.shapes[id] = {
...Utils.deepClone(shape),
id,
point: Vec.round(Vec.add(shape.point, delta)),
}
cloneMap[shape.id] = id
})
const page = TLDR.getPage(data, currentPageId)
Object.values(page.bindings).forEach((binding) => {
if (ids.includes(binding.fromId)) {
if (ids.includes(binding.toId)) {
// If the binding is between two duplicating shapes then
// duplicate the binding, too
const duplicatedBindingId = Utils.uniqueId()
const duplicatedBinding = {
...Utils.deepClone(binding),
id: duplicatedBindingId,
fromId: cloneMap[binding.fromId],
toId: cloneMap[binding.toId],
}
before.bindings[duplicatedBindingId] = undefined
after.bindings[duplicatedBindingId] = duplicatedBinding
// Change the duplicated shape's handle so that it reference
// the duplicated binding
const boundShape = after.shapes[duplicatedBinding.fromId]
Object.values(boundShape!.handles!).forEach((handle) => {
if (handle!.bindingId === binding.id) {
handle!.bindingId = duplicatedBindingId
}
})
} else {
// If only the fromId is selected, delete the binding on
// the duplicated shape's handles
const boundShape = after.shapes[cloneMap[binding.fromId]]
Object.values(boundShape!.handles!).forEach((handle) => {
if (handle!.bindingId === binding.id) {
handle!.bindingId = undefined
}
})
}
}
})
return { return {
id: 'duplicate', id: 'duplicate',
before: { before: {
document: { document: {
pages: { pages: {
[currentPageId]: { shapes: before }, [currentPageId]: before,
}, },
pageStates: { pageStates: {
[currentPageId]: { selectedIds: ids }, [currentPageId]: { selectedIds: ids },
@ -39,10 +90,10 @@ export function duplicate(data: Data, ids: string[]): TLDrawCommand {
after: { after: {
document: { document: {
pages: { pages: {
[currentPageId]: { shapes: after }, [currentPageId]: after,
}, },
pageStates: { pageStates: {
[currentPageId]: { selectedIds: Object.keys(after) }, [currentPageId]: { selectedIds: Object.keys(after.shapes) },
}, },
}, },
}, },

View file

@ -0,0 +1,215 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { TLDrawState } from '~state'
import { mockDocument } from '~test'
import type { GroupShape, TLDrawShape } from '~types'
describe('Group command', () => {
const tlstate = new TLDrawState()
it('does, undoes and redoes command', () => {
tlstate.loadDocument(mockDocument)
tlstate.group(['rect1', 'rect2'], 'newGroup')
expect(tlstate.getShape<GroupShape>('newGroup')).toBeTruthy()
tlstate.undo()
expect(tlstate.getShape<GroupShape>('newGroup')).toBeUndefined()
tlstate.redo()
expect(tlstate.getShape<GroupShape>('newGroup')).toBeTruthy()
})
describe('when less than two shapes are selected', () => {
it('does nothing', () => {
tlstate.loadDocument(mockDocument)
tlstate.deselectAll()
// @ts-ignore
const stackLength = tlstate.stack.length
tlstate.group([], 'newGroup')
expect(tlstate.getShape<GroupShape>('newGroup')).toBeUndefined()
// @ts-ignore
expect(tlstate.stack.length).toBe(stackLength)
tlstate.group(['rect1'], 'newGroup')
expect(tlstate.getShape<GroupShape>('newGroup')).toBeUndefined()
// @ts-ignore
expect(tlstate.stack.length).toBe(stackLength)
})
})
describe('when grouping shapes on the page', () => {
/*
When the parent is a page, the group is created as a child of the page
and the shapes are reparented to the group. The group's child
index should be the minimum child index of the selected shapes.
*/
it('creates a group with the correct props', () => {
tlstate.loadDocument(mockDocument)
tlstate.updateShapes(
{
id: 'rect1',
point: [300, 300],
childIndex: 3,
},
{
id: 'rect2',
point: [20, 20],
childIndex: 4,
}
)
tlstate.group(['rect1', 'rect2'], 'newGroup')
const group = tlstate.getShape<GroupShape>('newGroup')
expect(group).toBeTruthy()
expect(group.parentId).toBe('page1')
expect(group.childIndex).toBe(3)
expect(group.point).toStrictEqual([20, 20])
expect(group.children).toStrictEqual(['rect1', 'rect2'])
})
it('reparents the grouped shapes', () => {
tlstate.loadDocument(mockDocument)
tlstate.updateShapes(
{
id: 'rect1',
childIndex: 2.5,
},
{
id: 'rect2',
childIndex: 4.7,
}
)
tlstate.group(['rect1', 'rect2'], 'newGroup')
let rect1: TLDrawShape
let rect2: TLDrawShape
rect1 = tlstate.getShape('rect1')
rect2 = tlstate.getShape('rect2')
// Reparents the shapes
expect(rect1.parentId).toBe('newGroup')
expect(rect2.parentId).toBe('newGroup')
// Sets and preserves the order of the grouped shapes
expect(rect1.childIndex).toBe(1)
expect(rect2.childIndex).toBe(2)
tlstate.undo()
rect1 = tlstate.getShape('rect1')
rect2 = tlstate.getShape('rect2')
// Restores the shapes' parentIds
expect(rect1.parentId).toBe('page1')
expect(rect2.parentId).toBe('page1')
// Restores the shapes' childIndexs
expect(rect1.childIndex).toBe(2.5)
expect(rect2.childIndex).toBe(4.7)
})
})
describe('when grouping shapes that are the child of another group', () => {
/*
When the selected shapes are the children of another group, and so
long as the children do not represent ALL of the group's children,
then a new group should be created that is a child of the parent group.
*/
it.todo('does not group shapes if shapes are all the groups children')
/*
If the selected shapes represent ALL of the children of the a
group, then no effect should occur.
*/
it.todo('creates the new group as a child of the parent group')
/*
The new group should be a child of the parent group.
*/
it('moves the selected layers to the new group', () => {
/*
The new group should have the selected children. The old parents
should no longer have the selected shapes among their children.
All of the selected shapes should be assigned the new parent.
*/
})
it.todo('deletes any groups that no longer have children')
/*
If the selected groups included the children of another group, then
that group should be destroyed. Other rules around deleted
shapes should here apply: bindings connected to the group
should be deleted, etc.
*/
it.todo('preserves the child index order')
/*
The layers should be in the same order as the original layers as
they would have appeared on a layers tree (lowest child index
first, parent inclusive).
*/
})
describe('when grouping shapes with different parents', () => {
/*
When two shapes with different parents are grouped, the new parent
group should have the same parent as the shape nearest to the top
of the layer tree. The new group's child index should be that
shape's child index.
For example, if the shapes are grouped in the following order:
- page1
- group1
- arrow1
- rect1 (x)
- arrow2
- rect2 (x)
The new parent group should have the same parent as rect1.
- page1
- group1
- arrow1
- group2
- rect1 (x)
- rect2 (x)
- arrow2
If, instead, the shapes are grouped in the following order:
- page1
- arrow1
- rect1 (x)
- group1
- arrow2
- rect2 (x)
Then the new parent group should have the same parent as
rect2.
- page1
- arrow1
- group2 (x)
- rect1
- rect2
- group1
- arrow2
We can find this by searching the tree for the nearest shape to
the top.
*/
it.todo('creates a group in the correct place')
/*
The new group should be a child of the nearest shape to the top
of the tree.
*/
})
})

View file

@ -0,0 +1,200 @@
import { TLDrawBinding, TLDrawShape, TLDrawShapeType } from '~types'
import { Utils } from '@tldraw/core'
import type { Data, TLDrawCommand } from '~types'
import { TLDR } from '~state/tldr'
import type { Patch } from 'rko'
export function group(
data: Data,
ids: string[],
groupId = Utils.uniqueId()
): TLDrawCommand | undefined {
const beforeShapes: Record<string, Patch<TLDrawShape | undefined>> = {}
const afterShapes: Record<string, Patch<TLDrawShape | undefined>> = {}
const beforeBindings: Record<string, Patch<TLDrawBinding | undefined>> = {}
const afterBindings: Record<string, Patch<TLDrawBinding | undefined>> = {}
const { currentPageId } = data.appState
const initialShapes = ids.map((id) => TLDR.getShape(data, id, currentPageId))
// 1. Can we create this group?
// Do the shapes have the same parent?
if (initialShapes.every((shape) => shape.parentId === initialShapes[0].parentId)) {
// Is the common parent a shape (not the page)?
if (initialShapes[0].parentId !== currentPageId) {
const commonParent = TLDR.getShape(data, initialShapes[0].parentId, currentPageId)
// Are all of the common parent's shapes selected?
if (commonParent.children?.length === ids.length) {
// Don't create a group if that group would be the same as the
// existing group.
return
}
}
}
// A flattened array of shapes from the page
const flattenedShapes = TLDR.flattenPage(data, currentPageId)
// A map of shapes to their index in flattendShapes
const shapeIndexMap = Object.fromEntries(
initialShapes.map((shape) => [shape.id, flattenedShapes.indexOf(shape)])
)
// An array of shapes in order by their index in flattendShapes
const sortedShapes = initialShapes.sort((a, b) => shapeIndexMap[a.id] - shapeIndexMap[b.id])
// The parentId comes from the first shape in flattendShapes
const groupParentId = sortedShapes[0].parentId
// Likewise for the child index
const groupChildIndex = sortedShapes[0].childIndex
// The shape's point is the min point of its childrens' common bounds
const groupBounds = Utils.getCommonBounds(initialShapes.map((shape) => TLDR.getBounds(shape)))
// Create the group
beforeShapes[groupId] = undefined
afterShapes[groupId] = TLDR.getShapeUtils({ type: TLDrawShapeType.Group } as TLDrawShape).create({
id: groupId,
childIndex: groupChildIndex,
parentId: groupParentId,
point: [groupBounds.minX, groupBounds.minY],
size: [groupBounds.width, groupBounds.height],
children: sortedShapes.map((shape) => shape.id),
})
// Collect parents (other groups) that will have lost children
const otherEffectedGroups: TLDrawShape[] = []
// Reparent shapes to the new group
sortedShapes.forEach((shape, index) => {
// If the shape is part of a different group, mark the parent shape for cleanup
if (shape.parentId !== currentPageId) {
const parentShape = TLDR.getShape(data, shape.parentId, currentPageId)
otherEffectedGroups.push(parentShape)
}
beforeShapes[shape.id] = {
...beforeShapes[shape.id],
parentId: shape.parentId,
childIndex: shape.childIndex,
}
afterShapes[shape.id] = {
...afterShapes[shape.id],
parentId: groupId,
childIndex: index + 1,
}
})
// These are the ids of deleted groups
const deletedShapeIds: string[] = []
// Clean up effected parents
while (otherEffectedGroups.length > 0) {
const shape = otherEffectedGroups.pop()
if (!shape) break
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const nextChildren = (beforeShapes[shape.id]?.children || shape.children)!.filter(
(childId) => childId && !(ids.includes(childId) || deletedShapeIds.includes(childId))
)
// If the parent has no children, remove it
if (nextChildren.length === 0) {
beforeShapes[shape.id] = shape
afterShapes[shape.id] = undefined
// And if that parent is part of a different group, mark it for cleanup
if (shape.parentId !== currentPageId) {
deletedShapeIds.push(shape.id)
otherEffectedGroups.push(TLDR.getShape(data, shape.parentId, currentPageId))
}
} else {
beforeShapes[shape.id] = {
...beforeShapes[shape.id],
children: shape.children,
}
afterShapes[shape.id] = {
...afterShapes[shape.id],
children: nextChildren,
}
}
}
// TODO: This code is copied from delete.command, create a shared helper
const page = TLDR.getPage(data, currentPageId)
// We also need to delete bindings that reference the deleted shapes
Object.values(page.bindings).forEach((binding) => {
for (const id of [binding.toId, binding.fromId]) {
// If the binding references a deleted shape...
if (afterShapes[id] === undefined) {
// Delete this binding
beforeBindings[binding.id] = binding
afterBindings[binding.id] = undefined
// Let's also look each the bound shape...
const shape = TLDR.getShape(data, id, currentPageId)
// If the bound shape has a handle that references the deleted binding...
if (shape.handles) {
Object.values(shape.handles)
.filter((handle) => handle.bindingId === binding.id)
.forEach((handle) => {
// Save the binding reference in the before patch
beforeShapes[id] = {
...beforeShapes[id],
handles: {
...beforeShapes[id]?.handles,
[handle.id]: { bindingId: binding.id },
},
}
// Unless we're currently deleting the shape, remove the
// binding reference from the after patch
if (!deletedShapeIds.includes(id)) {
afterShapes[id] = {
...afterShapes[id],
handles: {
...afterShapes[id]?.handles,
[handle.id]: { bindingId: undefined },
},
}
}
})
}
}
}
})
return {
id: 'group_shapes',
before: {
document: {
pages: {
[data.appState.currentPageId]: {
shapes: beforeShapes,
bindings: beforeBindings,
},
},
},
},
after: {
document: {
pages: {
[data.appState.currentPageId]: {
shapes: afterShapes,
bindings: beforeBindings,
},
},
},
},
}
}

View file

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

View file

@ -1,19 +1,20 @@
export * from './align' export * from './align'
export * from './change-page'
export * from './create-page'
export * from './create' export * from './create'
export * from './delete-page'
export * from './delete' export * from './delete'
export * from './distribute' export * from './distribute'
export * from './duplicate-page'
export * from './duplicate' export * from './duplicate'
export * from './flip'
export * from './move' export * from './move'
export * from './rename-page'
export * from './rotate' export * from './rotate'
export * from './stretch' export * from './stretch'
export * from './style' export * from './style'
export * from './toggle-decoration'
export * from './toggle' export * from './toggle'
export * from './translate' export * from './translate'
export * from './flip'
export * from './toggle-decoration'
export * from './create-page'
export * from './delete-page'
export * from './rename-page'
export * from './duplicate-page'
export * from './change-page'
export * from './update' export * from './update'
export * from './group'

View file

@ -26,4 +26,7 @@ describe('Translate command', () => {
tlstate.nudge([1, 2], true) tlstate.nudge([1, 2], true)
expect(tlstate.getShape('rect2').point).toEqual([110, 120]) expect(tlstate.getShape('rect2').point).toEqual([110, 120])
}) })
it.todo('deleted bindings if nudging shape is bound to other shapes')
// When nudging an arrow shape, delete its bindings
}) })

View file

@ -3,6 +3,8 @@ import type { Data, TLDrawCommand, PagePartial } from '~types'
import { TLDR } from '~state/tldr' import { TLDR } from '~state/tldr'
export function translate(data: Data, ids: string[], delta: number[]): TLDrawCommand { export function translate(data: Data, ids: string[], delta: number[]): TLDrawCommand {
const { currentPageId } = data.appState
const before: PagePartial = { const before: PagePartial = {
shapes: {}, shapes: {},
bindings: {}, bindings: {},
@ -19,13 +21,15 @@ export function translate(data: Data, ids: string[], delta: number[]): TLDrawCom
(shape) => ({ (shape) => ({
point: Vec.round(Vec.add(shape.point, delta)), point: Vec.round(Vec.add(shape.point, delta)),
}), }),
data.appState.currentPageId currentPageId
) )
before.shapes = change.before before.shapes = change.before
after.shapes = change.after after.shapes = change.after
const bindingsToDelete = TLDR.getRelatedBindings(data, ids, data.appState.currentPageId) const bindingsToDelete = TLDR.getBindings(data, currentPageId).filter((binding) =>
ids.includes(binding.fromId)
)
bindingsToDelete.forEach((binding) => { bindingsToDelete.forEach((binding) => {
before.bindings[binding.id] = binding before.bindings[binding.id] = binding

View file

@ -851,6 +851,26 @@ export class TLDR {
} }
} }
/* -------------------------------------------------- */
/* Groups */
/* -------------------------------------------------- */
static flattenShape = (data: Data, shape: TLDrawShape): TLDrawShape[] => {
return [
shape,
...(shape.children ?? [])
.map((childId) => TLDR.getShape(data, childId, data.appState.currentPageId))
.sort((a, b) => a.childIndex - b.childIndex)
.flatMap((shape) => TLDR.flattenShape(data, shape)),
]
}
static flattenPage = (data: Data, pageId: string): TLDrawShape[] => {
return Object.values(data.document.pages[pageId].shapes)
.sort((a, b) => a.childIndex - b.childIndex)
.reduce<TLDrawShape[]>((acc, shape) => [...acc, ...TLDR.flattenShape(data, shape)], [])
}
/* -------------------------------------------------- */ /* -------------------------------------------------- */
/* Assertions */ /* Assertions */
/* -------------------------------------------------- */ /* -------------------------------------------------- */

File diff suppressed because it is too large Load diff

View file

@ -138,6 +138,7 @@ export enum TLDrawShapeType {
Draw = 'draw', Draw = 'draw',
Arrow = 'arrow', Arrow = 'arrow',
Text = 'text', Text = 'text',
Group = 'group',
} }
export enum Decoration { export enum Decoration {
@ -183,7 +184,19 @@ export interface TextShape extends TLDrawBaseShape {
text: string text: string
} }
export type TLDrawShape = RectangleShape | EllipseShape | DrawShape | ArrowShape | TextShape export interface GroupShape extends TLDrawBaseShape {
type: TLDrawShapeType.Group
size: number[]
children: string[]
}
export type TLDrawShape =
| RectangleShape
| EllipseShape
| DrawShape
| ArrowShape
| TextShape
| GroupShape
export abstract class TLDrawShapeUtil<T extends TLDrawShape> extends TLShapeUtil<T> { export abstract class TLDrawShapeUtil<T extends TLDrawShape> extends TLShapeUtil<T> {
abstract toolType: TLDrawToolType abstract toolType: TLDrawToolType

File diff suppressed because one or more lines are too long