Starts on groups, fixes duplicate bugs with bindings
This commit is contained in:
parent
e738018448
commit
a1a213f9b4
31 changed files with 23054 additions and 495 deletions
|
@ -4,10 +4,18 @@ const esbuild = require('esbuild')
|
|||
|
||||
async function main() {
|
||||
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({
|
||||
entryPoints: ['./src/index.ts'],
|
||||
|
|
|
@ -12,6 +12,8 @@ export function EditingTextShape({
|
|||
utils,
|
||||
isEditing,
|
||||
isBinding,
|
||||
isHovered,
|
||||
isSelected,
|
||||
isCurrentParent,
|
||||
meta,
|
||||
}: EditingShapeProps<TLShape>) {
|
||||
|
@ -24,6 +26,8 @@ export function EditingTextShape({
|
|||
return utils.render(shape, {
|
||||
ref,
|
||||
isEditing,
|
||||
isHovered,
|
||||
isSelected,
|
||||
isCurrentParent,
|
||||
isBinding,
|
||||
onTextChange,
|
||||
|
|
|
@ -12,18 +12,25 @@ export const RenderedShape = React.memo(
|
|||
utils,
|
||||
isEditing,
|
||||
isBinding,
|
||||
isHovered,
|
||||
isSelected,
|
||||
isCurrentParent,
|
||||
meta,
|
||||
}: RenderedShapeProps<TLShape>) {
|
||||
return utils.render(shape, {
|
||||
isEditing,
|
||||
isBinding,
|
||||
isHovered,
|
||||
isSelected,
|
||||
isCurrentParent,
|
||||
meta,
|
||||
})
|
||||
},
|
||||
(prev, next) => {
|
||||
// If these have changed, then definitely render
|
||||
if (
|
||||
prev.isHovered !== next.isHovered ||
|
||||
prev.isSelected !== next.isSelected ||
|
||||
prev.isEditing !== next.isEditing ||
|
||||
prev.isBinding !== next.isBinding ||
|
||||
prev.meta !== next.meta ||
|
||||
|
@ -32,6 +39,8 @@ export const RenderedShape = React.memo(
|
|||
return false
|
||||
}
|
||||
|
||||
// If not, and if the shape has changed, ask the shape's class
|
||||
// whether it should render
|
||||
if (next.shape !== prev.shape) {
|
||||
return !next.utils.shouldRender(next.shape, prev.shape)
|
||||
}
|
||||
|
|
|
@ -8,6 +8,8 @@ export const ShapeNode = React.memo(
|
|||
children,
|
||||
isEditing,
|
||||
isBinding,
|
||||
isHovered,
|
||||
isSelected,
|
||||
isCurrentParent,
|
||||
meta,
|
||||
}: IShapeTreeNode<M>) => {
|
||||
|
@ -17,6 +19,8 @@ export const ShapeNode = React.memo(
|
|||
shape={shape}
|
||||
isEditing={isEditing}
|
||||
isBinding={isBinding}
|
||||
isHovered={isHovered}
|
||||
isSelected={isSelected}
|
||||
isCurrentParent={isCurrentParent}
|
||||
meta={meta}
|
||||
/>
|
||||
|
|
|
@ -9,6 +9,8 @@ describe('shape', () => {
|
|||
shape={mockUtils.box.create({})}
|
||||
isEditing={false}
|
||||
isBinding={false}
|
||||
isHovered={false}
|
||||
isSelected={false}
|
||||
isCurrentParent={false}
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -9,6 +9,8 @@ export const Shape = React.memo(
|
|||
shape,
|
||||
isEditing,
|
||||
isBinding,
|
||||
isHovered,
|
||||
isSelected,
|
||||
isCurrentParent,
|
||||
meta,
|
||||
}: IShapeTreeNode<M>) => {
|
||||
|
@ -33,6 +35,8 @@ export const Shape = React.memo(
|
|||
isBinding={false}
|
||||
isCurrentParent={false}
|
||||
isEditing={true}
|
||||
isHovered={isHovered}
|
||||
isSelected={isSelected}
|
||||
utils={utils}
|
||||
meta={meta}
|
||||
/>
|
||||
|
@ -43,6 +47,8 @@ export const Shape = React.memo(
|
|||
isBinding={isBinding}
|
||||
isCurrentParent={isCurrentParent}
|
||||
isEditing={isEditing}
|
||||
isHovered={isHovered}
|
||||
isSelected={isSelected}
|
||||
meta={meta}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -14,11 +14,11 @@ function addToShapeTree<T extends TLShape, M extends Record<string, unknown>>(
|
|||
shape: TLShape,
|
||||
branch: IShapeTreeNode<M>[],
|
||||
shapes: TLPage<T, TLBinding>['shapes'],
|
||||
selectedIds: string[],
|
||||
pageState: {
|
||||
bindingTargetId?: string
|
||||
bindingId?: string
|
||||
hoveredId?: string
|
||||
selectedIds: string[]
|
||||
currentParentId?: string
|
||||
editingId?: string
|
||||
editingBindingId?: string
|
||||
|
@ -29,6 +29,11 @@ function addToShapeTree<T extends TLShape, M extends Record<string, unknown>>(
|
|||
shape,
|
||||
isCurrentParent: pageState.currentParentId === 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,
|
||||
meta,
|
||||
}
|
||||
|
@ -42,7 +47,7 @@ function addToShapeTree<T extends TLShape, M extends Record<string, unknown>>(
|
|||
.sort((a, b) => a.childIndex - b.childIndex)
|
||||
.forEach((childShape) =>
|
||||
// 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
|
||||
|
||||
// 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)
|
||||
|
||||
return (
|
||||
// TODO: Some shapes should always render (lines, rays)
|
||||
Utils.boundsContain(viewport, shapeBounds) || Utils.boundsCollide(viewport, shapeBounds)
|
||||
)
|
||||
return Utils.boundsContain(viewport, shapeBounds) || Utils.boundsCollide(viewport, shapeBounds)
|
||||
})
|
||||
|
||||
// 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
|
||||
.sort((a, b) => a.childIndex - b.childIndex)
|
||||
.forEach((shape) =>
|
||||
addToShapeTree(shape, tree, page.shapes, selectedIds, { ...pageState, bindingTargetId }, meta)
|
||||
addToShapeTree(shape, tree, page.shapes, { ...pageState, bindingTargetId }, meta)
|
||||
)
|
||||
|
||||
return tree
|
||||
|
|
|
@ -58,6 +58,8 @@ export interface TLRenderInfo<M = any, T extends SVGElement | HTMLElement = any>
|
|||
ref?: React.RefObject<T>
|
||||
isEditing: boolean
|
||||
isBinding: boolean
|
||||
isHovered: boolean
|
||||
isSelected: boolean
|
||||
isCurrentParent: boolean
|
||||
onTextChange?: TLCallbacks['onTextChange']
|
||||
onTextBlur?: TLCallbacks['onTextBlur']
|
||||
|
@ -262,7 +264,7 @@ export abstract class TLShapeUtil<T extends TLShape> {
|
|||
|
||||
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
|
||||
|
||||
|
@ -374,6 +376,8 @@ export interface IShapeTreeNode<M extends Record<string, unknown>> {
|
|||
children?: IShapeTreeNode<M>[]
|
||||
isEditing: boolean
|
||||
isBinding: boolean
|
||||
isHovered: boolean
|
||||
isSelected: boolean
|
||||
isCurrentParent: boolean
|
||||
meta?: M
|
||||
}
|
||||
|
|
|
@ -4,10 +4,18 @@ const esbuild = require('esbuild')
|
|||
|
||||
async function main() {
|
||||
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({
|
||||
entryPoints: ['./src/index.ts'],
|
||||
|
|
|
@ -106,8 +106,9 @@ export function useKeyboardShortcuts(tlstate: TLDrawState) {
|
|||
|
||||
// Duplicate
|
||||
|
||||
useHotkeys('ctrl+d,command+d', () => {
|
||||
useHotkeys('ctrl+d,command+d', (e) => {
|
||||
tlstate.duplicate()
|
||||
e.preventDefault()
|
||||
})
|
||||
|
||||
// Flip
|
||||
|
@ -182,6 +183,18 @@ export function useKeyboardShortcuts(tlstate: TLDrawState) {
|
|||
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
|
||||
|
||||
useHotkeys('[', () => {
|
||||
|
@ -201,7 +214,7 @@ export function useKeyboardShortcuts(tlstate: TLDrawState) {
|
|||
})
|
||||
|
||||
useHotkeys('command+shift+backspace', (e) => {
|
||||
tlstate.reset()
|
||||
tlstate.resetDocument()
|
||||
e.preventDefault()
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
||||
export const tldrawShapeUtils: TLDrawShapeUtils = {
|
||||
|
@ -7,6 +7,7 @@ export const tldrawShapeUtils: TLDrawShapeUtils = {
|
|||
[TLDrawShapeType.Draw]: new Draw(),
|
||||
[TLDrawShapeType.Arrow]: new Arrow(),
|
||||
[TLDrawShapeType.Text]: new Text(),
|
||||
[TLDrawShapeType.Group]: new Group(),
|
||||
}
|
||||
|
||||
export type ShapeByType<T extends keyof TLDrawShapeUtils> = TLDrawShapeUtils[T]
|
||||
|
|
7
packages/tldraw/src/shape/shapes/group/group.spec.tsx
Normal file
7
packages/tldraw/src/shape/shapes/group/group.spec.tsx
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { Group } from './group'
|
||||
|
||||
describe('Group shape', () => {
|
||||
it('Creates an instance', () => {
|
||||
new Group()
|
||||
})
|
||||
})
|
240
packages/tldraw/src/shape/shapes/group/group.tsx
Normal file
240
packages/tldraw/src/shape/shapes/group/group.tsx
Normal 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]),
|
||||
}
|
||||
}
|
||||
}
|
1
packages/tldraw/src/shape/shapes/group/index.ts
Normal file
1
packages/tldraw/src/shape/shapes/group/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './group'
|
|
@ -3,3 +3,4 @@ export * from './arrow'
|
|||
export * from './rectangle'
|
||||
export * from './ellipse'
|
||||
export * from './text'
|
||||
export * from './group'
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import type { Data, TLDrawCommand } from '~types'
|
||||
import { Utils } from '@tldraw/core'
|
||||
|
||||
export function createPage(data: Data): TLDrawCommand {
|
||||
const newId = Utils.uniqueId()
|
||||
export function createPage(data: Data, pageId = Utils.uniqueId()): TLDrawCommand {
|
||||
const { currentPageId } = data.appState
|
||||
|
||||
return {
|
||||
|
@ -13,27 +12,27 @@ export function createPage(data: Data): TLDrawCommand {
|
|||
},
|
||||
document: {
|
||||
pages: {
|
||||
[newId]: undefined,
|
||||
[pageId]: undefined,
|
||||
},
|
||||
pageStates: {
|
||||
[newId]: undefined,
|
||||
[pageId]: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
appState: {
|
||||
currentPageId: newId,
|
||||
currentPageId: pageId,
|
||||
},
|
||||
document: {
|
||||
pages: {
|
||||
[newId]: { id: newId, shapes: {}, bindings: {} },
|
||||
[pageId]: { id: pageId, shapes: {}, bindings: {} },
|
||||
},
|
||||
pageStates: {
|
||||
[newId]: {
|
||||
id: newId,
|
||||
[pageId]: {
|
||||
id: pageId,
|
||||
selectedIds: [],
|
||||
camera: { point: [-window.innerWidth / 2, -window.innerHeight / 2], zoom: 1 },
|
||||
currentParentId: newId,
|
||||
currentParentId: pageId,
|
||||
editingId: undefined,
|
||||
bindingId: undefined,
|
||||
hoveredId: undefined,
|
||||
|
|
|
@ -85,4 +85,14 @@ describe('Delete command', () => {
|
|||
expect(Object.values(tlstate.page.bindings)[0]).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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
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
|
||||
|
||||
export function deleteShapes(data: Data, ids: string[]): TLDrawCommand {
|
||||
const { currentPageId } = data.appState
|
||||
|
||||
export function deleteShapes(
|
||||
data: Data,
|
||||
ids: string[],
|
||||
pageId = data.appState.currentPageId
|
||||
): TLDrawCommand {
|
||||
const before: PagePartial = {
|
||||
shapes: {},
|
||||
bindings: {},
|
||||
|
@ -16,13 +18,32 @@ export function deleteShapes(data: Data, ids: string[]): TLDrawCommand {
|
|||
bindings: {},
|
||||
}
|
||||
|
||||
const parentsToUpdate: GroupShape[] = []
|
||||
|
||||
const deletedIds = [...ids]
|
||||
|
||||
// These are the shapes we're definitely going to delete
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
Object.values(page.bindings).forEach((binding) => {
|
||||
|
@ -34,7 +55,7 @@ export function deleteShapes(data: Data, ids: string[]): TLDrawCommand {
|
|||
after.bindings[binding.id] = undefined
|
||||
|
||||
// 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 (shape.handles) {
|
||||
|
@ -52,7 +73,7 @@ export function deleteShapes(data: Data, ids: string[]): TLDrawCommand {
|
|||
|
||||
// Unless we're currently deleting the shape, remove the
|
||||
// binding reference from the after patch
|
||||
if (!ids.includes(id)) {
|
||||
if (!deletedIds.includes(id)) {
|
||||
after.shapes[id] = {
|
||||
...after.shapes[id],
|
||||
handles: { ...after.shapes[id]?.handles, [handle.id]: { bindingId: undefined } },
|
||||
|
@ -69,20 +90,20 @@ export function deleteShapes(data: Data, ids: string[]): TLDrawCommand {
|
|||
before: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: before,
|
||||
[pageId]: before,
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: { selectedIds: TLDR.getSelectedIds(data, currentPageId) },
|
||||
[pageId]: { selectedIds: TLDR.getSelectedIds(data, pageId) },
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: after,
|
||||
[pageId]: after,
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: { selectedIds: [] },
|
||||
[pageId]: { selectedIds: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -10,7 +10,7 @@ export function duplicatePage(data: Data, pageId: string): TLDrawCommand {
|
|||
const nextPage = {
|
||||
...page,
|
||||
id: newId,
|
||||
...Object.fromEntries(
|
||||
shapes: Object.fromEntries(
|
||||
Object.entries(page.shapes).map(([id, shape]) => {
|
||||
return [
|
||||
id,
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { TLDrawState } from '~state'
|
||||
import { mockDocument } from '~test'
|
||||
import { ArrowShape, TLDrawShapeType } from '~types'
|
||||
|
||||
describe('Duplicate command', () => {
|
||||
const tlstate = new TLDrawState()
|
||||
|
@ -21,4 +23,101 @@ describe('Duplicate command', () => {
|
|||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,35 +1,86 @@
|
|||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { Utils, Vec } from '@tldraw/core'
|
||||
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 {
|
||||
const { currentPageId } = data.appState
|
||||
|
||||
const delta = Vec.div([16, 16], TLDR.getCamera(data, currentPageId).zoom)
|
||||
|
||||
const after = Object.fromEntries(
|
||||
TLDR.getSelectedIds(data, currentPageId)
|
||||
.map((id) => TLDR.getShape(data, id, currentPageId))
|
||||
.map((shape) => {
|
||||
const id = Utils.uniqueId()
|
||||
return [
|
||||
id,
|
||||
{
|
||||
...Utils.deepClone(shape),
|
||||
id,
|
||||
point: Vec.round(Vec.add(shape.point, delta)),
|
||||
},
|
||||
]
|
||||
})
|
||||
const before: PagePartial = {
|
||||
shapes: {},
|
||||
bindings: {},
|
||||
}
|
||||
|
||||
const after: PagePartial = {
|
||||
shapes: {},
|
||||
bindings: {},
|
||||
}
|
||||
|
||||
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 {
|
||||
id: 'duplicate',
|
||||
before: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: { shapes: before },
|
||||
[currentPageId]: before,
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: { selectedIds: ids },
|
||||
|
@ -39,10 +90,10 @@ export function duplicate(data: Data, ids: string[]): TLDrawCommand {
|
|||
after: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: { shapes: after },
|
||||
[currentPageId]: after,
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: { selectedIds: Object.keys(after) },
|
||||
[currentPageId]: { selectedIds: Object.keys(after.shapes) },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
215
packages/tldraw/src/state/command/group/group.command.spec.ts
Normal file
215
packages/tldraw/src/state/command/group/group.command.spec.ts
Normal 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.
|
||||
*/
|
||||
})
|
||||
})
|
200
packages/tldraw/src/state/command/group/group.command.ts
Normal file
200
packages/tldraw/src/state/command/group/group.command.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
1
packages/tldraw/src/state/command/group/index.ts
Normal file
1
packages/tldraw/src/state/command/group/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './group.command'
|
|
@ -1,19 +1,20 @@
|
|||
export * from './align'
|
||||
export * from './change-page'
|
||||
export * from './create-page'
|
||||
export * from './create'
|
||||
export * from './delete-page'
|
||||
export * from './delete'
|
||||
export * from './distribute'
|
||||
export * from './duplicate-page'
|
||||
export * from './duplicate'
|
||||
export * from './flip'
|
||||
export * from './move'
|
||||
export * from './rename-page'
|
||||
export * from './rotate'
|
||||
export * from './stretch'
|
||||
export * from './style'
|
||||
export * from './toggle-decoration'
|
||||
export * from './toggle'
|
||||
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 './group'
|
||||
|
|
|
@ -26,4 +26,7 @@ describe('Translate command', () => {
|
|||
tlstate.nudge([1, 2], true)
|
||||
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
|
||||
})
|
||||
|
|
|
@ -3,6 +3,8 @@ import type { Data, TLDrawCommand, PagePartial } from '~types'
|
|||
import { TLDR } from '~state/tldr'
|
||||
|
||||
export function translate(data: Data, ids: string[], delta: number[]): TLDrawCommand {
|
||||
const { currentPageId } = data.appState
|
||||
|
||||
const before: PagePartial = {
|
||||
shapes: {},
|
||||
bindings: {},
|
||||
|
@ -19,13 +21,15 @@ export function translate(data: Data, ids: string[], delta: number[]): TLDrawCom
|
|||
(shape) => ({
|
||||
point: Vec.round(Vec.add(shape.point, delta)),
|
||||
}),
|
||||
data.appState.currentPageId
|
||||
currentPageId
|
||||
)
|
||||
|
||||
before.shapes = change.before
|
||||
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) => {
|
||||
before.bindings[binding.id] = binding
|
||||
|
|
|
@ -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 */
|
||||
/* -------------------------------------------------- */
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -138,6 +138,7 @@ export enum TLDrawShapeType {
|
|||
Draw = 'draw',
|
||||
Arrow = 'arrow',
|
||||
Text = 'text',
|
||||
Group = 'group',
|
||||
}
|
||||
|
||||
export enum Decoration {
|
||||
|
@ -183,7 +184,19 @@ export interface TextShape extends TLDrawBaseShape {
|
|||
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> {
|
||||
abstract toolType: TLDrawToolType
|
||||
|
|
21352
tsconfig.tsbuildinfo
21352
tsconfig.tsbuildinfo
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue