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:
parent
1d5a9efa17
commit
4bd1a31721
5 changed files with 197 additions and 1 deletions
9
apps/examples/src/examples/selection-ui/README.md
Normal file
9
apps/examples/src/examples/selection-ui/README.md
Normal 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.
|
135
apps/examples/src/examples/selection-ui/SelectionUiExample.tsx
Normal file
135
apps/examples/src/examples/selection-ui/SelectionUiExample.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -726,6 +726,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
getSelectedShapes(): TLShape[];
|
||||
getSelectionPageBounds(): Box | null;
|
||||
getSelectionRotatedPageBounds(): Box | undefined;
|
||||
getSelectionRotatedScreenBounds(): Box | undefined;
|
||||
getSelectionRotation(): number;
|
||||
getShape<T extends TLShape = TLShape>(shape: TLParentId | TLShape): T | undefined;
|
||||
getShapeAncestors(shape: TLShape | TLShapeId, acc?: TLShape[]): TLShape[];
|
||||
|
|
|
@ -12182,6 +12182,42 @@
|
|||
"isAbstract": false,
|
||||
"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",
|
||||
"canonicalReference": "@tldraw/editor!Editor#getSelectionRotation:member(1)",
|
||||
|
|
|
@ -1661,6 +1661,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The bounds of the selection bounding box in the current page space.
|
||||
*
|
||||
|
@ -1701,6 +1702,20 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
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
|
||||
|
||||
/**
|
||||
|
@ -2848,7 +2863,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
* @public
|
||||
*/
|
||||
pageToScreen(point: VecLike) {
|
||||
const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)!
|
||||
const screenBounds = this.getViewportScreenBounds()
|
||||
const { x: cx, y: cy, z: cz = 1 } = this.getCamera()
|
||||
|
||||
return {
|
||||
|
|
Loading…
Reference in a new issue