diff --git a/packages/core/src/components/bounds/bounds.test.tsx b/packages/core/src/components/bounds/bounds.test.tsx
index 26b80edf8..656883e3a 100644
--- a/packages/core/src/components/bounds/bounds.test.tsx
+++ b/packages/core/src/components/bounds/bounds.test.tsx
@@ -12,6 +12,7 @@ describe('bounds', () => {
viewportWidth={1000}
isLocked={false}
isHidden={false}
+ showCloneButtons={false}
/>
)
})
diff --git a/packages/core/src/components/bounds/bounds.tsx b/packages/core/src/components/bounds/bounds.tsx
index 3f5073eb7..03664ba3b 100644
--- a/packages/core/src/components/bounds/bounds.tsx
+++ b/packages/core/src/components/bounds/bounds.tsx
@@ -5,6 +5,7 @@ import { CenterHandle } from './center-handle'
import { RotateHandle } from './rotate-handle'
import { CornerHandle } from './corner-handle'
import { EdgeHandle } from './edge-handle'
+import { CloneButtons } from './clone-buttons'
import { Container } from '+components/container'
import { SVGContainer } from '+components/svg-container'
@@ -14,11 +15,21 @@ interface BoundsProps {
rotation: number
isLocked: boolean
isHidden: boolean
+ showCloneButtons: boolean
viewportWidth: number
+ children?: React.ReactNode
}
export const Bounds = React.memo(
- ({ zoom, bounds, viewportWidth, rotation, isHidden, isLocked }: BoundsProps): JSX.Element => {
+ ({
+ zoom,
+ bounds,
+ viewportWidth,
+ rotation,
+ isHidden,
+ isLocked,
+ showCloneButtons,
+ }: BoundsProps): JSX.Element => {
// Touch target size
const targetSize = (viewportWidth < 768 ? 16 : 8) / zoom
// Handle size
@@ -32,8 +43,8 @@ export const Bounds = React.memo(
return (
-
-
+
+
+ {showCloneButtons && }
)
diff --git a/packages/core/src/components/bounds/center-handle.tsx b/packages/core/src/components/bounds/center-handle.tsx
index 46602fcc7..911ac8486 100644
--- a/packages/core/src/components/bounds/center-handle.tsx
+++ b/packages/core/src/components/bounds/center-handle.tsx
@@ -4,17 +4,21 @@ import type { TLBounds } from '+types'
export interface CenterHandleProps {
bounds: TLBounds
isLocked: boolean
+ isHidden: boolean
}
-export const CenterHandle = React.memo(({ bounds, isLocked }: CenterHandleProps): JSX.Element => {
- return (
-
- )
-})
+export const CenterHandle = React.memo(
+ ({ bounds, isLocked, isHidden }: CenterHandleProps): JSX.Element => {
+ return (
+
+ )
+ }
+)
diff --git a/packages/core/src/components/bounds/clone-button.tsx b/packages/core/src/components/bounds/clone-button.tsx
new file mode 100644
index 000000000..b19df215d
--- /dev/null
+++ b/packages/core/src/components/bounds/clone-button.tsx
@@ -0,0 +1,31 @@
+import * as React from 'react'
+import { useTLContext } from '+hooks'
+import type { TLBounds } from '+types'
+
+export interface CloneButtonProps {
+ bounds: TLBounds
+ side: 'top' | 'right' | 'bottom' | 'left'
+}
+
+export function CloneButton({ bounds, side }: CloneButtonProps) {
+ const x = side === 'left' ? -44 : side === 'right' ? bounds.width + 44 : bounds.width / 2
+ const y = side === 'top' ? -44 : side === 'bottom' ? bounds.height + 44 : bounds.height / 2
+
+ const { callbacks, inputs } = useTLContext()
+
+ const handleClick = React.useCallback(
+ (e: React.PointerEvent) => {
+ e.stopPropagation()
+ const info = inputs.pointerDown(e, side)
+ callbacks.onShapeClone?.(info, e)
+ },
+ [callbacks.onShapeClone]
+ )
+
+ return (
+
+
+
+
+ )
+}
diff --git a/packages/core/src/components/bounds/clone-buttons.tsx b/packages/core/src/components/bounds/clone-buttons.tsx
new file mode 100644
index 000000000..0fbcf5821
--- /dev/null
+++ b/packages/core/src/components/bounds/clone-buttons.tsx
@@ -0,0 +1,18 @@
+import * as React from 'react'
+import type { TLBounds } from '+types'
+import { CloneButton } from './clone-button'
+
+export interface CloneButtonsProps {
+ bounds: TLBounds
+}
+
+export function CloneButtons({ bounds }: CloneButtonsProps) {
+ return (
+ <>
+
+
+
+
+ >
+ )
+}
diff --git a/packages/core/src/components/page/page.tsx b/packages/core/src/components/page/page.tsx
index 9838c83e2..76b3b2adf 100644
--- a/packages/core/src/components/page/page.tsx
+++ b/packages/core/src/components/page/page.tsx
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as React from 'react'
-import type { TLBinding, TLPage, TLPageState, TLShape } from '+types'
+import type { TLBinding, TLPage, TLPageState, TLShape, TLShapeUtil } from '+types'
import { useSelection, useShapeTree, useHandles, useTLContext } from '+hooks'
import { Bounds } from '+components/bounds'
import { BoundsBg } from '+components/bounds/bounds-bg'
@@ -39,8 +39,6 @@ export const Page = React.memo(function Page
+
+ showCloneButtons = utils.canClone
+
+ if (shape.handles !== undefined) {
+ shapeWithHandles = shape
+ }
+ }
+
return (
<>
{bounds && !hideBounds && }
@@ -57,9 +72,10 @@ export const Page = React.memo(function Page page.shapes[id])
.filter(Boolean)
- .map((id) => (
-
+ .map((shape) => (
+
))}
{!hideIndicators && hoveredId && (
)}
{!hideHandles && shapeWithHandles && }
diff --git a/packages/core/src/hooks/useStyle.tsx b/packages/core/src/hooks/useStyle.tsx
index 37b1393e6..ab4010506 100644
--- a/packages/core/src/hooks/useStyle.tsx
+++ b/packages/core/src/hooks/useStyle.tsx
@@ -266,6 +266,31 @@ const tlcss = css`
stroke: var(--tl-selectStroke);
}
+ .tl-clone-button-target {
+ pointer-events: all;
+ }
+
+ .tl-clone-button-target:hover > .tl-clone-button {
+ stroke-width: calc(1.5px * var(--tl-scale));
+ stroke: var(--tl-selectStroke);
+ opacity: 1;
+ }
+
+ .tl-clone-button-target:hover {
+ opacity: 1;
+ }
+
+ .tl-clone-button {
+ r: calc(8px * var(--tl-scale));
+ pointer-events: all;
+ cursor: pointer;
+ fill: transparent;
+ }
+
+ .tl-clone-button:hover {
+ fill: var(--tl-selectStroke);
+ }
+
.tl-bounds {
pointer-events: none;
contain: layout style size;
diff --git a/packages/core/src/hooks/useZoomEvents.ts b/packages/core/src/hooks/useZoomEvents.ts
index f5d01c428..b60257db6 100644
--- a/packages/core/src/hooks/useZoomEvents.ts
+++ b/packages/core/src/hooks/useZoomEvents.ts
@@ -31,38 +31,28 @@ export function useZoomEvents(zoom: number, ref: React.Re
}
}, [])
- React.useEffect(() => {
- const elm = ref.current
-
- function handleWheel(e: WheelEvent) {
- if (e.altKey) {
- const point = inputs.pointer?.point ?? [inputs.bounds.width / 2, inputs.bounds.height / 2]
-
- const info = inputs.pinch(point, point)
-
- callbacks.onZoom?.({ ...info, delta: [...point, e.deltaY] }, e)
- return
- }
-
- e.preventDefault()
-
- if (inputs.isPinching) return
-
- if (Vec.isEqual([e.deltaX, e.deltaY], [0, 0])) return
-
- const info = inputs.pan([e.deltaX, e.deltaY], e as WheelEvent)
-
- callbacks.onPan?.(info, e)
- }
-
- elm?.addEventListener('wheel', handleWheel, { passive: false })
- return () => {
- elm?.removeEventListener('wheel', handleWheel)
- }
- }, [ref, callbacks, inputs])
-
useGesture(
{
+ onWheel: ({ delta, event: e }) => {
+ if (e.altKey && e.buttons === 0) {
+ const point = inputs.pointer?.point ?? [inputs.bounds.width / 2, inputs.bounds.height / 2]
+
+ const info = inputs.pinch(point, point)
+
+ callbacks.onZoom?.({ ...info, delta: [...point, -e.deltaY] }, e)
+ return
+ }
+
+ e.preventDefault()
+
+ if (inputs.isPinching) return
+
+ if (Vec.isEqual(delta, [0, 0])) return
+
+ const info = inputs.pan(delta, e as WheelEvent)
+
+ callbacks.onPan?.(info, e)
+ },
onPinchStart: ({ origin, event }) => {
const elm = ref.current
diff --git a/packages/core/src/shapes/createShape.tsx b/packages/core/src/shapes/createShape.tsx
index 64a155afc..4b3935961 100644
--- a/packages/core/src/shapes/createShape.tsx
+++ b/packages/core/src/shapes/createShape.tsx
@@ -26,6 +26,8 @@ export const ShapeUtil = function {
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index c0103b8e3..7b4407905 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -147,6 +147,11 @@ export type TLKeyboardEventHandler = (key: string, info: TLKeyboardInfo, e: Keyb
export type TLPointerEventHandler = (info: TLPointerInfo, e: React.PointerEvent) => void
+export type TLShapeCloneHandler = (
+ info: TLPointerInfo<'top' | 'left' | 'right' | 'bottom'>,
+ e: React.PointerEvent
+) => void
+
export type TLCanvasEventHandler = (info: TLPointerInfo<'canvas'>, e: React.PointerEvent) => void
export type TLBoundsEventHandler = (info: TLPointerInfo<'bounds'>, e: React.PointerEvent) => void
@@ -215,6 +220,7 @@ export interface TLCallbacks {
// Misc
onShapeChange: TLShapeChangeHandler
onShapeBlur: TLShapeBlurHandler
+ onShapeClone: TLShapeCloneHandler
onRenderCountChange: (ids: string[]) => void
onError: (error: Error) => void
onBoundsChange: (bounds: TLBounds) => void
@@ -333,17 +339,13 @@ export type TLShapeUtil<
canEdit: boolean
+ canClone: boolean
+
canBind: boolean
isStateful: boolean
- minHeight: number
-
- minWidth: number
-
- maxHeight: number
-
- maxWidth: number
+ showBounds: boolean
getRotatedBounds(this: TLShapeUtil, shape: T): TLBounds
diff --git a/packages/tldraw/src/components/tldraw/tldraw.tsx b/packages/tldraw/src/components/tldraw/tldraw.tsx
index fdd1a1dd5..3e665b4c4 100644
--- a/packages/tldraw/src/components/tldraw/tldraw.tsx
+++ b/packages/tldraw/src/components/tldraw/tldraw.tsx
@@ -274,6 +274,7 @@ function InnerTldraw({
onRenderCountChange={tlstate.onRenderCountChange}
onShapeChange={tlstate.onShapeChange}
onShapeBlur={tlstate.onShapeBlur}
+ onShapeClone={tlstate.onShapeClone}
onBoundsChange={tlstate.updateBounds}
onKeyDown={tlstate.onKeyDown}
onKeyUp={tlstate.onKeyUp}
diff --git a/packages/tldraw/src/shape/shapes/draw/draw.tsx b/packages/tldraw/src/shape/shapes/draw/draw.tsx
index f00f979b9..a3ee1761a 100644
--- a/packages/tldraw/src/shape/shapes/draw/draw.tsx
+++ b/packages/tldraw/src/shape/shapes/draw/draw.tsx
@@ -49,7 +49,7 @@ export const Draw = new ShapeUtil(() => ({
const verySmall = bounds.width <= strokeWidth / 2 && bounds.height <= strokeWidth / 2
if (verySmall) {
- const sw = (1 + strokeWidth) / 2
+ const sw = 1 + strokeWidth
return (
@@ -140,8 +140,6 @@ export const Draw = new ShapeUtil(() => ({
return getSolidStrokePathData(shape, false)
}, [points])
- if (!shape) return null
-
const bounds = this.getBounds(shape)
const verySmall = bounds.width < 4 && bounds.height < 4
@@ -321,7 +319,7 @@ function getDrawStrokePathData(shape: DrawShape, isComplete: boolean) {
function getSolidStrokePathData(shape: DrawShape, isComplete: boolean) {
const { points } = shape
- if (points.length === 0) return 'M 0 0 L 0 0'
+ if (points.length < 2) return 'M 0 0 L 0 0'
const options = getOptions(shape, isComplete)
diff --git a/packages/tldraw/src/shape/shapes/sticky/sticky.tsx b/packages/tldraw/src/shape/shapes/sticky/sticky.tsx
index 83b8023b5..26973410d 100644
--- a/packages/tldraw/src/shape/shapes/sticky/sticky.tsx
+++ b/packages/tldraw/src/shape/shapes/sticky/sticky.tsx
@@ -27,6 +27,8 @@ export const Sticky = new ShapeUtil(()
canEdit: true,
+ canClone: true,
+
pathCache: new WeakMap([]),
defaultProps: {
@@ -78,6 +80,11 @@ export const Sticky = new ShapeUtil(()
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') return
+ if (e.key === 'Tab' && shape.text.length === 0) {
+ e.preventDefault()
+ return
+ }
+
e.stopPropagation()
if (e.key === 'Tab') {
diff --git a/packages/tldraw/src/state/tlstate.ts b/packages/tldraw/src/state/tlstate.ts
index 232183de9..b958cd8a9 100644
--- a/packages/tldraw/src/state/tlstate.ts
+++ b/packages/tldraw/src/state/tlstate.ts
@@ -6,6 +6,7 @@ import {
TLBoundsEventHandler,
TLBoundsHandleEventHandler,
TLKeyboardEventHandler,
+ TLShapeCloneHandler,
TLCanvasEventHandler,
TLPageState,
TLPinchEventHandler,
@@ -2182,6 +2183,7 @@ export class TLDrawState extends StateManager {
}
onZoom: TLWheelEventHandler = (info, e) => {
+ if (this.state.appState.status !== TLDrawStatus.Idle) return
this.zoom(info.delta[2] / 100, info.delta)
this.onPointerMove(info, e as unknown as React.PointerEvent)
}
@@ -2322,6 +2324,8 @@ export class TLDrawState extends StateManager {
this.currentTool.onShapeBlur?.()
}
+ onShapeClone: TLShapeCloneHandler = (info, e) => this.currentTool.onShapeClone?.(info, e)
+
onRenderCountChange = (ids: string[]) => {
const appState = this.getAppState()
if (appState.isEmptyCanvas && ids.length > 0) {
diff --git a/packages/tldraw/src/state/tool/BaseTool/BaseTool.ts b/packages/tldraw/src/state/tool/BaseTool/BaseTool.ts
index 5b3685347..a885a4fcb 100644
--- a/packages/tldraw/src/state/tool/BaseTool/BaseTool.ts
+++ b/packages/tldraw/src/state/tool/BaseTool/BaseTool.ts
@@ -5,6 +5,8 @@ import type {
TLKeyboardEventHandler,
TLPinchEventHandler,
TLPointerEventHandler,
+ TLShapeBlurHandler,
+ TLShapeCloneHandler,
TLWheelEventHandler,
} from '~../../core/src/types'
import type { TLDrawState } from '~state'
@@ -105,5 +107,6 @@ export abstract class BaseTool {
onReleaseHandle?: TLPointerEventHandler
// Misc
- onShapeBlur?: () => void
+ onShapeBlur?: TLShapeBlurHandler
+ onShapeClone?: TLShapeCloneHandler
}
diff --git a/packages/tldraw/src/state/tool/SelectTool/SelectTool.ts b/packages/tldraw/src/state/tool/SelectTool/SelectTool.ts
index 153c164c2..0ef64a3ce 100644
--- a/packages/tldraw/src/state/tool/SelectTool/SelectTool.ts
+++ b/packages/tldraw/src/state/tool/SelectTool/SelectTool.ts
@@ -7,6 +7,7 @@ import {
TLPointerEventHandler,
TLPinchEventHandler,
TLKeyboardEventHandler,
+ TLShapeCloneHandler,
Utils,
} from '@tldraw/core'
import { SessionType, TLDrawShapeType } from '~types'
@@ -19,6 +20,8 @@ enum Status {
PointingCanvas = 'pointingCanvas',
PointingHandle = 'pointingHandle',
PointingBounds = 'pointingBounds',
+ PointingClone = 'pointingClone',
+ TranslatingClone = 'translatingClone',
PointingBoundsHandle = 'pointingBoundsHandle',
TranslatingHandle = 'translatingHandle',
Translating = 'translating',
@@ -68,6 +71,51 @@ export class SelectTool extends BaseTool {
this.setStatus(Status.Idle)
}
+ getShapeClone = (id: string, side: 'top' | 'right' | 'bottom' | 'left') => {
+ const shape = this.state.getShape(id)
+
+ const utils = TLDR.getShapeUtils(shape)
+
+ if (utils.canClone) {
+ const bounds = utils.getBounds(shape)
+
+ const center = utils.getCenter(shape)
+
+ let point =
+ side === 'top'
+ ? [bounds.minX, bounds.minY - (bounds.height + 32)]
+ : side === 'right'
+ ? [bounds.maxX + 32, bounds.minY]
+ : side === 'bottom'
+ ? [bounds.minX, bounds.maxY + 32]
+ : [bounds.minX - (bounds.width + 32), bounds.minY]
+
+ if (shape.rotation !== 0) {
+ const newCenter = Vec.add(point, [bounds.width / 2, bounds.height / 2])
+
+ const rotatedCenter = Vec.rotWith(newCenter, center, shape.rotation || 0)
+
+ point = Vec.sub(rotatedCenter, [bounds.width / 2, bounds.height / 2])
+ }
+
+ const id = Utils.uniqueId()
+
+ const clone = {
+ ...shape,
+ id,
+ point,
+ }
+
+ if (clone.type === TLDrawShapeType.Sticky) {
+ clone.text = ''
+ }
+
+ return clone
+ }
+
+ return
+ }
+
/* ----------------- Event Handlers ----------------- */
onCancel = () => {
@@ -83,6 +131,24 @@ export class SelectTool extends BaseTool {
return
}
+ if (key === 'Tab') {
+ if (this.status === Status.Idle && this.state.selectedIds.length === 1) {
+ const [selectedId] = this.state.selectedIds
+
+ const clonedShape = this.getShapeClone(selectedId, 'right')
+
+ if (clonedShape) {
+ this.state.createShapes(clonedShape)
+
+ this.setStatus(Status.Idle)
+ this.state.setEditingId(clonedShape.id)
+ this.state.select(clonedShape.id)
+ }
+ }
+
+ return
+ }
+
if (key === 'Meta' || key === 'Control') {
// TODO: Make all sessions have all of these arguments
this.state.updateSession(
@@ -146,6 +212,15 @@ export class SelectTool extends BaseTool {
return
}
+ if (this.status === Status.PointingClone) {
+ if (Vec.dist(info.origin, info.point) > 4) {
+ this.setStatus(Status.TranslatingClone)
+ const point = this.state.getPagePoint(info.origin)
+ this.state.startSession(SessionType.Translate, point)
+ }
+ return
+ }
+
if (this.status === Status.PointingBounds) {
if (Vec.dist(info.origin, info.point) > 4) {
this.setStatus(Status.Translating)
@@ -194,6 +269,16 @@ export class SelectTool extends BaseTool {
}
onPointerUp: TLPointerEventHandler = (info) => {
+ if (this.status === Status.TranslatingClone || this.status === Status.PointingClone) {
+ if (this.pointedId) {
+ this.state.completeSession()
+ this.state.setEditingId(this.pointedId)
+ }
+ this.setStatus(Status.Idle)
+ this.pointedId = undefined
+ return
+ }
+
if (this.status === Status.PointingBounds) {
if (info.target === 'bounds') {
// If we just clicked the selecting bounds's background,
@@ -466,4 +551,21 @@ export class SelectTool extends BaseTool {
this.state.pinchZoom(info.point, info.delta, info.delta[2])
this.onPointerMove(info, e as unknown as React.PointerEvent)
}
+
+ /* ---------------------- Misc ---------------------- */
+
+ onShapeClone: TLShapeCloneHandler = (info) => {
+ const selectedShapeId = this.state.selectedIds[0]
+
+ const clonedShape = this.getShapeClone(selectedShapeId, info.target)
+
+ if (clonedShape) {
+ this.state.createShapes(clonedShape)
+
+ // Now start pointing the bounds, so that a user can start
+ // dragging to reposition if they wish.
+ this.pointedId = clonedShape.id
+ this.setStatus(Status.PointingClone)
+ }
+ }
}