[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:
parent
0183a4d5a2
commit
46c9ac508d
16 changed files with 279 additions and 62 deletions
|
@ -12,6 +12,7 @@ describe('bounds', () => {
|
||||||
viewportWidth={1000}
|
viewportWidth={1000}
|
||||||
isLocked={false}
|
isLocked={false}
|
||||||
isHidden={false}
|
isHidden={false}
|
||||||
|
showCloneButtons={false}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -4,17 +4,21 @@ 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(
|
||||||
return (
|
({ bounds, isLocked, isHidden }: CenterHandleProps): JSX.Element => {
|
||||||
<rect
|
return (
|
||||||
className={isLocked ? 'tl-bounds-center tl-dashed' : 'tl-bounds-center'}
|
<rect
|
||||||
x={-1}
|
className={isLocked ? 'tl-bounds-center tl-dashed' : 'tl-bounds-center'}
|
||||||
y={-1}
|
x={-1}
|
||||||
width={bounds.width + 2}
|
y={-1}
|
||||||
height={bounds.height + 2}
|
width={bounds.width + 2}
|
||||||
pointerEvents="none"
|
height={bounds.height + 2}
|
||||||
/>
|
opacity={isHidden ? 0 : 1}
|
||||||
)
|
pointerEvents="none"
|
||||||
})
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
31
packages/core/src/components/bounds/clone-button.tsx
Normal file
31
packages/core/src/components/bounds/clone-button.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
18
packages/core/src/components/bounds/clone-buttons.tsx
Normal file
18
packages/core/src/components/bounds/clone-buttons.tsx
Normal 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" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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} />}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
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 }) => {
|
onPinchStart: ({ origin, event }) => {
|
||||||
const elm = ref.current
|
const elm = ref.current
|
||||||
|
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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') {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue