[fix] overlay rendering issues (#1389)

This PR fixes several issues with the way that SVG overlays were
rendered.
- fixes editing embed shape on firefox (weird SVG pointer events bug)
- fixes layering of overlays
- collaborator cursors are offset

### Change Type

- [x] `patch` — change to unshipped changes

### Test Plan

1. Try editing an embed shape on Firefox
2. Confirm that cursor hints are no longer spinning
3. Confirm that cursors are displayed correctly over other shapes
This commit is contained in:
Steve Ruiz 2023-05-16 15:35:22 +01:00 committed by GitHub
parent 267fea8d5a
commit 0cc95c271d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 467 additions and 339 deletions

View file

@ -3,6 +3,11 @@ import '@tldraw/tldraw/editor.css'
import '@tldraw/tldraw/ui.css'
import { useRef } from 'react'
const SHOW_MOVING_CURSOR = false
const CURSOR_SPEED = 0.1
const CIRCLE_RADIUS = 100
const UPDATE_FPS = 60
export default function UserPresenceExample() {
const rTimeout = useRef<any>(-1)
return (
@ -17,6 +22,7 @@ export default function UserPresenceExample() {
// we're having to create these ourselves.
const userId = TLUser.createCustomId('user-1')
const user = TLUser.create({
id: userId,
name: 'User 1',
@ -48,22 +54,34 @@ export default function UserPresenceExample() {
clearTimeout(rTimeout.current)
}
rTimeout.current = setInterval(() => {
const SPEED = 0.1
const R = 400
const k = 1000 / SPEED
const t = (Date.now() % k) / k
// rotate in a circle
const x = Math.cos(t * Math.PI * 2) * R
const y = Math.sin(t * Math.PI * 2) * R
if (SHOW_MOVING_CURSOR) {
rTimeout.current = setInterval(() => {
const k = 1000 / CURSOR_SPEED
const now = Date.now()
const t = (now % k) / k
// rotate in a circle
app.store.put([
{
...userPresence,
cursor: {
x: Math.cos(t * Math.PI * 2) * CIRCLE_RADIUS,
y: Math.sin(t * Math.PI * 2) * CIRCLE_RADIUS,
},
lastActivityTimestamp: now,
},
])
}, 1000 / UPDATE_FPS)
} else {
app.store.put([
{
...userPresence,
cursor: { x, y },
lastActivityTimestamp: Date.now(),
},
{ ...userPresence, cursor: { x: 0, y: 0 }, lastActivityTimestamp: Date.now() },
])
}, 100)
rTimeout.current = setInterval(() => {
app.store.put([
{ ...userPresence, cursor: { x: 0, y: 0 }, lastActivityTimestamp: Date.now() },
])
}, 1000)
}
}}
/>
</div>

View file

@ -262,6 +262,15 @@ input,
z-index: 2;
}
.tl-overlays__item {
position: absolute;
top: 0px;
left: 0px;
overflow: visible;
pointer-events: none;
transform-origin: top left;
}
.tl-svg-context {
position: absolute;
top: 0px;
@ -271,15 +280,6 @@ input,
pointer-events: none;
}
.tl-svg-origin-container {
position: absolute;
top: 0px;
left: 0px;
overflow: visible;
pointer-events: none;
transform-origin: top left;
}
/* ------------------- Background ------------------- */
.tl-background {
@ -399,6 +399,68 @@ input,
color: inherit;
}
/* --------------- Overlay Stack --------------- */
/* back of the stack, behind user's stuff */
.tl-collaborator__scribble {
z-index: 10;
}
.tl-collaborator__brush {
z-index: 11;
}
.tl-collaborator__shape-indicator {
z-index: 12;
}
.tl-user-handles {
z-index: 50;
}
.tl-user-scribble {
z-index: 52;
}
.tl-user-brush {
z-index: 51;
}
.tl-user-indicator__selected {
z-index: 51;
}
.tl-user-indicator__hovered {
z-index: 52;
}
.tl-user-snapline {
z-index: 53;
}
.tl-selection__fg {
pointer-events: none;
z-index: 54;
}
.tl-user-indicator__hint {
z-index: 55;
stroke-width: calc(2.5px * var(--tl-scale));
}
/* behind collaborator cursor */
.tl-collaborator__cursor-hint {
z-index: 56;
}
.tl-collaborator__cursor {
z-index: 57;
}
.tl-cursor {
overflow: visible;
}
/* -------------------- Indicator ------------------- */
.tl-shape-indicator {
@ -407,13 +469,9 @@ input,
stroke-width: calc(1.5px * var(--tl-scale));
}
.tl-shape-indicator__hinting {
stroke-width: calc(2.5px * var(--tl-scale));
}
/* ------------------ SelectionBox ------------------ */
.tlui-selection__bg {
.tl-selection__bg {
position: absolute;
top: 0px;
left: 0px;
@ -422,11 +480,7 @@ input,
pointer-events: all;
}
.tlui-selection__fg {
pointer-events: none;
}
.tlui-selection__fg__outline {
.tl-selection__fg__outline {
fill: none;
pointer-events: none;
stroke: var(--color-selection-stroke);

View file

@ -119,15 +119,15 @@ export const Canvas = track(function Canvas({
<ShapesToDisplay />
</div>
<div className="tl-overlays">
<ScribbleWrapper />
<HandlesWrapper />
<BrushWrapper />
<ScribbleWrapper />
<ZoomBrushWrapper />
<SelectedIdIndicators />
<HoveredShapeIndicator />
<SelectionFg />
<HintedShapeIndicator />
<SnapLinesWrapper />
<HandlesWrapper />
<SelectionFg />
{debugFlags.newLiveCollaborators.value ? (
<LiveCollaboratorsNext />
) : (
@ -162,7 +162,7 @@ const ScribbleWrapper = track(function ScribbleWrapper() {
if (!(Scribble && scribble)) return null
return <Scribble scribble={scribble} zoom={zoom} />
return <Scribble className="tl-user-scribble" scribble={scribble} zoom={zoom} />
})
const BrushWrapper = track(function BrushWrapper() {
@ -172,7 +172,7 @@ const BrushWrapper = track(function BrushWrapper() {
if (!(Brush && brush && app.isIn('select.brushing'))) return null
return <Brush brush={brush} />
return <Brush className="tl-user-brush" brush={brush} />
})
export const ZoomBrushWrapper = track(function Zoom() {
@ -182,7 +182,7 @@ export const ZoomBrushWrapper = track(function Zoom() {
if (!(ZoomBrush && zoomBrush && app.isIn('zoom'))) return null
return <ZoomBrush brush={zoomBrush} />
return <ZoomBrush className="tl-user-brush" brush={zoomBrush} />
})
export const SnapLinesWrapper = track(function SnapLines() {
@ -198,7 +198,7 @@ export const SnapLinesWrapper = track(function SnapLines() {
return (
<>
{lines.map((line) => (
<SnapLine line={line} key={line.id} zoom={zoomLevel} />
<SnapLine key={line.id} className="tl-user-snapline" line={line} zoom={zoomLevel} />
))}
</>
)
@ -248,7 +248,7 @@ const HandlesWrapper = track(function HandlesWrapper() {
handlesToDisplay.sort((a) => (a.type === 'vertex' ? 1 : -1))
return (
<svg className="tl-svg-origin-container">
<svg className="tl-user-handles tl-overlays__item">
<g transform={Matrix2d.toCssString(transform)}>
{handlesToDisplay.map((handle) => {
return <HandleWrapper key={handle.id} shapeId={onlySelectedShape.id} handle={handle} />
@ -317,7 +317,7 @@ const SelectedIdIndicators = track(function SelectedIdIndicators() {
return (
<>
{app.selectedIds.map((id) => (
<ShapeIndicator key={id + '_indicator'} id={id} />
<ShapeIndicator key={id + '_indicator'} className="tl-user-indicator__selected" id={id} />
))}
</>
)
@ -334,7 +334,7 @@ const HoveredShapeIndicator = function HoveredShapeIndicator() {
if (!displayingHoveredId) return null
return <ShapeIndicator id={displayingHoveredId} />
return <ShapeIndicator className="tl-user-indicator__hovered" id={displayingHoveredId} />
}
const HintedShapeIndicator = track(function HintedShapeIndicator() {
@ -347,7 +347,7 @@ const HintedShapeIndicator = track(function HintedShapeIndicator() {
return (
<>
{ids.map((id) => (
<ShapeIndicator id={id} key={id + '_hinting'} isHinting />
<ShapeIndicator className="tl-user-indicator__hint" id={id} key={id + '_hinting'} />
))}
</>
)
@ -373,7 +373,7 @@ function Cursor() {
}
function CollaboratorHint() {
return <path id="cursor_hint" fill="currentColor" d="M -2,-5 2,0 -2,5 Z" opacity=".8" />
return <path id="cursor_hint" fill="currentColor" d="M -2,-5 2,0 -2,5 Z" />
}
function ArrowheadDot() {

View file

@ -13,7 +13,7 @@ export function CropHandles({ size, width, height, hideAlternateHandles }: CropH
const offset = cropStrokeWidth / 2
return (
<svg className="tl-svg-origin-container">
<svg className="tl-overlays__item">
{/* Top left */}
<polyline
className="tl-corner-crop-handle"

View file

@ -4,16 +4,21 @@ import { useRef } from 'react'
import { useTransform } from '../hooks/useTransform'
/** @public */
export type TLBrushComponent = (props: { brush: Box2dModel; color?: string }) => any | null
export type TLBrushComponent = (props: {
brush: Box2dModel
color?: string
opacity?: number
className?: string
}) => any | null
export const DefaultBrush: TLBrushComponent = ({ brush, color }) => {
export const DefaultBrush: TLBrushComponent = ({ brush, color, opacity }) => {
const rSvg = useRef<SVGSVGElement>(null)
useTransform(rSvg, brush.x, brush.y)
return (
<svg className="tl-svg-origin-container" ref={rSvg}>
<svg className="tl-overlays__item" ref={rSvg}>
{color ? (
<g className="tl-brush">
<g className="tl-brush" opacity={opacity}>
<rect
width={toDomPrecision(Math.max(1, brush.w))}
height={toDomPrecision(Math.max(1, brush.h))}

View file

@ -1,20 +1,25 @@
import { Box2d, clamp, radiansToDegrees, Vec2d } from '@tldraw/primitives'
import { Box2d, clamp, Vec2d } from '@tldraw/primitives'
import { Vec2dModel } from '@tldraw/tlschema'
import classNames from 'classnames'
import { useRef } from 'react'
import { useTransform } from '../hooks/useTransform'
export type TLCollaboratorHintComponent = (props: {
className?: string
point: Vec2dModel
viewport: Box2d
zoom: number
opacity?: number
color: string
}) => JSX.Element | null
export const DefaultCollaboratorHint: TLCollaboratorHintComponent = ({
className,
zoom,
point,
color,
viewport,
opacity = 1,
}) => {
const rSvg = useRef<SVGSVGElement>(null)
@ -23,12 +28,13 @@ export const DefaultCollaboratorHint: TLCollaboratorHintComponent = ({
clamp(point.x, viewport.minX + 5 / zoom, viewport.maxX - 5 / zoom),
clamp(point.y, viewport.minY + 5 / zoom, viewport.maxY - 5 / zoom),
1 / zoom,
radiansToDegrees(Vec2d.Angle(viewport.center, point))
Vec2d.Angle(viewport.center, point)
)
return (
<svg ref={rSvg} className="tl-svg-origin-container">
<use href="#cursor_hint" color={color} />
<svg ref={rSvg} className={classNames('tl-overlays__item', className)}>
<use href="#cursor_hint" color={color} strokeWidth={3} stroke="var(--color-background)" />
<use href="#cursor_hint" color={color} opacity={opacity} />
</svg>
)
}

View file

@ -1,23 +1,26 @@
import { Vec2dModel } from '@tldraw/tlschema'
import { memo } from 'react'
import classNames from 'classnames'
import { memo, useRef } from 'react'
import { useTransform } from '../hooks/useTransform'
/** @public */
export type TLCursorComponent = (props: {
className?: string
point: Vec2dModel | null
zoom: number
color?: string
name: string | null
}) => any | null
const _Cursor: TLCursorComponent = ({ zoom, point, color, name }) => {
const _Cursor: TLCursorComponent = ({ className, zoom, point, color, name }) => {
const rDiv = useRef<HTMLDivElement>(null)
useTransform(rDiv, point?.x, point?.y, 1 / zoom)
if (!point) return null
return (
<div
className="tl-cursor"
style={{ transform: `translate(${point.x}px, ${point.y}px) scale(${1 / zoom})` }}
>
<svg>
<div ref={rDiv} className={classNames('tl-overlays__item', className)}>
<svg className="tl-cursor">
<use href="#cursor" color={color} />
</svg>
{name !== null && name !== '' && (

View file

@ -1,11 +1,21 @@
import { TLHandle, TLShapeId } from '@tldraw/tlschema'
import classNames from 'classnames'
export type TLHandleComponent = (props: { shapeId: TLShapeId; handle: TLHandle }) => any | null
export type TLHandleComponent = (props: {
shapeId: TLShapeId
handle: TLHandle
className?: string
}) => any | null
export const DefaultHandle: TLHandleComponent = ({ handle }) => {
export const DefaultHandle: TLHandleComponent = ({ handle, className }) => {
return (
<g className={classNames('tl-handle', { 'tl-handle__hint': handle.type !== 'vertex' })}>
<g
className={classNames(
'tl-handle',
{ 'tl-handle__hint': handle.type !== 'vertex' },
className
)}
>
<circle className="tl-handle__bg" />
<circle className="tl-handle__fg" />
</g>

View file

@ -1,5 +1,6 @@
import { EASINGS, getStroke } from '@tldraw/primitives'
import { TLScribble } from '@tldraw/tlschema'
import classNames from 'classnames'
import { getSvgPathFromStroke } from '../utils/svg'
/** @public */
@ -8,9 +9,16 @@ export type TLScribbleComponent = (props: {
zoom: number
color?: string
opacity?: number
className?: string
}) => any
export const DefaultScribble: TLScribbleComponent = ({ scribble, zoom, color, opacity }) => {
export const DefaultScribble: TLScribbleComponent = ({
scribble,
zoom,
color,
opacity,
className,
}) => {
const d = getSvgPathFromStroke(
getStroke(scribble.points, {
size: scribble.size / zoom,
@ -21,7 +29,7 @@ export const DefaultScribble: TLScribbleComponent = ({ scribble, zoom, color, op
)
return (
<svg className="tl-svg-origin-container">
<svg className={className ? classNames('tl-overlays__item', className) : className}>
<path
className="tl-scribble"
d={d}

View file

@ -1,4 +1,5 @@
import { rangeIntersection } from '@tldraw/primitives'
import classNames from 'classnames'
import * as React from 'react'
import { type GapsSnapLine, type PointsSnapLine, type SnapLine } from '../app/managers/SnapManager'
@ -148,11 +149,15 @@ function GapsSnapLine({ gaps, direction, zoom }: { zoom: number } & GapsSnapLine
)
}
export type TLSnapLineComponent = (props: { line: SnapLine; zoom: number }) => any
export type TLSnapLineComponent = (props: {
className?: string
line: SnapLine
zoom: number
}) => any
export const DefaultSnapLine: TLSnapLineComponent = ({ line, zoom }) => {
export const DefaultSnapLine: TLSnapLineComponent = ({ className, line, zoom }) => {
return (
<svg className="tl-svg-origin-container">
<svg className={classNames('tl-overlays__item', className)}>
{line.type === 'points' ? (
<PointsSnapLine {...line} zoom={zoom} />
) : line.type === 'gaps' ? (

View file

@ -111,10 +111,17 @@ const Collaborator = track(function Collaborator({
return (
<>
{brush && CollaboratorBrush ? (
<CollaboratorBrush key={userId + '_brush'} brush={brush} color={color} />
<CollaboratorBrush
className="tl-collaborator__brush"
key={userId + '_brush'}
brush={brush}
color={color}
opacity={0.1}
/>
) : null}
{isCursorInViewport && CollaboratorCursor ? (
<CollaboratorCursor
className="tl-collaborator__cursor"
key={userId + '_cursor'}
point={cursor}
color={color}
@ -123,6 +130,7 @@ const Collaborator = track(function Collaborator({
/>
) : CollaboratorHint ? (
<CollaboratorHint
className="tl-collaborator__cursor-hint"
key={userId + '_cursor_hint'}
point={cursor}
color={color}
@ -132,6 +140,7 @@ const Collaborator = track(function Collaborator({
) : null}
{scribble && CollaboratorScribble ? (
<CollaboratorScribble
className="tl-collaborator__scribble"
key={userId + '_scribble'}
scribble={scribble}
color={color}
@ -141,7 +150,13 @@ const Collaborator = track(function Collaborator({
) : null}
{CollaboratorShapeIndicator &&
selectedIds.map((shapeId) => (
<CollaboratorShapeIndicator key={userId + '_' + shapeId} id={shapeId} color={color} />
<CollaboratorShapeIndicator
className="tl-collaborator__shape-indicator"
key={userId + '_' + shapeId}
id={shapeId}
color={color}
opacity={0.5}
/>
))}
</>
)

View file

@ -120,7 +120,7 @@ export const SelectionBg = track(function SelectionBg() {
return (
<div
className="tlui-selection__bg"
className="tl-selection__bg"
draggable={false}
style={{
transform,

View file

@ -8,6 +8,11 @@ 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)
@ -68,12 +73,11 @@ export const SelectionFg = track(function SelectionFg() {
(onlyShape ? !app.getShapeUtil(onlyShape).hideSelectionBoundsFg(onlyShape) : true) &&
!isChangingStyles
const shouldDisplayBox =
let shouldDisplayBox =
(showSelectionBounds &&
app.isInAny(
'select.idle',
'select.brushing',
'select.editing_shape',
'select.scribble_brushing',
'select.pointing_canvas',
'select.pointing_selection',
@ -81,10 +85,17 @@ export const SelectionFg = track(function SelectionFg() {
'select.crop.idle',
'select.crop.pointing_crop',
'select.pointing_resize_handle',
'select.pointing_crop_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 &&
@ -162,255 +173,255 @@ export const SelectionFg = track(function SelectionFg() {
textHandleHeight * zoom >= 4
return (
<svg className="tl-svg-origin-container" ref={rSvg}>
<g data-wd="selection-foreground" className="tlui-selection__fg">
<svg ref={rSvg} className="tl-overlays__item tl-selection__fg" data-wd="selection-foreground">
{shouldDisplayBox && (
<rect
className={classNames('tlui-selection__fg__outline', { 'tl-hidden': !shouldDisplayBox })}
className={classNames('tl-selection__fg__outline')}
width={toDomPrecision(width)}
height={toDomPrecision(height)}
/>
<RotateCornerHandle
data-wd="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-wd="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-wd="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-wd="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-wd="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-wd="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-wd="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-wd="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-wd="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-wd="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-wd="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-wd="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-wd="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-wd="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-wd="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-wd="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-wd="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-wd="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-wd="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,
}}
)}
<RotateCornerHandle
data-wd="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-wd="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-wd="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-wd="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-wd="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-wd="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-wd="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-wd="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-wd="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-wd="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-wd="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-wd="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-wd="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-wd="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)}
/>
)}
</g>
<rect
data-wd="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-wd="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-wd="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-wd="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-wd="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>
)
})

View file

@ -1,4 +1,3 @@
import { Matrix2d } from '@tldraw/primitives'
import { TLShape, TLShapeId } from '@tldraw/tlschema'
import classNames from 'classnames'
import * as React from 'react'
@ -51,15 +50,14 @@ export const InnerIndicator = ({ app, id }: { app: App; id: TLShapeId }) => {
)
}
export const ShapeIndicator = React.memo(function ShapeIndicator({
id,
isHinting,
color,
}: {
export type TLShapeIndicatorComponent = (props: {
id: TLShapeId
isHinting?: boolean
color?: string
}) {
color?: string | undefined
opacity?: number
className?: string
}) => JSX.Element | null
const _ShapeIndicator: TLShapeIndicatorComponent = ({ id, className, color, opacity }) => {
const app = useApp()
const transform = useValue(
@ -67,28 +65,23 @@ export const ShapeIndicator = React.memo(function ShapeIndicator({
() => {
const pageTransform = app.getPageTransformById(id)
if (!pageTransform) return ''
return Matrix2d.toCssString(pageTransform)
return pageTransform.toCssString()
},
[app, id]
)
return (
<svg className="tl-svg-origin-container">
<svg className={classNames('tl-overlays__item', className)}>
<g
className={classNames('tl-shape-indicator', {
'tl-shape-indicator__hinting': isHinting,
})}
className="tl-shape-indicator"
transform={transform}
stroke={color ?? 'var(--color-selected)'}
opacity={opacity}
>
<InnerIndicator app={app} id={id} />
</g>
</svg>
)
})
}
export type TLShapeIndicatorComponent = (props: {
id: TLShapeId
isHinting?: boolean | undefined
color?: string | undefined
}) => JSX.Element | null
export const ShapeIndicator = React.memo(_ShapeIndicator)