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) + } + } }