kopia lustrzana https://github.com/Tldraw/Tldraw
527 wiersze
16 KiB
TypeScript
527 wiersze
16 KiB
TypeScript
import {
|
|
Box2d,
|
|
RotateCorner,
|
|
TLEmbedShape,
|
|
TLSelectionForegroundComponent,
|
|
TLTextShape,
|
|
getCursor,
|
|
toDomPrecision,
|
|
track,
|
|
useEditor,
|
|
useSelectionEvents,
|
|
useTransform,
|
|
useValue,
|
|
} from '@tldraw/editor'
|
|
import classNames from 'classnames'
|
|
import { useRef } from 'react'
|
|
import { useReadonly } from '../ui/hooks/useReadonly'
|
|
import { TldrawCropHandles } from './TldrawCropHandles'
|
|
|
|
/** @public */
|
|
export const TldrawSelectionForeground: TLSelectionForegroundComponent = track(
|
|
function TldrawSelectionForeground({ bounds, rotation }: { bounds: Box2d; rotation: number }) {
|
|
const editor = useEditor()
|
|
const rSvg = useRef<SVGSVGElement>(null)
|
|
|
|
const isReadonlyMode = useReadonly()
|
|
const topEvents = useSelectionEvents('top')
|
|
const rightEvents = useSelectionEvents('right')
|
|
const bottomEvents = useSelectionEvents('bottom')
|
|
const leftEvents = useSelectionEvents('left')
|
|
const topLeftEvents = useSelectionEvents('top_left')
|
|
const topRightEvents = useSelectionEvents('top_right')
|
|
const bottomRightEvents = useSelectionEvents('bottom_right')
|
|
const bottomLeftEvents = useSelectionEvents('bottom_left')
|
|
|
|
const isDefaultCursor =
|
|
!editor.isMenuOpen && editor.getInstanceState().cursor.type === 'default'
|
|
const isCoarsePointer = editor.getInstanceState().isCoarsePointer
|
|
|
|
const shapes = editor.selectedShapes
|
|
const onlyShape = editor.onlySelectedShape
|
|
const isLockedShape = onlyShape && editor.isShapeOrAncestorLocked(onlyShape)
|
|
|
|
// if all shapes have an expandBy for the selection outline, we can expand by the l
|
|
const expandOutlineBy = onlyShape
|
|
? editor.getShapeUtil(onlyShape).expandSelectionOutlinePx(onlyShape)
|
|
: 0
|
|
|
|
useTransform(rSvg, bounds?.x, bounds?.y, 1, editor.selectionRotation, {
|
|
x: -expandOutlineBy,
|
|
y: -expandOutlineBy,
|
|
})
|
|
|
|
if (!bounds) return null
|
|
bounds = bounds.clone().expandBy(expandOutlineBy).zeroFix()
|
|
|
|
const zoom = editor.zoomLevel
|
|
const isChangingStyle = editor.getInstanceState().isChangingStyle
|
|
|
|
const width = bounds.width
|
|
const height = bounds.height
|
|
|
|
const size = 8 / zoom
|
|
const isTinyX = width < size * 2
|
|
const isTinyY = height < size * 2
|
|
|
|
const isSmallX = width < size * 4
|
|
const isSmallY = height < size * 4
|
|
const isSmallCropX = width < size * 5
|
|
const isSmallCropY = height < size * 5
|
|
|
|
const mobileHandleMultiplier = isCoarsePointer ? 1.75 : 1
|
|
const targetSize = (6 / zoom) * mobileHandleMultiplier
|
|
|
|
const targetSizeX = (isSmallX ? targetSize / 2 : targetSize) * (mobileHandleMultiplier * 0.75)
|
|
const targetSizeY = (isSmallY ? targetSize / 2 : targetSize) * (mobileHandleMultiplier * 0.75)
|
|
|
|
const showSelectionBounds =
|
|
(onlyShape ? !editor.getShapeUtil(onlyShape).hideSelectionBoundsFg(onlyShape) : true) &&
|
|
!isChangingStyle
|
|
|
|
let shouldDisplayBox =
|
|
(showSelectionBounds &&
|
|
editor.isInAny(
|
|
'select.idle',
|
|
'select.brushing',
|
|
'select.scribble_brushing',
|
|
'select.pointing_canvas',
|
|
'select.pointing_selection',
|
|
'select.pointing_shape',
|
|
'select.crop.idle',
|
|
'select.crop.pointing_crop',
|
|
'select.pointing_resize_handle',
|
|
'select.pointing_crop_handle'
|
|
)) ||
|
|
(showSelectionBounds &&
|
|
editor.isIn('select.resizing') &&
|
|
onlyShape &&
|
|
editor.isShapeOfType<TLTextShape>(onlyShape, 'text'))
|
|
|
|
if (onlyShape && shouldDisplayBox) {
|
|
if (editor.environment.isFirefox && editor.isShapeOfType<TLEmbedShape>(onlyShape, 'embed')) {
|
|
shouldDisplayBox = false
|
|
}
|
|
}
|
|
|
|
const showCropHandles =
|
|
editor.isInAny(
|
|
'select.pointing_crop_handle',
|
|
'select.crop.idle',
|
|
'select.crop.pointing_crop'
|
|
) &&
|
|
!isChangingStyle &&
|
|
!isReadonlyMode
|
|
|
|
const shouldDisplayControls =
|
|
editor.isInAny(
|
|
'select.idle',
|
|
'select.pointing_selection',
|
|
'select.pointing_shape',
|
|
'select.crop.idle'
|
|
) &&
|
|
!isChangingStyle &&
|
|
!isReadonlyMode
|
|
|
|
const showCornerRotateHandles =
|
|
!isCoarsePointer &&
|
|
!(isTinyX || isTinyY) &&
|
|
(shouldDisplayControls || showCropHandles) &&
|
|
(onlyShape ? !editor.getShapeUtil(onlyShape).hideRotateHandle(onlyShape) : true) &&
|
|
!isLockedShape
|
|
|
|
const showMobileRotateHandle =
|
|
isCoarsePointer &&
|
|
(!isSmallX || !isSmallY) &&
|
|
(shouldDisplayControls || showCropHandles) &&
|
|
(onlyShape ? !editor.getShapeUtil(onlyShape).hideRotateHandle(onlyShape) : true) &&
|
|
!isLockedShape
|
|
|
|
const showResizeHandles =
|
|
shouldDisplayControls &&
|
|
(onlyShape
|
|
? editor.getShapeUtil(onlyShape).canResize(onlyShape) &&
|
|
!editor.getShapeUtil(onlyShape).hideResizeHandles(onlyShape)
|
|
: true) &&
|
|
!showCropHandles &&
|
|
!isLockedShape
|
|
|
|
const hideAlternateCornerHandles = isTinyX || isTinyY
|
|
const showOnlyOneHandle = isTinyX && isTinyY
|
|
const hideAlternateCropHandles = isSmallCropX || isSmallCropY
|
|
|
|
const showHandles = showResizeHandles || showCropHandles
|
|
const hideRotateCornerHandles = !showCornerRotateHandles
|
|
const hideMobileRotateHandle = !shouldDisplayControls || !showMobileRotateHandle
|
|
const hideTopLeftCorner = !shouldDisplayControls || !showHandles
|
|
const hideTopRightCorner = !shouldDisplayControls || !showHandles || hideAlternateCornerHandles
|
|
const hideBottomLeftCorner =
|
|
!shouldDisplayControls || !showHandles || hideAlternateCornerHandles
|
|
const hideBottomRightCorner =
|
|
!shouldDisplayControls || !showHandles || (showOnlyOneHandle && !showCropHandles)
|
|
|
|
let hideEdgeTargetsDueToCoarsePointer = isCoarsePointer
|
|
|
|
if (
|
|
hideEdgeTargetsDueToCoarsePointer &&
|
|
shapes.every((shape) => editor.getShapeUtil(shape).isAspectRatioLocked(shape))
|
|
) {
|
|
hideEdgeTargetsDueToCoarsePointer = false
|
|
}
|
|
|
|
// If we're showing crop handles, then show the edges too.
|
|
// If we're showing resize handles, then show the edges only
|
|
// if we're not hiding them for some other reason
|
|
let hideEdgeTargets = true
|
|
|
|
if (showCropHandles) {
|
|
hideEdgeTargets = hideAlternateCropHandles
|
|
} else if (showResizeHandles) {
|
|
hideEdgeTargets =
|
|
hideAlternateCornerHandles || showOnlyOneHandle || hideEdgeTargetsDueToCoarsePointer
|
|
}
|
|
|
|
const textHandleHeight = Math.min(24 / zoom, height - targetSizeY * 3)
|
|
const showTextResizeHandles =
|
|
shouldDisplayControls &&
|
|
isCoarsePointer &&
|
|
onlyShape &&
|
|
editor.isShapeOfType<TLTextShape>(onlyShape, 'text') &&
|
|
textHandleHeight * zoom >= 4
|
|
|
|
return (
|
|
<svg
|
|
className="tl-overlays__item tl-selection__fg tl-svg-context"
|
|
data-testid="selection-foreground"
|
|
>
|
|
<g ref={rSvg}>
|
|
{shouldDisplayBox && (
|
|
<rect
|
|
className={classNames('tl-selection__fg__outline')}
|
|
width={toDomPrecision(width)}
|
|
height={toDomPrecision(height)}
|
|
/>
|
|
)}
|
|
<RotateCornerHandle
|
|
data-testid="selection.rotate.top-left"
|
|
cx={0}
|
|
cy={0}
|
|
targetSize={targetSize}
|
|
corner="top_left_rotate"
|
|
cursor={isDefaultCursor ? getCursor('nwse-rotate', rotation) : undefined}
|
|
isHidden={hideRotateCornerHandles}
|
|
/>
|
|
<RotateCornerHandle
|
|
data-testid="selection.rotate.top-right"
|
|
cx={width + targetSize * 3}
|
|
cy={0}
|
|
targetSize={targetSize}
|
|
corner="top_right_rotate"
|
|
cursor={isDefaultCursor ? getCursor('nesw-rotate', rotation) : undefined}
|
|
isHidden={hideRotateCornerHandles}
|
|
/>
|
|
<RotateCornerHandle
|
|
data-testid="selection.rotate.bottom-left"
|
|
cx={0}
|
|
cy={height + targetSize * 3}
|
|
targetSize={targetSize}
|
|
corner="bottom_left_rotate"
|
|
cursor={isDefaultCursor ? getCursor('swne-rotate', rotation) : undefined}
|
|
isHidden={hideRotateCornerHandles}
|
|
/>
|
|
<RotateCornerHandle
|
|
data-testid="selection.rotate.bottom-right"
|
|
cx={width + targetSize * 3}
|
|
cy={height + targetSize * 3}
|
|
targetSize={targetSize}
|
|
corner="bottom_right_rotate"
|
|
cursor={isDefaultCursor ? getCursor('senw-rotate', rotation) : undefined}
|
|
isHidden={hideRotateCornerHandles}
|
|
/>
|
|
<MobileRotateHandle
|
|
data-testid="selection.rotate.mobile"
|
|
cx={isSmallX ? -targetSize * 1.5 : width / 2}
|
|
cy={isSmallX ? height / 2 : -targetSize * 1.5}
|
|
size={size}
|
|
isHidden={hideMobileRotateHandle}
|
|
/>
|
|
{/* Targets */}
|
|
<rect
|
|
className={classNames('tl-transparent', {
|
|
'tl-hidden': hideEdgeTargets,
|
|
})}
|
|
data-testid="selection.resize.top"
|
|
aria-label="top target"
|
|
pointerEvents="all"
|
|
x={0}
|
|
y={toDomPrecision(0 - (isSmallY ? targetSizeY * 2 : targetSizeY))}
|
|
width={toDomPrecision(width)}
|
|
height={toDomPrecision(Math.max(1, targetSizeY * 2))}
|
|
style={isDefaultCursor ? { cursor: getCursor('ns-resize', rotation) } : undefined}
|
|
{...topEvents}
|
|
/>
|
|
<rect
|
|
className={classNames('tl-transparent', {
|
|
'tl-hidden': hideEdgeTargets,
|
|
})}
|
|
data-testid="selection.resize.right"
|
|
aria-label="right target"
|
|
pointerEvents="all"
|
|
x={toDomPrecision(width - (isSmallX ? 0 : targetSizeX))}
|
|
y={0}
|
|
height={toDomPrecision(height)}
|
|
width={toDomPrecision(Math.max(1, targetSizeX * 2))}
|
|
style={isDefaultCursor ? { cursor: getCursor('ew-resize', rotation) } : undefined}
|
|
{...rightEvents}
|
|
/>
|
|
<rect
|
|
className={classNames('tl-transparent', {
|
|
'tl-hidden': hideEdgeTargets,
|
|
})}
|
|
data-testid="selection.resize.bottom"
|
|
aria-label="bottom target"
|
|
pointerEvents="all"
|
|
x={0}
|
|
y={toDomPrecision(height - (isSmallY ? 0 : targetSizeY))}
|
|
width={toDomPrecision(width)}
|
|
height={toDomPrecision(Math.max(1, targetSizeY * 2))}
|
|
style={isDefaultCursor ? { cursor: getCursor('ns-resize', rotation) } : undefined}
|
|
{...bottomEvents}
|
|
/>
|
|
<rect
|
|
className={classNames('tl-transparent', {
|
|
'tl-hidden': hideEdgeTargets,
|
|
})}
|
|
data-testid="selection.resize.left"
|
|
aria-label="left target"
|
|
pointerEvents="all"
|
|
x={toDomPrecision(0 - (isSmallX ? targetSizeX * 2 : targetSizeX))}
|
|
y={0}
|
|
height={toDomPrecision(height)}
|
|
width={toDomPrecision(Math.max(1, targetSizeX * 2))}
|
|
style={isDefaultCursor ? { cursor: getCursor('ew-resize', rotation) } : undefined}
|
|
{...leftEvents}
|
|
/>
|
|
{/* Corner Targets */}
|
|
<rect
|
|
className={classNames('tl-transparent', {
|
|
'tl-hidden': hideTopLeftCorner,
|
|
})}
|
|
data-testid="selection.target.top-left"
|
|
aria-label="top-left target"
|
|
pointerEvents="all"
|
|
x={toDomPrecision(0 - (isSmallX ? targetSizeX * 2 : targetSizeX * 1.5))}
|
|
y={toDomPrecision(0 - (isSmallY ? targetSizeY * 2 : targetSizeY * 1.5))}
|
|
width={toDomPrecision(targetSizeX * 3)}
|
|
height={toDomPrecision(targetSizeY * 3)}
|
|
style={isDefaultCursor ? { cursor: getCursor('nwse-resize', rotation) } : undefined}
|
|
{...topLeftEvents}
|
|
/>
|
|
<rect
|
|
className={classNames('tl-transparent', {
|
|
'tl-hidden': hideTopRightCorner,
|
|
})}
|
|
data-testid="selection.target.top-right"
|
|
aria-label="top-right target"
|
|
pointerEvents="all"
|
|
x={toDomPrecision(width - (isSmallX ? 0 : targetSizeX * 1.5))}
|
|
y={toDomPrecision(0 - (isSmallY ? targetSizeY * 2 : targetSizeY * 1.5))}
|
|
width={toDomPrecision(targetSizeX * 3)}
|
|
height={toDomPrecision(targetSizeY * 3)}
|
|
style={isDefaultCursor ? { cursor: getCursor('nesw-resize', rotation) } : undefined}
|
|
{...topRightEvents}
|
|
/>
|
|
<rect
|
|
className={classNames('tl-transparent', {
|
|
'tl-hidden': hideBottomRightCorner,
|
|
})}
|
|
data-testid="selection.target.bottom-right"
|
|
aria-label="bottom-right target"
|
|
pointerEvents="all"
|
|
x={toDomPrecision(width - (isSmallX ? targetSizeX : targetSizeX * 1.5))}
|
|
y={toDomPrecision(height - (isSmallY ? targetSizeY : targetSizeY * 1.5))}
|
|
width={toDomPrecision(targetSizeX * 3)}
|
|
height={toDomPrecision(targetSizeY * 3)}
|
|
style={isDefaultCursor ? { cursor: getCursor('nwse-resize', rotation) } : undefined}
|
|
{...bottomRightEvents}
|
|
/>
|
|
<rect
|
|
className={classNames('tl-transparent', {
|
|
'tl-hidden': hideBottomLeftCorner,
|
|
})}
|
|
data-testid="selection.target.bottom-left"
|
|
aria-label="bottom-left target"
|
|
pointerEvents="all"
|
|
x={toDomPrecision(0 - (isSmallX ? targetSizeX * 3 : targetSizeX * 1.5))}
|
|
y={toDomPrecision(height - (isSmallY ? 0 : targetSizeY * 1.5))}
|
|
width={toDomPrecision(targetSizeX * 3)}
|
|
height={toDomPrecision(targetSizeY * 3)}
|
|
style={isDefaultCursor ? { cursor: getCursor('nesw-resize', rotation) } : undefined}
|
|
{...bottomLeftEvents}
|
|
/>
|
|
{/* Resize Handles */}
|
|
{showResizeHandles && (
|
|
<>
|
|
<rect
|
|
data-testid="selection.resize.top-left"
|
|
className={classNames('tl-corner-handle', {
|
|
'tl-hidden': hideTopLeftCorner,
|
|
})}
|
|
aria-label="top_left handle"
|
|
x={toDomPrecision(0 - size / 2)}
|
|
y={toDomPrecision(0 - size / 2)}
|
|
width={toDomPrecision(size)}
|
|
height={toDomPrecision(size)}
|
|
/>
|
|
<rect
|
|
data-testid="selection.resize.top-right"
|
|
className={classNames('tl-corner-handle', {
|
|
'tl-hidden': hideTopRightCorner,
|
|
})}
|
|
aria-label="top_right handle"
|
|
x={toDomPrecision(width - size / 2)}
|
|
y={toDomPrecision(0 - size / 2)}
|
|
width={toDomPrecision(size)}
|
|
height={toDomPrecision(size)}
|
|
/>
|
|
<rect
|
|
data-testid="selection.resize.bottom-right"
|
|
className={classNames('tl-corner-handle', {
|
|
'tl-hidden': hideBottomRightCorner,
|
|
})}
|
|
aria-label="bottom_right handle"
|
|
x={toDomPrecision(width - size / 2)}
|
|
y={toDomPrecision(height - size / 2)}
|
|
width={toDomPrecision(size)}
|
|
height={toDomPrecision(size)}
|
|
/>
|
|
<rect
|
|
data-testid="selection.resize.bottom-left"
|
|
className={classNames('tl-corner-handle', {
|
|
'tl-hidden': hideBottomLeftCorner,
|
|
})}
|
|
aria-label="bottom_left handle"
|
|
x={toDomPrecision(0 - size / 2)}
|
|
y={toDomPrecision(height - size / 2)}
|
|
width={toDomPrecision(size)}
|
|
height={toDomPrecision(size)}
|
|
/>
|
|
</>
|
|
)}
|
|
{showTextResizeHandles && (
|
|
<>
|
|
<rect
|
|
data-testid="selection.text-resize.left.handle"
|
|
className="tl-text-handle"
|
|
aria-label="bottom_left handle"
|
|
x={toDomPrecision(0 - size / 4)}
|
|
y={toDomPrecision(height / 2 - textHandleHeight / 2)}
|
|
rx={size / 4}
|
|
width={toDomPrecision(size / 2)}
|
|
height={toDomPrecision(textHandleHeight)}
|
|
/>
|
|
<rect
|
|
data-testid="selection.text-resize.right.handle"
|
|
className="tl-text-handle"
|
|
aria-label="bottom_left handle"
|
|
rx={size / 4}
|
|
x={toDomPrecision(width - size / 4)}
|
|
y={toDomPrecision(height / 2 - textHandleHeight / 2)}
|
|
width={toDomPrecision(size / 2)}
|
|
height={toDomPrecision(textHandleHeight)}
|
|
/>
|
|
</>
|
|
)}
|
|
{/* Crop Handles */}
|
|
{showCropHandles && (
|
|
<TldrawCropHandles
|
|
{...{
|
|
size,
|
|
width,
|
|
height,
|
|
hideAlternateHandles: hideAlternateCropHandles,
|
|
}}
|
|
/>
|
|
)}
|
|
</g>
|
|
</svg>
|
|
)
|
|
}
|
|
)
|
|
|
|
export const RotateCornerHandle = function RotateCornerHandle({
|
|
cx,
|
|
cy,
|
|
targetSize,
|
|
corner,
|
|
cursor,
|
|
isHidden,
|
|
'data-testid': testId,
|
|
}: {
|
|
cx: number
|
|
cy: number
|
|
targetSize: number
|
|
corner: RotateCorner
|
|
cursor?: string
|
|
isHidden: boolean
|
|
'data-testid'?: string
|
|
}) {
|
|
const events = useSelectionEvents(corner)
|
|
return (
|
|
<rect
|
|
className={classNames('tl-transparent', 'tl-rotate-corner', { 'tl-hidden': isHidden })}
|
|
data-testid={testId}
|
|
aria-label={`${corner} target`}
|
|
pointerEvents="all"
|
|
x={toDomPrecision(cx - targetSize * 3)}
|
|
y={toDomPrecision(cy - targetSize * 3)}
|
|
width={toDomPrecision(Math.max(1, targetSize * 3))}
|
|
height={toDomPrecision(Math.max(1, targetSize * 3))}
|
|
cursor={cursor}
|
|
{...events}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const SQUARE_ROOT_PI = Math.sqrt(Math.PI)
|
|
|
|
export const MobileRotateHandle = function RotateHandle({
|
|
cx,
|
|
cy,
|
|
size,
|
|
isHidden,
|
|
'data-testid': testId,
|
|
}: {
|
|
cx: number
|
|
cy: number
|
|
size: number
|
|
isHidden: boolean
|
|
'data-testid'?: string
|
|
}) {
|
|
const events = useSelectionEvents('mobile_rotate')
|
|
|
|
const editor = useEditor()
|
|
const zoom = useValue('zoom level', () => editor.zoomLevel, [editor])
|
|
const bgRadius = Math.max(14 * (1 / zoom), 20 / Math.max(1, zoom))
|
|
|
|
return (
|
|
<g>
|
|
<circle
|
|
data-testid={testId}
|
|
pointerEvents="all"
|
|
className={classNames('tl-transparent', 'tl-mobile-rotate__bg', { 'tl-hidden': isHidden })}
|
|
cx={cx}
|
|
cy={cy}
|
|
r={bgRadius}
|
|
{...events}
|
|
/>
|
|
<circle
|
|
className={classNames('tl-mobile-rotate__fg', { 'tl-hidden': isHidden })}
|
|
cx={cx}
|
|
cy={cy}
|
|
r={size / SQUARE_ROOT_PI}
|
|
/>
|
|
</g>
|
|
)
|
|
}
|