kopia lustrzana https://github.com/Tldraw/Tldraw
520 wiersze
16 KiB
TypeScript
520 wiersze
16 KiB
TypeScript
import { RotateCorner, toDomPrecision } from '@tldraw/primitives'
|
|
import { track } from '@tldraw/state'
|
|
import classNames from 'classnames'
|
|
import { useRef } from 'react'
|
|
import { EmbedShapeUtil } from '../editor/shapes/embed/EmbedShapeUtil'
|
|
import { TextShapeUtil } from '../editor/shapes/text/TextShapeUtil'
|
|
import { getCursor } from '../hooks/useCursor'
|
|
import { useEditor } from '../hooks/useEditor'
|
|
import { useSelectionEvents } from '../hooks/useSelectionEvents'
|
|
import { useTransform } from '../hooks/useTransform'
|
|
import { CropHandles } from './CropHandles'
|
|
|
|
const IS_FIREFOX =
|
|
typeof navigator !== 'undefined' &&
|
|
navigator.userAgent &&
|
|
navigator.userAgent.toLowerCase().indexOf('firefox') > -1
|
|
|
|
export const SelectionFg = track(function SelectionFg() {
|
|
const editor = useEditor()
|
|
const rSvg = useRef<SVGSVGElement>(null)
|
|
|
|
const isReadonlyMode = editor.isReadOnly
|
|
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.cursor.type === 'default'
|
|
const isCoarsePointer = editor.isCoarsePointer
|
|
|
|
let bounds = editor.selectionBounds
|
|
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)
|
|
|
|
const zoom = editor.zoomLevel
|
|
const rotation = editor.selectionRotation
|
|
const isChangingStyles = editor.isChangingStyle
|
|
|
|
const width = Math.max(1, bounds.width)
|
|
const height = Math.max(1, 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) &&
|
|
!isChangingStyles
|
|
|
|
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',
|
|
'select.editing_shape'
|
|
)) ||
|
|
(showSelectionBounds &&
|
|
editor.isIn('select.resizing') &&
|
|
onlyShape &&
|
|
editor.isShapeOfType(onlyShape, TextShapeUtil))
|
|
|
|
if (
|
|
onlyShape &&
|
|
editor.isShapeOfType(onlyShape, EmbedShapeUtil) &&
|
|
shouldDisplayBox &&
|
|
IS_FIREFOX
|
|
) {
|
|
shouldDisplayBox = false
|
|
}
|
|
|
|
const showCropHandles =
|
|
editor.isInAny(
|
|
'select.pointing_crop_handle',
|
|
'select.crop.idle',
|
|
'select.crop.pointing_crop'
|
|
) &&
|
|
!isChangingStyles &&
|
|
!isReadonlyMode
|
|
|
|
const shouldDisplayControls =
|
|
editor.isInAny(
|
|
'select.idle',
|
|
'select.pointing_selection',
|
|
'select.pointing_shape',
|
|
'select.crop.idle'
|
|
) &&
|
|
!isChangingStyles &&
|
|
!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(onlyShape, TextShapeUtil) &&
|
|
textHandleHeight * zoom >= 4
|
|
|
|
return (
|
|
<svg
|
|
ref={rSvg}
|
|
className="tl-overlays__item tl-selection__fg"
|
|
data-testid="selection-foreground"
|
|
>
|
|
{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(Math.max(1, 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(Math.max(1, 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(Math.max(1, 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(Math.max(1, 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 && (
|
|
<CropHandles
|
|
{...{
|
|
size,
|
|
width,
|
|
height,
|
|
hideAlternateHandles: hideAlternateCropHandles,
|
|
}}
|
|
/>
|
|
)}
|
|
</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')
|
|
|
|
return (
|
|
<g>
|
|
<circle
|
|
data-testid={testId}
|
|
pointerEvents="all"
|
|
className={classNames('tl-transparent', 'tl-mobile-rotate__bg', { 'tl-hidden': isHidden })}
|
|
cx={cx}
|
|
cy={cy}
|
|
{...events}
|
|
/>
|
|
<circle
|
|
className={classNames('tl-mobile-rotate__fg', { 'tl-hidden': isHidden })}
|
|
cx={cx}
|
|
cy={cy}
|
|
r={size / SQUARE_ROOT_PI}
|
|
/>
|
|
</g>
|
|
)
|
|
}
|