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() {
|
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'],
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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'],
|
||||||
|
|
|
@ -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()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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]
|
||||||
|
|
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 './rectangle'
|
||||||
export * from './ellipse'
|
export * from './ellipse'
|
||||||
export * from './text'
|
export * from './text'
|
||||||
|
export * from './group'
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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: [] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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) },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
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 './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'
|
||||||
|
|
|
@ -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
|
||||||
})
|
})
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
@ -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
|
||||||
|
|
21352
tsconfig.tsbuildinfo
21352
tsconfig.tsbuildinfo
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue