tldraw/packages/editor/src/lib/components/SelectionFg.tsx
Steve Ruiz e3cf05f408
Add playwright tests (#1484)
This PR replaces our webdriver end to end tests with playwright tests.

It:
- replaces our webdriver workflow with a new e2e workflow based on
playwright
- removes the webdriver project
- adds e2e tests to our examples app
- replaces all `data-wd` attributes with `data-testid`

### Coverage

Most of the tests from our previous e2e tests are reproduced here,
though there are some related to our gestures that will need to be done
in a different way—or not at all. I've also added a handful of new
tests, too.

### Where are they

The tests are now part of our examples app rather than being in its own
different app. This should help us test our different examples too. As
far as I can tell there are no downsides here in terms of the regular
developer experience, though they might complicate any CodeSandbox
projects that are hooked into the examples app.

### Change Type

- [x] `tests` — Changes to any testing-related code only (will not
publish a new version)
2023-05-30 15:28:56 +01:00

502 lines
15 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
// 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)
const showMobileRotateHandle =
isCoarsePointer &&
(!isSmallX || !isSmallY) &&
(shouldDisplayControls || showCropHandles) &&
(onlyShape ? !app.getShapeUtil(onlyShape).hideRotateHandle(onlyShape) : true)
const showResizeHandles =
shouldDisplayControls &&
(onlyShape
? app.getShapeUtil(onlyShape).canResize(onlyShape) &&
!app.getShapeUtil(onlyShape).hideResizeHandles(onlyShape)
: true) &&
!showCropHandles
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>
)
}