Browse Source

feat: grouping & ungrouping shapes (#7713)

Grouping && ungrouping shapes support
Peng Xiao 2 years ago
parent
commit
1141890a8c
23 changed files with 404 additions and 109 deletions
  1. 4 11
      src/main/frontend/handler/paste.cljs
  2. 22 0
      tldraw/apps/tldraw-logseq/src/components/ContextMenu/ContextMenu.tsx
  3. 7 4
      tldraw/apps/tldraw-logseq/src/lib/preview-manager.tsx
  4. 1 1
      tldraw/apps/tldraw-logseq/src/lib/shapes/BoxShape.tsx
  5. 65 0
      tldraw/apps/tldraw-logseq/src/lib/shapes/GroupShape.tsx
  6. 1 1
      tldraw/apps/tldraw-logseq/src/lib/shapes/PolygonShape.tsx
  7. 7 6
      tldraw/apps/tldraw-logseq/src/lib/shapes/index.ts
  8. 40 8
      tldraw/demo/src/App.jsx
  9. 2 0
      tldraw/packages/core/src/constants.ts
  10. 53 2
      tldraw/packages/core/src/lib/TLApi/TLApi.ts
  11. 94 13
      tldraw/packages/core/src/lib/TLApp/TLApp.ts
  12. 9 6
      tldraw/packages/core/src/lib/TLHistory.ts
  13. 0 25
      tldraw/packages/core/src/lib/shapes/TLDotShape/TLDotShape.test.ts
  14. 46 0
      tldraw/packages/core/src/lib/shapes/TLGroupShape/TLGroupShape.tsx
  15. 1 0
      tldraw/packages/core/src/lib/shapes/TLGroupShape/index.ts
  16. 1 0
      tldraw/packages/core/src/lib/shapes/index.ts
  17. 16 11
      tldraw/packages/core/src/lib/tools/TLSelectTool/states/BrushingState.ts
  18. 3 3
      tldraw/packages/core/src/lib/tools/TLSelectTool/states/ContextMenuState.ts
  19. 1 0
      tldraw/packages/core/src/lib/tools/TLSelectTool/states/PointingShapeBehindBoundsState.ts
  20. 3 2
      tldraw/packages/core/src/lib/tools/TLSelectTool/states/PointingShapeState.ts
  21. 11 12
      tldraw/packages/core/src/lib/tools/TLSelectTool/states/TranslatingState.ts
  22. 1 0
      tldraw/packages/react/src/components/AppCanvas.tsx
  23. 16 4
      tldraw/packages/react/src/components/Canvas/Canvas.tsx

+ 4 - 11
src/main/frontend/handler/paste.cljs

@@ -72,16 +72,6 @@
                                              (gp-util/safe-decode-uri-component text))]
     (try-parse-as-json (second matched-text))))
 
-(defn- get-whiteboard-shape-refs-text
-  [text]
-  (let [tldr (get-whiteboard-tldr-from-text text)]
-    (when (and tldr (object? tldr))
-      (->> (gobj/get tldr "shapes")
-           (mapv (fn [shape]
-                   (let [shape-id (gobj/get shape "id")]
-                     (block-ref/->block-ref shape-id))))
-           (string/join "\n")))))
-
 (defn- paste-copied-blocks-or-text
   ;; todo: logseq/whiteboard-shapes is now text/html
   [text e html]
@@ -90,7 +80,10 @@
         input (state/get-input)
         input-id (state/get-edit-input-id)
         text (string/replace text "\r\n" "\n") ;; Fix for Windows platform
-        shape-refs-text (when-not (string/blank? html) (get-whiteboard-shape-refs-text html))
+        shape-refs-text (when (and (not (string/blank? html))
+                                   (get-whiteboard-tldr-from-text html)) 
+                          ;; text should alway be prepared block-ref generated in tldr
+                          text)
         internal-paste? (and
                          (seq (:copy/blocks copied-blocks))
                          ;; not copied from the external clipboard

+ 22 - 0
tldraw/apps/tldraw-logseq/src/components/ContextMenu/ContextMenu.tsx

@@ -124,6 +124,28 @@ export const ContextMenu = observer(function ContextMenu({
               <ReactContextMenu.Separator className="menu-separator" />
             </>
           )}
+          {(app.selectedShapesArray.some(s => s.type === 'group' || app.getParentGroup(s)) ||
+            app.selectedShapesArray.length > 1) && (
+            <>
+              {app.selectedShapesArray.some(s => s.type === 'group' || app.getParentGroup(s)) && (
+                <ReactContextMenu.Item
+                  className="tl-menu-item"
+                  onClick={() => runAndTransition(app.api.unGroup)}
+                >
+                  Ungroup
+                </ReactContextMenu.Item>
+              )}
+              {app.selectedShapesArray.length > 1 && (
+                <ReactContextMenu.Item
+                  className="tl-menu-item"
+                  onClick={() => runAndTransition(app.api.doGroup)}
+                >
+                  Group
+                </ReactContextMenu.Item>
+              )}
+              <ReactContextMenu.Separator className="menu-separator" />
+            </>
+          )}
           {app.selectedShapes?.size > 0 && (
             <>
               <ReactContextMenu.Item

+ 7 - 4
tldraw/apps/tldraw-logseq/src/lib/preview-manager.tsx

@@ -27,10 +27,13 @@ export class PreviewManager {
     const page = snapshot.pages[0]
     this.pageId = page?.id
     this.assets = snapshot.assets
-    this.shapes = page?.shapes.map(s => {
-      const ShapeClass = getShapeClass(s.type)
-      return new ShapeClass(s)
-    })
+    this.shapes = page?.shapes
+      .map(s => {
+        const ShapeClass = getShapeClass(s.type)
+        return new ShapeClass(s)
+      })
+      // do not need to render group shape because it is invisible in preview
+      .filter(s => s.type !== 'group')
   }
 
   generatePreviewJsx(viewport?: TLViewport, ratio?: number) {

+ 1 - 1
tldraw/apps/tldraw-logseq/src/lib/shapes/BoxShape.tsx

@@ -96,7 +96,7 @@ export class BoxShape extends TLBoxShape<BoxShapeProps> {
             fontStyle={italic ? 'italic' : 'normal'}
             fontWeight={fontWeight}
           />
-          <SVGContainer {...events} opacity={isErasing ? 0.2 : opacity}>
+          <SVGContainer opacity={isErasing ? 0.2 : opacity}>
             {isBinding && <BindingIndicator mode="svg" strokeWidth={strokeWidth} size={[w, h]} />}
             <rect
               className={isSelected || !noFill ? 'tl-hitarea-fill' : 'tl-hitarea-stroke'}

+ 65 - 0
tldraw/apps/tldraw-logseq/src/lib/shapes/GroupShape.tsx

@@ -0,0 +1,65 @@
+import { GROUP_PADDING, TLGroupShape, TLGroupShapeProps } from '@tldraw/core'
+import { SVGContainer, TLComponentProps, useApp } from '@tldraw/react'
+import { observer } from 'mobx-react-lite'
+
+export interface GroupShapeProps extends TLGroupShapeProps {}
+
+export class GroupShape extends TLGroupShape<GroupShapeProps> {
+  static id = 'group'
+
+  static defaultProps: GroupShapeProps = {
+    id: 'group',
+    type: 'group',
+    parentId: 'page',
+    point: [0, 0],
+    size: [0, 0],
+    children: [],
+  }
+
+  // TODO: add styles for arrow binding states
+  ReactComponent = observer(({ events }: TLComponentProps) => {
+    const strokeWidth = 2
+    const bounds = this.getBounds()
+    const app = useApp()
+
+    const childSelected = app.selectedShapesArray.some(s => {
+      return app.shapesInGroups([this]).includes(s)
+    })
+
+    const Indicator = this.ReactIndicator
+
+    return (
+      <SVGContainer {...events}>
+        <rect
+          className={'tl-hitarea-fill'}
+          x={strokeWidth / 2}
+          y={strokeWidth / 2}
+          width={Math.max(0.01, bounds.width - strokeWidth)}
+          height={Math.max(0.01, bounds.height - strokeWidth)}
+          pointerEvents="all"
+        />
+        {childSelected && (
+          <g stroke="var(--color-selectedFill)">
+            <Indicator />
+          </g>
+        )}
+      </SVGContainer>
+    )
+  })
+
+  ReactIndicator = observer(() => {
+    const bounds = this.getBounds()
+    return (
+      <rect
+        strokeDasharray="8 2"
+        x={-GROUP_PADDING}
+        y={-GROUP_PADDING}
+        rx={GROUP_PADDING / 2}
+        ry={GROUP_PADDING / 2}
+        width={bounds.width + GROUP_PADDING * 2}
+        height={bounds.height + GROUP_PADDING * 2}
+        fill="transparent"
+      />
+    )
+  })
+}

+ 1 - 1
tldraw/apps/tldraw-logseq/src/lib/shapes/PolygonShape.tsx

@@ -112,7 +112,7 @@ export class PolygonShape extends TLPolygonShape<PolygonShapeProps> {
             fontStyle={italic ? 'italic' : 'normal'}
             fontWeight={fontWeight}
           />
-          <SVGContainer {...events} opacity={isErasing ? 0.2 : opacity}>
+          <SVGContainer opacity={isErasing ? 0.2 : opacity}>
             <g transform={`translate(${x}, ${y})`}>
               <polygon
                 className={isSelected || !noFill ? 'tl-hitarea-fill' : 'tl-hitarea-stroke'}

+ 7 - 6
tldraw/apps/tldraw-logseq/src/lib/shapes/index.ts

@@ -1,19 +1,18 @@
 import type { TLReactShapeConstructor } from '@tldraw/react'
 import { BoxShape } from './BoxShape'
-import { DotShape } from './DotShape'
 import { EllipseShape } from './EllipseShape'
+import { GroupShape } from './GroupShape'
 import { HighlighterShape } from './HighlighterShape'
 import { HTMLShape } from './HTMLShape'
-import { ImageShape } from './ImageShape'
-import { VideoShape } from './VideoShape'
 import { IFrameShape } from './IFrameShape'
+import { ImageShape } from './ImageShape'
 import { LineShape } from './LineShape'
 import { LogseqPortalShape } from './LogseqPortalShape'
 import { PencilShape } from './PencilShape'
 import { PolygonShape } from './PolygonShape'
 import { TextShape } from './TextShape'
+import { VideoShape } from './VideoShape'
 import { YouTubeShape } from './YouTubeShape'
-import type { PenShape } from './PenShape'
 
 export type Shape =
   // | PenShape
@@ -31,20 +30,21 @@ export type Shape =
   | IFrameShape
   | HTMLShape
   | LogseqPortalShape
+  | GroupShape
 
 export * from './BoxShape'
 export * from './DotShape'
 export * from './EllipseShape'
 export * from './HighlighterShape'
 export * from './HTMLShape'
-export * from './ImageShape'
-export * from './VideoShape'
 export * from './IFrameShape'
+export * from './ImageShape'
 export * from './LineShape'
 export * from './LogseqPortalShape'
 export * from './PencilShape'
 export * from './PolygonShape'
 export * from './TextShape'
+export * from './VideoShape'
 export * from './YouTubeShape'
 
 export const shapes: TLReactShapeConstructor<Shape>[] = [
@@ -62,6 +62,7 @@ export const shapes: TLReactShapeConstructor<Shape>[] = [
   IFrameShape,
   HTMLShape,
   LogseqPortalShape,
+  GroupShape,
 ]
 
 export type SizeLevel = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'

+ 40 - 8
tldraw/demo/src/App.jsx

@@ -22,20 +22,52 @@ const documentModel = onLoad() ?? {
       id: 'page1',
       name: 'Page',
       shapes: [
+        {
+          parentId: 'page1',
+          id: '2ec86a35-7ae1-11ed-8cf0-d77b96340231',
+          type: 'group',
+          children: [
+            '2ec86a30-7ae1-11ed-8cf0-d77b96340231',
+            '304ce750-7ae1-11ed-8cf0-d77b96340231',
+          ],
+        },
+        {
+          scale: [1, 1],
+          id: '2ec86a30-7ae1-11ed-8cf0-d77b96340231',
+          parentId: 'page1',
+          type: 'box',
+          point: [440.1057854416563, 323.39934576376567],
+          size: [237.39428786834378, 109.46744189728395],
+          borderRadius: 2,
+          stroke: '',
+          fill: '',
+          noFill: false,
+          fontWeight: 400,
+          italic: false,
+          strokeType: 'line',
+          strokeWidth: 2,
+          opacity: 1,
+          label: '',
+          nonce: 1670934308981,
+        },
         {
           scale: [1, 1],
-          blockType: 'B',
-          id: 'p6bv7EfoQPIF1eZB1RRO6',
-          type: 'logseq-portal',
+          id: '304ce750-7ae1-11ed-8cf0-d77b96340231',
           parentId: 'page1',
-          point: [369.109375, 170.5546875],
-          size: [240, 0],
+          type: 'box',
+          point: [667.72008322492, 250.01956107918932],
+          size: [316.42711988510905, 134.2180982739887],
+          borderRadius: 2,
           stroke: '',
           fill: '',
+          noFill: false,
+          fontWeight: 400,
+          italic: false,
+          strokeType: 'line',
           strokeWidth: 2,
           opacity: 1,
-          pageId: 'aaasssdddfff',
-          nonce: 1,
+          label: '',
+          nonce: 1670934311539,
         },
       ],
       bindings: {},
@@ -232,7 +264,7 @@ export default function App() {
           isWhiteboardPage: () => false,
           saveAsset: fileToBase64,
           makeAssetUrl: a => a,
-          getBlockPageName: a => a + '_page'
+          getBlockPageName: a => a + '_page',
         }}
         model={model}
         onPersist={app => {

+ 2 - 0
tldraw/packages/core/src/constants.ts

@@ -30,6 +30,8 @@ export const EMPTY_OBJECT: any = {}
 
 export const EMPTY_ARRAY: any[] = []
 
+export const GROUP_PADDING = 8
+
 export const CURSORS: Record<TLSelectionHandle, TLCursor> = {
   [TLResizeEdge.Bottom]: TLCursor.NsResize,
   [TLResizeEdge.Top]: TLCursor.NsResize,

+ 53 - 2
tldraw/packages/core/src/lib/TLApi/TLApi.ts

@@ -1,6 +1,6 @@
 import Vec from '@tldraw/vec'
 import type { TLAsset, TLBinding, TLEventMap } from '../../types'
-import { BoundsUtils, uniqueId } from '../../utils'
+import { BoundsUtils, isNonNullable, uniqueId } from '../../utils'
 import type { TLShape, TLShapeModel } from '../shapes'
 import type { TLApp } from '../TLApp'
 
@@ -92,7 +92,9 @@ export class TLApi<S extends TLShape = TLShape, K extends TLEventMap = TLEventMa
 
   /** Select all shapes on the current page. */
   selectAll = (): this => {
-    this.app.setSelectedShapes(this.app.currentPage.shapes)
+    this.app.setSelectedShapes(
+      this.app.currentPage.shapes.filter(s => !this.app.shapesInGroups().includes(s))
+    )
     return this
   }
 
@@ -238,6 +240,14 @@ export class TLApi<S extends TLShape = TLShape, K extends TLEventMap = TLEventMa
       }
     })
 
+    clonedShapes.forEach(s => {
+      if (s.children && s.children?.length > 0) {
+        s.children = s.children.map(oldId => {
+          return clonedShapes[shapes.findIndex(s => s.id === oldId)].id
+        })
+      }
+    })
+
     const clonedBindings: TLBinding[] = []
 
     // Try to rebinding the shapes with the given bindings
@@ -348,4 +358,45 @@ export class TLApi<S extends TLShape = TLShape, K extends TLEventMap = TLEventMa
     }
     return this
   }
+
+  doGroup = (shapes: S[] = this.app.allSelectedShapesArray) => {
+    const selectedGroups: S[] = [
+      ...shapes.filter(s => s.type === 'group'),
+      ...shapes.map(s => this.app.getParentGroup(s)),
+    ].filter(isNonNullable)
+    // not using this.app.removeShapes because it also remove shapes in the group
+    this.app.currentPage.removeShapes(...selectedGroups)
+
+    // group all shapes
+    const selectedShapes = shapes.filter(s => s.type !== 'group')
+    if (selectedShapes.length > 1) {
+      const ShapeGroup = this.app.getShapeClass('group')
+      const group = new ShapeGroup({
+        id: uniqueId(),
+        type: ShapeGroup.id,
+        parentId: this.app.currentPage.id,
+        children: selectedShapes.map(s => s.id),
+      })
+      this.app.currentPage.addShapes(group)
+      this.app.setSelectedShapes([group])
+      // the shapes in the group should also be moved to the bottom of the array (to be on top on the canvas)
+      this.app.bringForward(selectedShapes)
+    }
+    this.app.persist()
+  }
+
+  unGroup = (shapes: S[] = this.app.allSelectedShapesArray) => {
+    const selectedGroups: S[] = [
+      ...shapes.filter(s => s.type === 'group'),
+      ...shapes.map(s => this.app.getParentGroup(s)),
+    ].filter(isNonNullable)
+
+    const shapesInGroups = this.app.shapesInGroups(selectedGroups)
+
+    // not using this.app.removeShapes because it also remove shapes in the group
+    this.app.currentPage.removeShapes(...selectedGroups)
+    this.app.persist()
+
+    this.app.setSelectedShapes(shapesInGroups)
+  }
 }

+ 94 - 13
tldraw/packages/core/src/lib/TLApp/TLApp.ts

@@ -17,8 +17,15 @@ import type {
   TLSubscriptionEventName,
 } from '../../types'
 import { AlignType, DistributeType } from '../../types'
-import { BoundsUtils, createNewLineBinding, isNonNullable, KeyUtils, uniqueId } from '../../utils'
-import type { TLShape, TLShapeConstructor, TLShapeModel } from '../shapes'
+import {
+  BoundsUtils,
+  createNewLineBinding,
+  dedupe,
+  isNonNullable,
+  KeyUtils,
+  uniqueId,
+} from '../../utils'
+import type { TLGroupShape, TLShape, TLShapeConstructor, TLShapeModel } from '../shapes'
 import { TLApi } from '../TLApi'
 import { TLCursors } from '../TLCursors'
 
@@ -320,19 +327,59 @@ export class TLApp<
 
   @action readonly deleteShapes = (shapes: S[] | string[]): this => {
     if (shapes.length === 0) return this
-    let ids: Set<string>
-    if (typeof shapes[0] === 'string') {
-      ids = new Set(shapes as string[])
-    } else {
-      ids = new Set((shapes as S[]).map(shape => shape.id))
-    }
+    const normalizedShapes: S[] = shapes
+      .map(shape => (typeof shape === 'string' ? this.getShapeById(shape) : shape))
+      .filter(isNonNullable)
+
+    // delete a group shape should also delete its children
+    const shapesInGroups = this.shapesInGroups(normalizedShapes)
+
+    normalizedShapes.forEach(shape => {
+      if (this.getParentGroup(shape)) {
+        shapesInGroups.push(shape)
+      }
+    })
+
+    let ids: Set<string> = new Set([...normalizedShapes, ...shapesInGroups].map(s => s.id))
+
+    shapesInGroups.forEach(shape => {
+      // delete a shape in a group should also update the group shape
+      const parentGroup = this.getParentGroup(shape)
+      if (parentGroup) {
+        const newChildren: string[] | undefined = parentGroup.props.children?.filter(
+          id => id !== shape.id
+        )
+        if (!newChildren || newChildren?.length <= 1) {
+          // remove empty group or group with only one child
+          ids.add(parentGroup.id)
+        } else {
+          parentGroup.update({ children: newChildren })
+        }
+      }
+    })
+
+    const allShapesToDelete = [...ids].map(id => this.getShapeById(id)!)
+
     this.setSelectedShapes(this.selectedShapesArray.filter(shape => !ids.has(shape.id)))
-    const removedShapes = this.currentPage.removeShapes(...shapes)
+    const removedShapes = this.currentPage.removeShapes(...allShapesToDelete)
     if (removedShapes) this.notify('delete-shapes', removedShapes)
     this.persist()
     return this
   }
 
+  /** Get all shapes in groups */
+  shapesInGroups(groups = this.shapes): S[] {
+    return groups
+      .flatMap(shape => shape.props.children)
+      .filter(isNonNullable)
+      .map(id => this.getShapeById(id))
+      .filter(isNonNullable)
+  }
+
+  getParentGroup(shape: S) {
+    return this.shapes.find(group => group.props.children?.includes(shape.id))
+  }
+
   bringForward = (shapes: S[] | string[] = this.selectedShapesArray): this => {
     if (shapes.length > 0) this.currentPage.bringForward(shapes)
     return this
@@ -489,21 +536,25 @@ export class TLApp<
 
   copy = () => {
     if (this.selectedShapesArray.length > 0 && !this.editingShape) {
+      const selectedShapes = this.allSelectedShapesArray
       const jsonString = JSON.stringify({
-        shapes: this.selectedShapesArray.map(shape => shape.serialized),
+        shapes: selectedShapes.map(shape => shape.serialized),
         // pasting into other whiteboard may require this if any shape uses the assets
         assets: this.getCleanUpAssets().filter(asset => {
-          return this.selectedShapesArray.some(shape => shape.props.assetId === asset.id)
+          return selectedShapes.some(shape => shape.props.assetId === asset.id)
         }),
         // convey the bindings to maintain the new links after pasting
         bindings: toJS(this.currentPage.bindings),
       })
       const tldrawString = encodeURIComponent(`<whiteboard-tldr>${jsonString}</whiteboard-tldr>`)
+
+      const shapeBlockRefs = this.selectedShapesArray.map(s => `((${s.props.id}))`).join(' ')
+
       // FIXME: use `writeClipboard` in frontend.utils
       navigator.clipboard.write([
         new ClipboardItem({
           'text/html': new Blob([tldrawString], { type: 'text/html' }),
-          'text/plain': new Blob([`((${this.selectedShapesArray[0].props.id}))`], {
+          'text/plain': new Blob([shapeBlockRefs], {
             type: 'text/plain',
           }),
         }),
@@ -578,6 +629,14 @@ export class TLApp<
     return hoveredId ? currentPage.shapes.find(shape => shape.id === hoveredId) : undefined
   }
 
+  @computed get hoveredGroup(): S | undefined {
+    const { hoveredShape } = this
+    const hoveredGroup = hoveredShape
+      ? this.shapes.find(s => s.type === 'group' && s.props.children?.includes(hoveredShape.id))
+      : undefined
+    return hoveredGroup as S | undefined
+  }
+
   @action readonly setHoveredShape = (shape?: string | S): this => {
     this.hoveredId = typeof shape === 'string' ? shape : shape?.id
     return this
@@ -598,6 +657,17 @@ export class TLApp<
     return Array.from(selectedShapes.values())
   }
 
+  // include selected shapes in groups
+  @computed get allSelectedShapes() {
+    return new Set(this.allSelectedShapesArray)
+  }
+
+  // include selected shapes in groups
+  @computed get allSelectedShapesArray() {
+    const { selectedShapesArray } = this
+    return dedupe([...selectedShapesArray, ...this.shapesInGroups(selectedShapesArray)])
+  }
+
   @action setSelectedShapes = (shapes: S[] | string[]): this => {
     const { selectedIds, selectedShapes } = this
     selectedIds.clear()
@@ -865,7 +935,18 @@ export class TLApp<
   Shapes = new Map<string, TLShapeConstructor<S>>()
 
   registerShapes = (Shapes: TLShapeConstructor<S>[]) => {
-    Shapes.forEach(Shape => this.Shapes.set(Shape.id, Shape))
+    Shapes.forEach(Shape => {
+      // monkey patch Shape
+      if (Shape.id === 'group') {
+        // Group Shape requires this hack to get the real children shapes
+        const app = this
+        Shape.prototype.getShapes = function () {
+          // @ts-expect-error FIXME: this is a hack to get around the fact that we can't use computed properties in the constructor
+          return this.props.children?.map(id => app.getShapeById(id)).filter(Boolean) ?? []
+        }
+      }
+      return this.Shapes.set(Shape.id, Shape)
+    })
   }
 
   deregisterShapes = (Shapes: TLShapeConstructor<S>[]) => {

+ 9 - 6
tldraw/packages/core/src/lib/TLHistory.ts

@@ -1,7 +1,8 @@
 import { action, computed, makeObservable, observable, transaction } from 'mobx'
 import type { TLEventMap } from '../types'
 import { deepCopy, deepEqual, omit } from '../utils'
-import type { TLShape } from './shapes'
+import type { TLShape, TLShapeModel } from './shapes'
+import type { TLGroupShape } from './shapes/TLGroupShape'
 import type { TLApp, TLDocumentModel } from './TLApp'
 import { TLPage } from './TLPage'
 
@@ -101,6 +102,11 @@ export class TLHistory<S extends TLShape = TLShape, K extends TLEventMap = TLEve
     }
   }
 
+  instantiateShape = (serializedShape: TLShapeModel) => {
+    const ShapeClass = this.app.getShapeClass(serializedShape.type)
+    return new ShapeClass(serializedShape)
+  }
+
   @action deserialize = (snapshot: TLDocumentModel) => {
     transaction(() => {
       const { pages } = snapshot
@@ -131,9 +137,7 @@ export class TLHistory<S extends TLShape = TLShape, K extends TLEventMap = TLEve
                 shapesMap.delete(serializedShape.id)
               } else {
                 // Create the shape
-                const ShapeClass = this.app.getShapeClass(serializedShape.type)
-                const newShape = new ShapeClass(serializedShape)
-                shapesToAdd.push(newShape)
+                shapesToAdd.push(this.instantiateShape(serializedShape))
               }
             }
 
@@ -158,8 +162,7 @@ export class TLHistory<S extends TLShape = TLShape, K extends TLEventMap = TLEve
                 nonce,
                 bindings,
                 shapes: shapes.map(serializedShape => {
-                  const ShapeClass = this.app.getShapeClass(serializedShape.type)
-                  return new ShapeClass(serializedShape)
+                  return this.instantiateShape(serializedShape)
                 }),
               })
             )

+ 0 - 25
tldraw/packages/core/src/lib/shapes/TLDotShape/TLDotShape.test.ts

@@ -1,25 +0,0 @@
-import type { TLDotShapeProps } from '.'
-import { TLDotShape } from './TLDotShape'
-
-describe('A minimal test', () => {
-  it('Creates the shape', () => {
-    interface DotShapeProps extends TLDotShapeProps {
-      stroke: string
-    }
-
-    class Shape extends TLDotShape<DotShapeProps> {
-      static defaultProps: DotShapeProps = {
-        id: 'dot',
-        type: 'dot',
-        parentId: 'page',
-        point: [0, 0],
-        radius: 4,
-        stroke: 'black',
-      }
-    }
-
-    const shape = new Shape()
-    expect(shape).toBeDefined()
-    expect(shape.props.stroke).toBe('black')
-  })
-})

+ 46 - 0
tldraw/packages/core/src/lib/shapes/TLGroupShape/TLGroupShape.tsx

@@ -0,0 +1,46 @@
+import type { TLBounds } from '@tldraw/intersect'
+import { computed, makeObservable } from 'mobx'
+import { BoundsUtils } from '../../../utils'
+import { TLBoxShape, TLBoxShapeProps } from '../TLBoxShape'
+import type { TLShape } from '../TLShape'
+
+export interface TLGroupShapeProps extends TLBoxShapeProps {
+  children: string[] // shape ids
+}
+
+export class TLGroupShape<
+  P extends TLGroupShapeProps = TLGroupShapeProps,
+  M = any
+> extends TLBoxShape<P, M> {
+  constructor(props = {} as Partial<P>) {
+    super(props)
+    makeObservable(this)
+    this.canResize = [false, false]
+  }
+
+  canEdit = false
+  canFlip = false
+
+  static id = 'group'
+
+  static defaultProps: TLGroupShapeProps = {
+    id: 'group',
+    type: 'group',
+    parentId: 'page',
+    point: [0, 0],
+    size: [0, 0],
+    children: [],
+  }
+
+  getShapes(): TLShape[] {
+    throw new Error('will be implemented other places')
+  }
+
+  @computed get shapes() {
+    return this.getShapes()
+  }
+
+  getBounds = (): TLBounds => {
+    return BoundsUtils.getCommonBounds(this.shapes.map(s => s.getBounds()))
+  }
+}

+ 1 - 0
tldraw/packages/core/src/lib/shapes/TLGroupShape/index.ts

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

+ 1 - 0
tldraw/packages/core/src/lib/shapes/index.ts

@@ -9,3 +9,4 @@ export * from './TLLineShape'
 export * from './TLPolygonShape'
 export * from './TLStarShape'
 export * from './TLTextShape'
+export * from './TLGroupShape'

+ 16 - 11
tldraw/packages/core/src/lib/tools/TLSelectTool/states/BrushingState.ts

@@ -1,5 +1,5 @@
 import type { TLEventMap, TLEvents } from '../../../../types'
-import { BoundsUtils } from '../../../../utils'
+import { BoundsUtils, dedupe } from '../../../../utils'
 import type { TLShape } from '../../../shapes'
 import type { TLApp } from '../../../TLApp'
 import { TLBush } from '../../../TLBush'
@@ -41,13 +41,20 @@ export class BrushingState<
 
     this.app.setBrush(brushBounds)
 
-    const hits = this.tree
-      .search(brushBounds)
-      .filter(shape =>
-        ctrlKey
-          ? BoundsUtils.boundsContain(brushBounds, shape.rotatedBounds)
-          : shape.hitTestBounds(brushBounds)
-      )
+    const hits = dedupe(
+      this.tree
+        .search(brushBounds)
+        .filter(shape =>
+          ctrlKey
+            ? BoundsUtils.boundsContain(brushBounds, shape.rotatedBounds)
+            : shape.hitTestBounds(brushBounds)
+        )
+        .filter(shape => shape.type !== 'group')
+        .map(shape => {
+          // for a shape in a group, select the group instead
+          return this.app.getParentGroup(shape) ?? shape
+        })
+    )
 
     if (shiftKey) {
       if (hits.every(hit => this.initialSelectedShapes.includes(hit))) {
@@ -55,9 +62,7 @@ export class BrushingState<
         this.app.setSelectedShapes(this.initialSelectedShapes.filter(hit => !hits.includes(hit)))
       } else {
         // Select hit shapes + initial selected shapes
-        this.app.setSelectedShapes(
-          Array.from(new Set([...this.initialSelectedShapes, ...hits]).values())
-        )
+        this.app.setSelectedShapes(dedupe([...this.initialSelectedShapes, ...hits]))
       }
     } else {
       // Select hit shapes

+ 3 - 3
tldraw/packages/core/src/lib/tools/TLSelectTool/states/ContextMenuState.ts

@@ -22,12 +22,12 @@ export class ContextMenuState<
     } = this.app
 
     if (info.type === TLTargetType.Shape && !selectedShapes.has(info.shape)) {
+      const shape = this.app.getParentGroup(info.shape) ?? info.shape
       if (shiftKey) {
-        this.app.setSelectedShapes([...Array.from(selectedIds.values()), info.shape.id])
+        this.app.setSelectedShapes([...Array.from(selectedIds.values()), shape.id])
         return
       }
-
-      this.app.setSelectedShapes([info.shape])
+      this.app.setSelectedShapes([shape])
     }
   }
 

+ 1 - 0
tldraw/packages/core/src/lib/tools/TLSelectTool/states/PointingShapeBehindBoundsState.ts

@@ -31,6 +31,7 @@ export class PointingShapeBehindBoundsState<
       selectedIds,
       inputs: { shiftKey },
     } = this.app
+    // unlike PointingShapeState, always select the shape behind the group
     if (shiftKey) {
       this.app.setSelectedShapes([...Array.from(selectedIds.values()), this.info.shape.id])
     } else {

+ 3 - 2
tldraw/packages/core/src/lib/tools/TLSelectTool/states/PointingShapeState.ts

@@ -18,10 +18,11 @@ export class PointingShapeState<
       selectedIds,
       inputs: { shiftKey },
     } = this.app
+    const shape = this.app.getParentGroup(info.shape) ?? info.shape
     if (shiftKey) {
-      this.app.setSelectedShapes([...Array.from(selectedIds.values()), info.shape.id])
+      this.app.setSelectedShapes([...Array.from(selectedIds.values()), shape.id])
     } else {
-      this.app.setSelectedShapes([info.shape])
+      this.app.setSelectedShapes([shape])
     }
   }
 

+ 11 - 12
tldraw/packages/core/src/lib/tools/TLSelectTool/states/TranslatingState.ts

@@ -1,7 +1,7 @@
 import { Vec } from '@tldraw/vec'
 import { transaction } from 'mobx'
 import { type TLEventMap, TLCursor, type TLEvents } from '../../../../types'
-import { uniqueId } from '../../../../utils'
+import { dedupe, uniqueId } from '../../../../utils'
 import type { TLShape } from '../../../shapes'
 import type { TLApp } from '../../../TLApp'
 import { TLToolState } from '../../../TLToolState'
@@ -47,16 +47,17 @@ export class TranslatingState<
     }
 
     transaction(() => {
-      selectedShapes.forEach(shape =>
+      this.app.allSelectedShapes.forEach(shape => {
         shape.update({ point: Vec.add(initialPoints[shape.id], delta) })
-      )
+      })
     })
   }
 
   private startCloning() {
+    // FIXME: clone group?
     if (!this.didClone) {
       // Create the clones
-      this.clones = this.app.selectedShapesArray.map(shape => {
+      this.clones = this.app.allSelectedShapesArray.map(shape => {
         const ShapeClass = this.app.getShapeClass(shape.type)
         if (!ShapeClass) throw Error('Could not find that shape class.')
         const clone = new ShapeClass({
@@ -77,7 +78,7 @@ export class TranslatingState<
     }
 
     // Move shapes back to their start positions
-    this.app.selectedShapes.forEach(shape => {
+    this.app.allSelectedShapes.forEach(shape => {
       shape.update({ point: this.initialPoints[shape.id] })
     })
 
@@ -94,8 +95,6 @@ export class TranslatingState<
     this.moveSelectedShapesToPointer()
 
     this.isCloning = true
-
-    this.moveSelectedShapesToPointer()
   }
 
   onEnter = () => {
@@ -103,10 +102,10 @@ export class TranslatingState<
     this.app.history.pause()
 
     // Set initial data
-    const { selectedShapesArray, inputs } = this.app
+    const { allSelectedShapesArray, inputs } = this.app
 
     this.initialShapePoints = Object.fromEntries(
-      selectedShapesArray.map(({ id, props: { point } }) => [id, point.slice()])
+      allSelectedShapesArray.map(({ id, props: { point } }) => [id, point.slice()])
     )
     this.initialPoints = this.initialShapePoints
 
@@ -160,7 +159,7 @@ export class TranslatingState<
         break
       }
       case 'Escape': {
-        this.app.selectedShapes.forEach(shape => {
+        this.app.allSelectedShapes.forEach(shape => {
           shape.update({ point: this.initialPoints[shape.id] })
         })
         this.tool.transition('idle')
@@ -174,10 +173,10 @@ export class TranslatingState<
       case 'Alt': {
         if (!this.isCloning) throw Error('Expected to be cloning.')
 
-        const { currentPage, selectedShapes } = this.app
+        const { currentPage, allSelectedShapes } = this.app
 
         // Remove the selected shapes (our clones)
-        currentPage.removeShapes(...selectedShapes)
+        currentPage.removeShapes(...allSelectedShapes)
 
         // Set the initial points to the original shape points
         this.initialPoints = this.initialShapePoints

+ 1 - 0
tldraw/packages/react/src/components/AppCanvas.tsx

@@ -18,6 +18,7 @@ export const AppCanvas = observer(function InnerApp<S extends TLReactShape>(
       brush={app.brush}
       editingShape={app.editingShape}
       hoveredShape={app.hoveredShape}
+      hoveredGroup={app.hoveredGroup}
       bindingShapes={app.bindingShapes}
       selectionDirectionHint={app.selectionDirectionHint}
       selectionBounds={app.selectionBounds}

+ 16 - 4
tldraw/packages/react/src/components/Canvas/Canvas.tsx

@@ -1,6 +1,14 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
 /* eslint-disable @typescript-eslint/no-non-null-assertion */
-import { EMPTY_OBJECT, TLAsset, TLBinding, TLBounds, TLCursor, TLTheme } from '@tldraw/core'
+import {
+  EMPTY_OBJECT,
+  isNonNullable,
+  TLAsset,
+  TLBinding,
+  TLBounds,
+  TLCursor,
+  TLTheme,
+} from '@tldraw/core'
 import { observer } from 'mobx-react-lite'
 import * as React from 'react'
 import { NOOP } from '../../constants'
@@ -38,6 +46,7 @@ export interface TLCanvasProps<S extends TLReactShape> {
   assets: Record<string, TLAsset>
   theme: TLTheme
   hoveredShape: S
+  hoveredGroup: S
   editingShape: S
   bindingShapes: S[]
   selectionDirectionHint: number[]
@@ -69,6 +78,7 @@ export const Canvas = observer(function Renderer<S extends TLReactShape>({
   bindingShapes,
   editingShape,
   hoveredShape,
+  hoveredGroup,
   selectionBounds,
   selectedShapes,
   erasingShapes,
@@ -111,6 +121,8 @@ export const Canvas = observer(function Renderer<S extends TLReactShape>({
   const erasingShapesSet = React.useMemo(() => new Set(erasingShapes || []), [erasingShapes])
   const singleSelectedShape = selectedShapes?.length === 1 ? selectedShapes[0] : undefined
 
+  const hoveredShapes: S[] = [...new Set([hoveredGroup, hoveredShape])].filter(isNonNullable)
+
   return (
     <div ref={rContainer} className={`tl-container ${className ?? ''}`}>
       <div tabIndex={-1} className="tl-absolute tl-canvas" {...events}>
@@ -153,9 +165,9 @@ export const Canvas = observer(function Renderer<S extends TLReactShape>({
                 isSelected={true}
               />
             ))}
-          {hoveredShape && (
-            <Indicator key={'hovered_indicator_' + hoveredShape.id} shape={hoveredShape} />
-          )}
+          {hoveredShapes.map(s => (
+            <Indicator key={'hovered_indicator_' + s.id} shape={s} />
+          ))}
           {singleSelectedShape && components.BacklinksCount && (
             <BacklinksCountContainer
               hidden={false}