
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)
502 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|