[feature] Side cloning (#149)

* Adds side clone behavior

* Adds tab to clone, fixes wheel

* Fix bug in draw shape

* Passing tests
This commit is contained in:
Steve Ruiz 2021-10-14 16:37:52 +01:00 committed by GitHub
parent 0183a4d5a2
commit 46c9ac508d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 279 additions and 62 deletions

View file

@ -12,6 +12,7 @@ describe('bounds', () => {
viewportWidth={1000} viewportWidth={1000}
isLocked={false} isLocked={false}
isHidden={false} isHidden={false}
showCloneButtons={false}
/> />
) )
}) })

View file

@ -5,6 +5,7 @@ import { CenterHandle } from './center-handle'
import { RotateHandle } from './rotate-handle' import { RotateHandle } from './rotate-handle'
import { CornerHandle } from './corner-handle' import { CornerHandle } from './corner-handle'
import { EdgeHandle } from './edge-handle' import { EdgeHandle } from './edge-handle'
import { CloneButtons } from './clone-buttons'
import { Container } from '+components/container' import { Container } from '+components/container'
import { SVGContainer } from '+components/svg-container' import { SVGContainer } from '+components/svg-container'
@ -14,11 +15,21 @@ interface BoundsProps {
rotation: number rotation: number
isLocked: boolean isLocked: boolean
isHidden: boolean isHidden: boolean
showCloneButtons: boolean
viewportWidth: number viewportWidth: number
children?: React.ReactNode
} }
export const Bounds = React.memo( 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 // Touch target size
const targetSize = (viewportWidth < 768 ? 16 : 8) / zoom const targetSize = (viewportWidth < 768 ? 16 : 8) / zoom
// Handle size // Handle size
@ -32,8 +43,8 @@ export const Bounds = React.memo(
return ( return (
<Container bounds={bounds} rotation={rotation}> <Container bounds={bounds} rotation={rotation}>
<SVGContainer opacity={isHidden ? 0 : 1}> <SVGContainer>
<CenterHandle bounds={bounds} isLocked={isLocked} /> <CenterHandle bounds={bounds} isLocked={isLocked} isHidden={isHidden} />
<EdgeHandle <EdgeHandle
targetSize={targetSize} targetSize={targetSize}
size={size} size={size}
@ -96,6 +107,7 @@ export const Bounds = React.memo(
bounds={bounds} bounds={bounds}
isHidden={!showHandles || !showRotateHandle} isHidden={!showHandles || !showRotateHandle}
/> />
{showCloneButtons && <CloneButtons bounds={bounds} />}
</SVGContainer> </SVGContainer>
</Container> </Container>
) )

View file

@ -4,9 +4,11 @@ import type { TLBounds } from '+types'
export interface CenterHandleProps { export interface CenterHandleProps {
bounds: TLBounds bounds: TLBounds
isLocked: boolean isLocked: boolean
isHidden: boolean
} }
export const CenterHandle = React.memo(({ bounds, isLocked }: CenterHandleProps): JSX.Element => { export const CenterHandle = React.memo(
({ bounds, isLocked, isHidden }: CenterHandleProps): JSX.Element => {
return ( return (
<rect <rect
className={isLocked ? 'tl-bounds-center tl-dashed' : 'tl-bounds-center'} className={isLocked ? 'tl-bounds-center tl-dashed' : 'tl-bounds-center'}
@ -14,7 +16,9 @@ export const CenterHandle = React.memo(({ bounds, isLocked }: CenterHandleProps)
y={-1} y={-1}
width={bounds.width + 2} width={bounds.width + 2}
height={bounds.height + 2} height={bounds.height + 2}
opacity={isHidden ? 0 : 1}
pointerEvents="none" pointerEvents="none"
/> />
) )
}) }
)

View file

@ -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<SVGCircleElement>) => {
e.stopPropagation()
const info = inputs.pointerDown(e, side)
callbacks.onShapeClone?.(info, e)
},
[callbacks.onShapeClone]
)
return (
<g className="tl-clone-button-target" transform={`translate(${x}, ${y})`}>
<rect className="tl-transparent" width={88} height={88} x={-44} y={-44} />
<circle className="tl-clone-button" onPointerDown={handleClick} />
</g>
)
}

View file

@ -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 (
<>
<CloneButton bounds={bounds} side="top" />
<CloneButton bounds={bounds} side="right" />
<CloneButton bounds={bounds} side="bottom" />
<CloneButton bounds={bounds} side="left" />
</>
)
}

View file

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import * as React from 'react' 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 { useSelection, useShapeTree, useHandles, useTLContext } from '+hooks'
import { Bounds } from '+components/bounds' import { Bounds } from '+components/bounds'
import { BoundsBg } from '+components/bounds/bounds-bg' import { BoundsBg } from '+components/bounds/bounds-bg'
@ -39,8 +39,6 @@ export const Page = React.memo(function Page<T extends TLShape, M extends Record
callbacks.onRenderCountChange callbacks.onRenderCountChange
) )
const { shapeWithHandles } = useHandles(page, pageState)
const { bounds, isLocked, rotation } = useSelection(page, pageState, shapeUtils) const { bounds, isLocked, rotation } = useSelection(page, pageState, shapeUtils)
const { const {
@ -49,6 +47,23 @@ export const Page = React.memo(function Page<T extends TLShape, M extends Record
camera: { zoom }, camera: { zoom },
} = pageState } = pageState
let showCloneButtons = false
let shapeWithHandles: TLShape | undefined = undefined
if (selectedIds.length === 1) {
const id = selectedIds[0]
const shape = page.shapes[id]
const utils = shapeUtils[shape.type] as TLShapeUtil<any, any>
showCloneButtons = utils.canClone
if (shape.handles !== undefined) {
shapeWithHandles = shape
}
}
return ( return (
<> <>
{bounds && !hideBounds && <BoundsBg bounds={bounds} rotation={rotation} />} {bounds && !hideBounds && <BoundsBg bounds={bounds} rotation={rotation} />}
@ -57,9 +72,10 @@ export const Page = React.memo(function Page<T extends TLShape, M extends Record
))} ))}
{!hideIndicators && {!hideIndicators &&
selectedIds selectedIds
.map((id) => page.shapes[id])
.filter(Boolean) .filter(Boolean)
.map((id) => ( .map((shape) => (
<ShapeIndicator key={'selected_' + id} shape={page.shapes[id]} meta={meta} isSelected /> <ShapeIndicator key={'selected_' + shape.id} shape={shape} meta={meta} isSelected />
))} ))}
{!hideIndicators && hoveredId && ( {!hideIndicators && hoveredId && (
<ShapeIndicator <ShapeIndicator
@ -77,6 +93,7 @@ export const Page = React.memo(function Page<T extends TLShape, M extends Record
isLocked={isLocked} isLocked={isLocked}
rotation={rotation} rotation={rotation}
isHidden={hideBounds} isHidden={hideBounds}
showCloneButtons={showCloneButtons}
/> />
)} )}
{!hideHandles && shapeWithHandles && <Handles shape={shapeWithHandles} />} {!hideHandles && shapeWithHandles && <Handles shape={shapeWithHandles} />}

View file

@ -266,6 +266,31 @@ const tlcss = css`
stroke: var(--tl-selectStroke); 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 { .tl-bounds {
pointer-events: none; pointer-events: none;
contain: layout style size; contain: layout style size;

View file

@ -31,16 +31,15 @@ export function useZoomEvents<T extends HTMLElement>(zoom: number, ref: React.Re
} }
}, []) }, [])
React.useEffect(() => { useGesture(
const elm = ref.current {
onWheel: ({ delta, event: e }) => {
function handleWheel(e: WheelEvent) { if (e.altKey && e.buttons === 0) {
if (e.altKey) {
const point = inputs.pointer?.point ?? [inputs.bounds.width / 2, inputs.bounds.height / 2] const point = inputs.pointer?.point ?? [inputs.bounds.width / 2, inputs.bounds.height / 2]
const info = inputs.pinch(point, point) const info = inputs.pinch(point, point)
callbacks.onZoom?.({ ...info, delta: [...point, e.deltaY] }, e) callbacks.onZoom?.({ ...info, delta: [...point, -e.deltaY] }, e)
return return
} }
@ -48,21 +47,12 @@ export function useZoomEvents<T extends HTMLElement>(zoom: number, ref: React.Re
if (inputs.isPinching) return if (inputs.isPinching) return
if (Vec.isEqual([e.deltaX, e.deltaY], [0, 0])) return if (Vec.isEqual(delta, [0, 0])) return
const info = inputs.pan([e.deltaX, e.deltaY], e as WheelEvent) const info = inputs.pan(delta, e as WheelEvent)
callbacks.onPan?.(info, e) callbacks.onPan?.(info, e)
} },
elm?.addEventListener('wheel', handleWheel, { passive: false })
return () => {
elm?.removeEventListener('wheel', handleWheel)
}
}, [ref, callbacks, inputs])
useGesture(
{
onPinchStart: ({ origin, event }) => { onPinchStart: ({ origin, event }) => {
const elm = ref.current const elm = ref.current

View file

@ -26,6 +26,8 @@ export const ShapeUtil = function <T extends TLShape, E extends Element, M = any
isStateful: false, isStateful: false,
canClone: false,
isAspectRatioLocked: false, isAspectRatioLocked: false,
create: (props) => { create: (props) => {

View file

@ -147,6 +147,11 @@ export type TLKeyboardEventHandler = (key: string, info: TLKeyboardInfo, e: Keyb
export type TLPointerEventHandler = (info: TLPointerInfo<string>, e: React.PointerEvent) => void export type TLPointerEventHandler = (info: TLPointerInfo<string>, 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 TLCanvasEventHandler = (info: TLPointerInfo<'canvas'>, e: React.PointerEvent) => void
export type TLBoundsEventHandler = (info: TLPointerInfo<'bounds'>, e: React.PointerEvent) => void export type TLBoundsEventHandler = (info: TLPointerInfo<'bounds'>, e: React.PointerEvent) => void
@ -215,6 +220,7 @@ export interface TLCallbacks<T extends TLShape> {
// Misc // Misc
onShapeChange: TLShapeChangeHandler<T, any> onShapeChange: TLShapeChangeHandler<T, any>
onShapeBlur: TLShapeBlurHandler<any> onShapeBlur: TLShapeBlurHandler<any>
onShapeClone: TLShapeCloneHandler
onRenderCountChange: (ids: string[]) => void onRenderCountChange: (ids: string[]) => void
onError: (error: Error) => void onError: (error: Error) => void
onBoundsChange: (bounds: TLBounds) => void onBoundsChange: (bounds: TLBounds) => void
@ -333,17 +339,13 @@ export type TLShapeUtil<
canEdit: boolean canEdit: boolean
canClone: boolean
canBind: boolean canBind: boolean
isStateful: boolean isStateful: boolean
minHeight: number showBounds: boolean
minWidth: number
maxHeight: number
maxWidth: number
getRotatedBounds(this: TLShapeUtil<T, E, M>, shape: T): TLBounds getRotatedBounds(this: TLShapeUtil<T, E, M>, shape: T): TLBounds

View file

@ -274,6 +274,7 @@ function InnerTldraw({
onRenderCountChange={tlstate.onRenderCountChange} onRenderCountChange={tlstate.onRenderCountChange}
onShapeChange={tlstate.onShapeChange} onShapeChange={tlstate.onShapeChange}
onShapeBlur={tlstate.onShapeBlur} onShapeBlur={tlstate.onShapeBlur}
onShapeClone={tlstate.onShapeClone}
onBoundsChange={tlstate.updateBounds} onBoundsChange={tlstate.updateBounds}
onKeyDown={tlstate.onKeyDown} onKeyDown={tlstate.onKeyDown}
onKeyUp={tlstate.onKeyUp} onKeyUp={tlstate.onKeyUp}

View file

@ -49,7 +49,7 @@ export const Draw = new ShapeUtil<DrawShape, SVGSVGElement, TLDrawMeta>(() => ({
const verySmall = bounds.width <= strokeWidth / 2 && bounds.height <= strokeWidth / 2 const verySmall = bounds.width <= strokeWidth / 2 && bounds.height <= strokeWidth / 2
if (verySmall) { if (verySmall) {
const sw = (1 + strokeWidth) / 2 const sw = 1 + strokeWidth
return ( return (
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}> <SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
@ -140,8 +140,6 @@ export const Draw = new ShapeUtil<DrawShape, SVGSVGElement, TLDrawMeta>(() => ({
return getSolidStrokePathData(shape, false) return getSolidStrokePathData(shape, false)
}, [points]) }, [points])
if (!shape) return null
const bounds = this.getBounds(shape) const bounds = this.getBounds(shape)
const verySmall = bounds.width < 4 && bounds.height < 4 const verySmall = bounds.width < 4 && bounds.height < 4
@ -321,7 +319,7 @@ function getDrawStrokePathData(shape: DrawShape, isComplete: boolean) {
function getSolidStrokePathData(shape: DrawShape, isComplete: boolean) { function getSolidStrokePathData(shape: DrawShape, isComplete: boolean) {
const { points } = shape 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) const options = getOptions(shape, isComplete)

View file

@ -27,6 +27,8 @@ export const Sticky = new ShapeUtil<StickyShape, HTMLDivElement, TLDrawMeta>(()
canEdit: true, canEdit: true,
canClone: true,
pathCache: new WeakMap<number[], string>([]), pathCache: new WeakMap<number[], string>([]),
defaultProps: { defaultProps: {
@ -78,6 +80,11 @@ export const Sticky = new ShapeUtil<StickyShape, HTMLDivElement, TLDrawMeta>(()
(e: React.KeyboardEvent<HTMLTextAreaElement>) => { (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape') return if (e.key === 'Escape') return
if (e.key === 'Tab' && shape.text.length === 0) {
e.preventDefault()
return
}
e.stopPropagation() e.stopPropagation()
if (e.key === 'Tab') { if (e.key === 'Tab') {

View file

@ -6,6 +6,7 @@ import {
TLBoundsEventHandler, TLBoundsEventHandler,
TLBoundsHandleEventHandler, TLBoundsHandleEventHandler,
TLKeyboardEventHandler, TLKeyboardEventHandler,
TLShapeCloneHandler,
TLCanvasEventHandler, TLCanvasEventHandler,
TLPageState, TLPageState,
TLPinchEventHandler, TLPinchEventHandler,
@ -2182,6 +2183,7 @@ export class TLDrawState extends StateManager<Data> {
} }
onZoom: TLWheelEventHandler = (info, e) => { onZoom: TLWheelEventHandler = (info, e) => {
if (this.state.appState.status !== TLDrawStatus.Idle) return
this.zoom(info.delta[2] / 100, info.delta) this.zoom(info.delta[2] / 100, info.delta)
this.onPointerMove(info, e as unknown as React.PointerEvent) this.onPointerMove(info, e as unknown as React.PointerEvent)
} }
@ -2322,6 +2324,8 @@ export class TLDrawState extends StateManager<Data> {
this.currentTool.onShapeBlur?.() this.currentTool.onShapeBlur?.()
} }
onShapeClone: TLShapeCloneHandler = (info, e) => this.currentTool.onShapeClone?.(info, e)
onRenderCountChange = (ids: string[]) => { onRenderCountChange = (ids: string[]) => {
const appState = this.getAppState() const appState = this.getAppState()
if (appState.isEmptyCanvas && ids.length > 0) { if (appState.isEmptyCanvas && ids.length > 0) {

View file

@ -5,6 +5,8 @@ import type {
TLKeyboardEventHandler, TLKeyboardEventHandler,
TLPinchEventHandler, TLPinchEventHandler,
TLPointerEventHandler, TLPointerEventHandler,
TLShapeBlurHandler,
TLShapeCloneHandler,
TLWheelEventHandler, TLWheelEventHandler,
} from '~../../core/src/types' } from '~../../core/src/types'
import type { TLDrawState } from '~state' import type { TLDrawState } from '~state'
@ -105,5 +107,6 @@ export abstract class BaseTool {
onReleaseHandle?: TLPointerEventHandler onReleaseHandle?: TLPointerEventHandler
// Misc // Misc
onShapeBlur?: () => void onShapeBlur?: TLShapeBlurHandler
onShapeClone?: TLShapeCloneHandler
} }

View file

@ -7,6 +7,7 @@ import {
TLPointerEventHandler, TLPointerEventHandler,
TLPinchEventHandler, TLPinchEventHandler,
TLKeyboardEventHandler, TLKeyboardEventHandler,
TLShapeCloneHandler,
Utils, Utils,
} from '@tldraw/core' } from '@tldraw/core'
import { SessionType, TLDrawShapeType } from '~types' import { SessionType, TLDrawShapeType } from '~types'
@ -19,6 +20,8 @@ enum Status {
PointingCanvas = 'pointingCanvas', PointingCanvas = 'pointingCanvas',
PointingHandle = 'pointingHandle', PointingHandle = 'pointingHandle',
PointingBounds = 'pointingBounds', PointingBounds = 'pointingBounds',
PointingClone = 'pointingClone',
TranslatingClone = 'translatingClone',
PointingBoundsHandle = 'pointingBoundsHandle', PointingBoundsHandle = 'pointingBoundsHandle',
TranslatingHandle = 'translatingHandle', TranslatingHandle = 'translatingHandle',
Translating = 'translating', Translating = 'translating',
@ -68,6 +71,51 @@ export class SelectTool extends BaseTool {
this.setStatus(Status.Idle) 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 ----------------- */ /* ----------------- Event Handlers ----------------- */
onCancel = () => { onCancel = () => {
@ -83,6 +131,24 @@ export class SelectTool extends BaseTool {
return 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') { if (key === 'Meta' || key === 'Control') {
// TODO: Make all sessions have all of these arguments // TODO: Make all sessions have all of these arguments
this.state.updateSession( this.state.updateSession(
@ -146,6 +212,15 @@ export class SelectTool extends BaseTool {
return 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 (this.status === Status.PointingBounds) {
if (Vec.dist(info.origin, info.point) > 4) { if (Vec.dist(info.origin, info.point) > 4) {
this.setStatus(Status.Translating) this.setStatus(Status.Translating)
@ -194,6 +269,16 @@ export class SelectTool extends BaseTool {
} }
onPointerUp: TLPointerEventHandler = (info) => { 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 (this.status === Status.PointingBounds) {
if (info.target === 'bounds') { if (info.target === 'bounds') {
// If we just clicked the selecting bounds's background, // 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.state.pinchZoom(info.point, info.delta, info.delta[2])
this.onPointerMove(info, e as unknown as React.PointerEvent) 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)
}
}
} }