Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feature] Side cloning #149

Merged
merged 4 commits into from
Oct 14, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/components/bounds/bounds.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ describe('bounds', () => {
viewportWidth={1000}
isLocked={false}
isHidden={false}
showCloneButtons={false}
/>
)
})
Expand Down
18 changes: 15 additions & 3 deletions packages/core/src/components/bounds/bounds.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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
Expand All @@ -32,8 +43,8 @@ export const Bounds = React.memo(

return (
<Container bounds={bounds} rotation={rotation}>
<SVGContainer opacity={isHidden ? 0 : 1}>
<CenterHandle bounds={bounds} isLocked={isLocked} />
<SVGContainer>
<CenterHandle bounds={bounds} isLocked={isLocked} isHidden={isHidden} />
<EdgeHandle
targetSize={targetSize}
size={size}
Expand Down Expand Up @@ -96,6 +107,7 @@ export const Bounds = React.memo(
bounds={bounds}
isHidden={!showHandles || !showRotateHandle}
/>
{showCloneButtons && <CloneButtons bounds={bounds} />}
</SVGContainer>
</Container>
)
Expand Down
28 changes: 16 additions & 12 deletions packages/core/src/components/bounds/center-handle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<rect
className={isLocked ? 'tl-bounds-center tl-dashed' : 'tl-bounds-center'}
x={-1}
y={-1}
width={bounds.width + 2}
height={bounds.height + 2}
pointerEvents="none"
/>
)
})
export const CenterHandle = React.memo(
({ bounds, isLocked, isHidden }: CenterHandleProps): JSX.Element => {
return (
<rect
className={isLocked ? 'tl-bounds-center tl-dashed' : 'tl-bounds-center'}
x={-1}
y={-1}
width={bounds.width + 2}
height={bounds.height + 2}
opacity={isHidden ? 0 : 1}
pointerEvents="none"
/>
)
}
)
31 changes: 31 additions & 0 deletions packages/core/src/components/bounds/clone-button.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
18 changes: 18 additions & 0 deletions packages/core/src/components/bounds/clone-buttons.tsx
Original file line number Diff line number Diff line change
@@ -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" />
</>
)
}
27 changes: 22 additions & 5 deletions packages/core/src/components/page/page.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -39,8 +39,6 @@ export const Page = React.memo(function Page<T extends TLShape, M extends Record
callbacks.onRenderCountChange
)

const { shapeWithHandles } = useHandles(page, pageState)

const { bounds, isLocked, rotation } = useSelection(page, pageState, shapeUtils)

const {
Expand All @@ -49,6 +47,23 @@ export const Page = React.memo(function Page<T extends TLShape, M extends Record
camera: { zoom },
} = 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 (
<>
{bounds && !hideBounds && <BoundsBg bounds={bounds} rotation={rotation} />}
Expand All @@ -57,9 +72,10 @@ export const Page = React.memo(function Page<T extends TLShape, M extends Record
))}
{!hideIndicators &&
selectedIds
.map((id) => page.shapes[id])
.filter(Boolean)
.map((id) => (
<ShapeIndicator key={'selected_' + id} shape={page.shapes[id]} meta={meta} isSelected />
.map((shape) => (
<ShapeIndicator key={'selected_' + shape.id} shape={shape} meta={meta} isSelected />
))}
{!hideIndicators && hoveredId && (
<ShapeIndicator
Expand All @@ -77,6 +93,7 @@ export const Page = React.memo(function Page<T extends TLShape, M extends Record
isLocked={isLocked}
rotation={rotation}
isHidden={hideBounds}
showCloneButtons={showCloneButtons}
/>
)}
{!hideHandles && shapeWithHandles && <Handles shape={shapeWithHandles} />}
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/hooks/useStyle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
40 changes: 15 additions & 25 deletions packages/core/src/hooks/useZoomEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,38 +31,28 @@ export function useZoomEvents<T extends HTMLElement>(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
}
useGesture(
{
onWheel: ({ delta, event: e }) => {
if (e.altKey && e.buttons === 0) {
const point = inputs.pointer?.point ?? [inputs.bounds.width / 2, inputs.bounds.height / 2]

e.preventDefault()
const info = inputs.pinch(point, point)

if (inputs.isPinching) return
callbacks.onZoom?.({ ...info, delta: [...point, -e.deltaY] }, e)
return
}

if (Vec.isEqual([e.deltaX, e.deltaY], [0, 0])) return
e.preventDefault()

const info = inputs.pan([e.deltaX, e.deltaY], e as WheelEvent)
if (inputs.isPinching) return

callbacks.onPan?.(info, e)
}
if (Vec.isEqual(delta, [0, 0])) return

elm?.addEventListener('wheel', handleWheel, { passive: false })
return () => {
elm?.removeEventListener('wheel', handleWheel)
}
}, [ref, callbacks, inputs])
const info = inputs.pan(delta, e as WheelEvent)

useGesture(
{
callbacks.onPan?.(info, e)
},
onPinchStart: ({ origin, event }) => {
const elm = ref.current

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/shapes/createShape.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export const ShapeUtil = function <T extends TLShape, E extends Element, M = any

isStateful: false,

canClone: false,

isAspectRatioLocked: false,

create: (props) => {
Expand Down
16 changes: 9 additions & 7 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
Expand Down Expand Up @@ -215,6 +220,7 @@ export interface TLCallbacks<T extends TLShape> {
// Misc
onShapeChange: TLShapeChangeHandler<T, any>
onShapeBlur: TLShapeBlurHandler<any>
onShapeClone: TLShapeCloneHandler
onRenderCountChange: (ids: string[]) => void
onError: (error: Error) => void
onBoundsChange: (bounds: TLBounds) => void
Expand Down Expand Up @@ -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<T, E, M>, shape: T): TLBounds

Expand Down
1 change: 1 addition & 0 deletions packages/tldraw/src/components/tldraw/tldraw.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
6 changes: 2 additions & 4 deletions packages/tldraw/src/shape/shapes/draw/draw.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const Draw = new ShapeUtil<DrawShape, SVGSVGElement, TLDrawMeta>(() => ({
const verySmall = bounds.width <= strokeWidth / 2 && bounds.height <= strokeWidth / 2

if (verySmall) {
const sw = (1 + strokeWidth) / 2
const sw = 1 + strokeWidth

return (
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
Expand Down Expand Up @@ -140,8 +140,6 @@ export const Draw = new ShapeUtil<DrawShape, SVGSVGElement, TLDrawMeta>(() => ({
return getSolidStrokePathData(shape, false)
}, [points])

if (!shape) return null

const bounds = this.getBounds(shape)

const verySmall = bounds.width < 4 && bounds.height < 4
Expand Down Expand Up @@ -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)

Expand Down
7 changes: 7 additions & 0 deletions packages/tldraw/src/shape/shapes/sticky/sticky.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export const Sticky = new ShapeUtil<StickyShape, HTMLDivElement, TLDrawMeta>(()

canEdit: true,

canClone: true,

pathCache: new WeakMap<number[], string>([]),

defaultProps: {
Expand Down Expand Up @@ -78,6 +80,11 @@ export const Sticky = new ShapeUtil<StickyShape, HTMLDivElement, TLDrawMeta>(()
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape') return

if (e.key === 'Tab' && shape.text.length === 0) {
e.preventDefault()
return
}

e.stopPropagation()

if (e.key === 'Tab') {
Expand Down