tldraw/packages/editor/src/lib/components/SelectionFg.tsx
Mitja Bezenšek d738c28c19
Add support for locking shapes (#1447)
Add support for locking shapes. 

How it works right now:
- You can lock / unlock shapes from the context menu.
- You can also lock shapes with `⇧⌘L` keyboard shortcut.
- You cannot select locked shapes: clicking on the shape, double click
to edit, select all, brush select,... should not work.
- You cannot change props of locked shapes.
- You cannot delete locked shapes.
- If a shape is grouped or within the frame the same rules apply.
- If you delete a group, that contains locked shape it will also delete
those shapes. This seems to be what other apps use as well.

Solves #1445 

### Change Type

- [x] `minor` — New Feature

### Test Plan

1. Insert a shape
2. Right click on it and lock it.
3. Test that you cannot select it, change its properties, delete it.
4. Do the same with locked groups.
5. Do the same with locked frames.

- [x] Unit Tests
- [ ] Webdriver tests

### Release Notes

- Add support for locking shapes.

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
2023-06-01 18:13:38 +00:00

506 lines
16 KiB
TypeScript

import { RotateCorner, toDomPrecision } from '@tldraw/primitives'
import classNames from 'classnames'
import { useRef } from 'react'
import { track } from 'signia-react'
import { useApp } from '../hooks/useApp'
import { getCursor } from '../hooks/useCursor'
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 app = useApp()
const rSvg = useRef<SVGSVGElement>(null)
const isReadonlyMode = app.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 = !app.isMenuOpen && app.cursor.type === 'default'
const isCoarsePointer = app.isCoarsePointer
let bounds = app.selectionBounds
const shapes = app.selectedShapes
const onlyShape = shapes.length === 1 ? shapes[0] : null
const isLockedShape = onlyShape && app.isShapeOrAncestorLocked(onlyShape)
// if all shapes have an expandBy for the selection outline, we can expand by the l
const expandOutlineBy = onlyShape
? app.getShapeUtil(onlyShape).expandSelectionOutlinePx(onlyShape)
: 0
useTransform(rSvg, bounds?.x, bounds?.y, 1, app.selectionRotation, {
x: -expandOutlineBy,
y: -expandOutlineBy,
})
if (!bounds) return null
bounds = bounds.clone().expandBy(expandOutlineBy)
const zoom = app.zoomLevel
const rotation = app.selectionRotation
const isChangingStyles = app.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 ? !app.getShapeUtil(onlyShape).hideSelectionBoundsFg(onlyShape) : true) &&
!isChangingStyles
let shouldDisplayBox =
(showSelectionBounds &&
app.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 && app.isIn('select.resizing') && onlyShape && shapes[0].type === 'text')
if (IS_FIREFOX && shouldDisplayBox) {
if (app.onlySelectedShape?.type === 'embed') {
shouldDisplayBox = false
}
}
const showCropHandles =
app.isInAny('select.pointing_crop_handle', 'select.crop.idle', 'select.crop.pointing_crop') &&
!isChangingStyles &&
!isReadonlyMode
const shouldDisplayControls =
app.isInAny(
'select.idle',
'select.pointing_selection',
'select.pointing_shape',
'select.crop.idle'
) &&
!isChangingStyles &&
!isReadonlyMode
const showCornerRotateHandles =
!isCoarsePointer &&
!(isTinyX || isTinyY) &&
(shouldDisplayControls || showCropHandles) &&
(onlyShape ? !app.getShapeUtil(onlyShape).hideRotateHandle(onlyShape) : true) &&
!isLockedShape
const showMobileRotateHandle =
isCoarsePointer &&
(!isSmallX || !isSmallY) &&
(shouldDisplayControls || showCropHandles) &&
(onlyShape ? !app.getShapeUtil(onlyShape).hideRotateHandle(onlyShape) : true) &&
!isLockedShape
const showResizeHandles =
shouldDisplayControls &&
(onlyShape
? app.getShapeUtil(onlyShape).canResize(onlyShape) &&
!app.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) => app.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?.type === 'text' &&
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>
)
}