Selection UI example (plus fixes to pageToScreen) (#3015)

This PR adds a custom selection UI example.

![Kapture 2024-03-01 at 14 02
25](https://github.com/tldraw/tldraw/assets/23072548/039cc6ab-17b9-4bc3-8c05-ad3ce788a5d3)

It also fixes a bug with pageToScreen and adds a
`getSelectionRotatedScreenBounds` method.

### Change Type

- [ ] `patch` — Bug fix
- [x] `minor` — New feature
- [ ] `major` — Breaking change
- [ ] `dependencies` — Changes to package dependencies[^1]
- [ ] `documentation` — Changes to the documentation only[^2]
- [ ] `tests` — Changes to any test code only[^2]
- [ ] `internal` — Any other changes that don't affect the published
package[^2]
- [ ] I don't know

[^1]: publishes a `patch` release, for devDependencies use `internal`
[^2]: will not publish a new version


### Release Notes

- Adds selection UI example.
- Adds `Editor.getSelectionRotatedScreenBounds` method
- Fixes a bug with `pageToScreen`.
This commit is contained in:
Steve Ruiz 2024-03-01 17:42:35 +00:00 committed by GitHub
parent 1d5a9efa17
commit 4bd1a31721
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 197 additions and 1 deletions

View file

@ -0,0 +1,9 @@
---
title: Selection UI
component: ./SelectionUiExample.tsx
category: shapes/tools
---
---
You can use the `InFrontOfTheCanvas` component to show extra user interface elements around the user's selection.

View file

@ -0,0 +1,135 @@
import {
TLComponents,
Tldraw,
Vec,
intersectLineSegmentPolygon,
stopEventPropagation,
useEditor,
useValue,
} from 'tldraw'
import 'tldraw/tldraw.css'
const components: TLComponents = {
InFrontOfTheCanvas: () => {
const editor = useEditor()
const info = useValue(
'selection bounds',
() => {
const screenBounds = editor.getViewportScreenBounds()
const rotation = editor.getSelectionRotation()
const rotatedScreenBounds = editor.getSelectionRotatedScreenBounds()
if (!rotatedScreenBounds) return
return {
// we really want the position within the
// tldraw component's bounds, not the screen itself
x: rotatedScreenBounds.x - screenBounds.x,
y: rotatedScreenBounds.y - screenBounds.y,
width: rotatedScreenBounds.width,
height: rotatedScreenBounds.height,
rotation: rotation,
}
},
[editor]
)
if (!info) return
return (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
transformOrigin: 'top left',
transform: `translate(${info.x}px, ${info.y}px) rotate(${info.rotation}rad)`,
pointerEvents: 'all',
}}
onPointerDown={stopEventPropagation}
>
<DuplicateInDirectionButton y={-40} x={info.width / 2 - 16} rotation={-(Math.PI / 2)} />
<DuplicateInDirectionButton y={info.height / 2 - 16} x={info.width + 8} rotation={0} />
<DuplicateInDirectionButton
y={info.height + 8}
x={info.width / 2 - 16}
rotation={Math.PI / 3}
/>
<DuplicateInDirectionButton y={info.height / 2 - 16} x={-40} rotation={Math.PI} />
</div>
)
},
}
export default function BasicExample() {
return (
<div className="tldraw__editor">
<Tldraw persistenceKey="example" components={components} />
</div>
)
}
/**
* This button will duplicate the editor's current selected shapes in
* a certain direction. Its rotation determines the appearance of the
* button (its actual css rotation) as well as the direction in which
* the duplicated shapes are offset from the original shapes. It's
* zeroed to the right.
*/
function DuplicateInDirectionButton({
x,
y,
rotation,
}: {
x: number
y: number
rotation: number
}) {
const editor = useEditor()
return (
<button
style={{
position: 'absolute',
width: 32,
height: 32,
pointerEvents: 'all',
transform: `translate(${x}px, ${y}px) rotate(${rotation}rad)`,
}}
onPointerDown={stopEventPropagation}
onClick={() => {
const selectionRotation = editor.getSelectionRotation() ?? 0
const rotatedPageBounds = editor.getSelectionRotatedPageBounds()!
const selectionPageBounds = editor.getSelectionPageBounds()!
if (!(rotatedPageBounds && selectionPageBounds)) return
editor.mark('duplicating in direction')
const PADDING = 32
// Find an intersection with the page bounds
const center = Vec.Rot(rotatedPageBounds.center, selectionRotation)
const int = intersectLineSegmentPolygon(
center,
Vec.Add(center, new Vec(100000, 0).rot(selectionRotation + rotation)),
rotatedPageBounds
.clone()
.expandBy(PADDING)
.corners.map((c) => c.rot(selectionRotation))
)
if (!int?.[0]) return
// Get the direction and distance to the intersection
const delta = Vec.Sub(int[0], center)
const dist = delta.len()
const dir = delta.norm()
// Get the offset for the duplicated shapes
const offset = dir.mul(dist * 2)
editor.duplicateShapes(editor.getSelectedShapes(), offset)
}}
>
</button>
)
}

View file

@ -726,6 +726,7 @@ export class Editor extends EventEmitter<TLEventMap> {
getSelectedShapes(): TLShape[]; getSelectedShapes(): TLShape[];
getSelectionPageBounds(): Box | null; getSelectionPageBounds(): Box | null;
getSelectionRotatedPageBounds(): Box | undefined; getSelectionRotatedPageBounds(): Box | undefined;
getSelectionRotatedScreenBounds(): Box | undefined;
getSelectionRotation(): number; getSelectionRotation(): number;
getShape<T extends TLShape = TLShape>(shape: TLParentId | TLShape): T | undefined; getShape<T extends TLShape = TLShape>(shape: TLParentId | TLShape): T | undefined;
getShapeAncestors(shape: TLShape | TLShapeId, acc?: TLShape[]): TLShape[]; getShapeAncestors(shape: TLShape | TLShapeId, acc?: TLShape[]): TLShape[];

View file

@ -12182,6 +12182,42 @@
"isAbstract": false, "isAbstract": false,
"name": "getSelectionRotatedPageBounds" "name": "getSelectionRotatedPageBounds"
}, },
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#getSelectionRotatedScreenBounds:member(1)",
"docComment": "/**\n * The bounds of the selection bounding box in the current page space.\n *\n * @readonly @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "getSelectionRotatedScreenBounds(): "
},
{
"kind": "Reference",
"text": "Box",
"canonicalReference": "@tldraw/editor!Box:class"
},
{
"kind": "Content",
"text": " | undefined"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 1,
"endIndex": 3
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [],
"isOptional": false,
"isAbstract": false,
"name": "getSelectionRotatedScreenBounds"
},
{ {
"kind": "Method", "kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#getSelectionRotation:member(1)", "canonicalReference": "@tldraw/editor!Editor#getSelectionRotation:member(1)",

View file

@ -1661,6 +1661,7 @@ export class Editor extends EventEmitter<TLEventMap> {
} }
return 0 return 0
} }
/** /**
* The bounds of the selection bounding box in the current page space. * The bounds of the selection bounding box in the current page space.
* *
@ -1701,6 +1702,20 @@ export class Editor extends EventEmitter<TLEventMap> {
return boxFromRotatedVertices return boxFromRotatedVertices
} }
/**
* The bounds of the selection bounding box in the current page space.
*
* @readonly
* @public
*/
@computed getSelectionRotatedScreenBounds(): Box | undefined {
const bounds = this.getSelectionRotatedPageBounds()
if (!bounds) return undefined
const { x, y } = this.pageToScreen(bounds.point)
const zoom = this.getZoomLevel()
return new Box(x, y, bounds.width * zoom, bounds.height * zoom)
}
// Focus Group // Focus Group
/** /**
@ -2848,7 +2863,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public * @public
*/ */
pageToScreen(point: VecLike) { pageToScreen(point: VecLike) {
const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)! const screenBounds = this.getViewportScreenBounds()
const { x: cx, y: cy, z: cz = 1 } = this.getCamera() const { x: cx, y: cy, z: cz = 1 } = this.getCamera()
return { return {