
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>
506 lines
16 KiB
TypeScript
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>
|
|
)
|
|
}
|