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[];
|
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[];
|
||||||
|
|
|
@ -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)",
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
Loading…
Reference in a new issue