2024-01-03 12:13:15 +00:00
|
|
|
import { BoxModel } from '@tldraw/tlschema'
|
|
|
|
import { Vec, VecLike } from './Vec'
|
2023-04-25 11:01:25 +00:00
|
|
|
import { PI, PI2, toPrecision } from './utils'
|
|
|
|
|
2023-11-15 18:06:02 +00:00
|
|
|
/** @public */
|
2024-01-03 12:13:15 +00:00
|
|
|
export type BoxLike = BoxModel | Box
|
2023-11-15 18:06:02 +00:00
|
|
|
|
2023-04-25 11:01:25 +00:00
|
|
|
/** @public */
|
|
|
|
export type SelectionEdge = 'top' | 'right' | 'bottom' | 'left'
|
|
|
|
|
|
|
|
/** @public */
|
|
|
|
export type SelectionCorner = 'top_left' | 'top_right' | 'bottom_right' | 'bottom_left'
|
|
|
|
|
|
|
|
/** @public */
|
|
|
|
export type SelectionHandle = SelectionEdge | SelectionCorner
|
|
|
|
|
|
|
|
/** @public */
|
|
|
|
export type RotateCorner =
|
|
|
|
| 'top_left_rotate'
|
|
|
|
| 'top_right_rotate'
|
|
|
|
| 'bottom_right_rotate'
|
|
|
|
| 'bottom_left_rotate'
|
|
|
|
| 'mobile_rotate'
|
|
|
|
|
|
|
|
/** @public */
|
2024-01-03 12:13:15 +00:00
|
|
|
export class Box {
|
2023-04-25 11:01:25 +00:00
|
|
|
constructor(x = 0, y = 0, w = 0, h = 0) {
|
|
|
|
this.x = x
|
|
|
|
this.y = y
|
|
|
|
this.w = w
|
|
|
|
this.h = h
|
|
|
|
}
|
|
|
|
|
|
|
|
x = 0
|
|
|
|
y = 0
|
|
|
|
w = 0
|
|
|
|
h = 0
|
|
|
|
|
2023-11-16 12:07:33 +00:00
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
2023-04-25 11:01:25 +00:00
|
|
|
get point() {
|
2024-01-03 12:13:15 +00:00
|
|
|
return new Vec(this.x, this.y)
|
2023-04-25 11:01:25 +00:00
|
|
|
}
|
|
|
|
|
2023-11-16 12:07:33 +00:00
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
2024-01-03 12:13:15 +00:00
|
|
|
set point(val: Vec) {
|
2023-04-25 11:01:25 +00:00
|
|
|
this.x = val.x
|
|
|
|
this.y = val.y
|
|
|
|
}
|
|
|
|
|
2023-11-16 12:07:33 +00:00
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
2023-04-25 11:01:25 +00:00
|
|
|
get minX() {
|
|
|
|
return this.x
|
|
|
|
}
|
|
|
|
|
2023-11-16 12:07:33 +00:00
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
2023-04-25 11:01:25 +00:00
|
|
|
set minX(n: number) {
|
|
|
|
this.x = n
|
|
|
|
}
|
|
|
|
|
2023-11-16 12:07:33 +00:00
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
2023-04-25 11:01:25 +00:00
|
|
|
get midX() {
|
|
|
|
return this.x + this.w / 2
|
|
|
|
}
|
|
|
|
|
2023-11-16 12:07:33 +00:00
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
2023-04-25 11:01:25 +00:00
|
|
|
get maxX() {
|
|
|
|
return this.x + this.w
|
|
|
|
}
|
|
|
|
|
2023-11-16 12:07:33 +00:00
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
2023-04-25 11:01:25 +00:00
|
|
|
get minY() {
|
|
|
|
return this.y
|
|
|
|
}
|
|
|
|
|
2023-11-16 12:07:33 +00:00
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
2023-04-25 11:01:25 +00:00
|
|
|
set minY(n: number) {
|
|
|
|
this.y = n
|
|
|
|
}
|
|
|
|
|
2023-11-16 12:07:33 +00:00
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
2023-04-25 11:01:25 +00:00
|
|
|
get midY() {
|
|
|
|
return this.y + this.h / 2
|
|
|
|
}
|
|
|
|
|
2023-11-16 12:07:33 +00:00
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
2023-04-25 11:01:25 +00:00
|
|
|
get maxY() {
|
|
|
|
return this.y + this.h
|
|
|
|
}
|
|
|
|
|
2023-11-16 12:07:33 +00:00
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
2023-04-25 11:01:25 +00:00
|
|
|
get width() {
|
|
|
|
return this.w
|
|
|
|
}
|
|
|
|
|
2023-11-16 12:07:33 +00:00
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
2023-04-25 11:01:25 +00:00
|
|
|
set width(n: number) {
|
|
|
|
this.w = n
|
|
|
|
}
|
|
|
|
|
2023-11-16 12:07:33 +00:00
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
2023-04-25 11:01:25 +00:00
|
|
|
get height() {
|
|
|
|
return this.h
|
|
|
|
}
|
|
|
|
|
2023-11-16 12:07:33 +00:00
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
2023-04-25 11:01:25 +00:00
|
|
|
set height(n: number) {
|
|
|
|
this.h = n
|
|
|
|
}
|
|
|
|
|
2023-11-16 12:07:33 +00:00
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
2023-04-25 11:01:25 +00:00
|
|
|
get aspectRatio() {
|
|
|
|
return this.width / this.height
|
|
|
|
}
|
|
|
|
|
2023-11-16 12:07:33 +00:00
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
2023-04-25 11:01:25 +00:00
|
|
|
get center() {
|
2024-01-03 12:13:15 +00:00
|
|
|
return new Vec(this.midX, this.midY)
|
2023-04-25 11:01:25 +00:00
|
|
|
}
|
|
|
|
|
2023-11-16 12:07:33 +00:00
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
2024-01-03 12:13:15 +00:00
|
|
|
set center(v: Vec) {
|
2023-04-25 11:01:25 +00:00
|
|
|
this.minX = v.x - this.width / 2
|
|
|
|
this.minY = v.y - this.height / 2
|
|
|
|
}
|
|
|
|
|
2023-11-16 12:07:33 +00:00
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
2023-04-25 11:01:25 +00:00
|
|
|
get corners() {
|
|
|
|
return [
|
2024-01-03 12:13:15 +00:00
|
|
|
new Vec(this.minX, this.minY),
|
|
|
|
new Vec(this.maxX, this.minY),
|
|
|
|
new Vec(this.maxX, this.maxY),
|
|
|
|
new Vec(this.minX, this.maxY),
|
2023-04-25 11:01:25 +00:00
|
|
|
]
|
|
|
|
}
|
|
|
|
|
2023-11-16 12:07:33 +00:00
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
[Snapping 3/5] Custom snapping API (#2793)
This diff adds an API for customising our existing snap types. These
are:
1. Bound snapping. When translating or resizing a shape, it'll snap to
certain key points on the bounds of particular shapes. Previously, these
were hard-coded to the corners and center of the bounding box of the
shape. Now, a shape can bring its own (e.g. a triangle may add snapping
for its 3 corners, and it's centroid rather than bounding box center.
2. Handle outline snapping. When dragging a handle, it'll snap to the
outline of other shapes geometry. Now, shapes can return different
geometry for this sort of snapping if they like.
Each of these is customised through a method on `ShapeUtil`:
`getBoundsSnapGeometry` and `getHandleSnapGeometry`. These return
interfaces describing the different geometry that can be snapped to in
both these cases. Currently, each returns an object with a single
property, but there are more types of snapping coming in follow-up PRs.
When reviewing this PR, start with the definitions of
`BoundsSnapGeometry` in `BoundsSnaps.ts` and `HandleSnapGeometry` in
`HandleSnaps.ts`
This doesn't add point snapping - i'll add that in a follow-up! It'll be
customisable with the `getHandleSnapGeometry` API.
Fixes TLD-2197
This PR is part of a series - please don't merge it until the things
before it have landed!
1. #2827
4. #2831
5. #2793 (you are here)
6. #2841
7. #2845
### Change Type
- [x] `minor` — New feature
[^1]: publishes a `patch` release, for devDependencies use `internal`
[^2]: will not publish a new version
### Test Plan
- [x] Unit Tests
### Release Notes
- Add `ShapeUtil.getSnapInfo` for customising shape snaps.
2024-02-15 15:10:04 +00:00
|
|
|
get cornersAndCenter() {
|
2023-04-25 11:01:25 +00:00
|
|
|
return [
|
2024-01-03 12:13:15 +00:00
|
|
|
new Vec(this.minX, this.minY),
|
|
|
|
new Vec(this.maxX, this.minY),
|
|
|
|
new Vec(this.maxX, this.maxY),
|
|
|
|
new Vec(this.minX, this.maxY),
|
2023-04-25 11:01:25 +00:00
|
|
|
this.center,
|
|
|
|
]
|
|
|
|
}
|
|
|
|
|
2023-11-16 12:07:33 +00:00
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
2024-01-03 12:13:15 +00:00
|
|
|
get sides(): Array<[Vec, Vec]> {
|
2023-04-25 11:01:25 +00:00
|
|
|
const { corners } = this
|
|
|
|
return [
|
|
|
|
[corners[0], corners[1]],
|
|
|
|
[corners[1], corners[2]],
|
|
|
|
[corners[2], corners[3]],
|
|
|
|
[corners[3], corners[0]],
|
|
|
|
]
|
|
|
|
}
|
|
|
|
|
2023-11-16 12:07:33 +00:00
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
2024-01-03 12:13:15 +00:00
|
|
|
get size(): Vec {
|
|
|
|
return new Vec(this.w, this.h)
|
2023-04-25 11:01:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
toFixed() {
|
|
|
|
this.x = toPrecision(this.x)
|
|
|
|
this.y = toPrecision(this.y)
|
|
|
|
this.w = toPrecision(this.w)
|
|
|
|
this.h = toPrecision(this.h)
|
|
|
|
return this
|
|
|
|
}
|
|
|
|
|
2024-01-03 12:13:15 +00:00
|
|
|
setTo(B: Box) {
|
2023-04-25 11:01:25 +00:00
|
|
|
this.x = B.x
|
|
|
|
this.y = B.y
|
|
|
|
this.w = B.w
|
|
|
|
this.h = B.h
|
|
|
|
return this
|
|
|
|
}
|
|
|
|
|
|
|
|
set(x = 0, y = 0, w = 0, h = 0) {
|
|
|
|
this.x = x
|
|
|
|
this.y = y
|
|
|
|
this.w = w
|
|
|
|
this.h = h
|
|
|
|
return this
|
|
|
|
}
|
|
|
|
|
2024-01-03 12:13:15 +00:00
|
|
|
expand(A: Box) {
|
2023-04-25 11:01:25 +00:00
|
|
|
const minX = Math.min(this.minX, A.minX)
|
|
|
|
const minY = Math.min(this.minY, A.minY)
|
|
|
|
const maxX = Math.max(this.maxX, A.maxX)
|
|
|
|
const maxY = Math.max(this.maxY, A.maxY)
|
|
|
|
|
|
|
|
this.x = minX
|
|
|
|
this.y = minY
|
|
|
|
this.w = maxX - minX
|
|
|
|
this.h = maxY - minY
|
|
|
|
return this
|
|
|
|
}
|
|
|
|
|
|
|
|
expandBy(n: number) {
|
|
|
|
this.x -= n
|
|
|
|
this.y -= n
|
|
|
|
this.w += n * 2
|
|
|
|
this.h += n * 2
|
|
|
|
return this
|
|
|
|
}
|
|
|
|
|
|
|
|
scale(n: number) {
|
|
|
|
this.x /= n
|
|
|
|
this.y /= n
|
|
|
|
this.w /= n
|
|
|
|
this.h /= n
|
|
|
|
return this
|
|
|
|
}
|
|
|
|
|
|
|
|
clone() {
|
|
|
|
const { x, y, w, h } = this
|
2024-01-03 12:13:15 +00:00
|
|
|
return new Box(x, y, w, h)
|
2023-04-25 11:01:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
translate(delta: VecLike) {
|
|
|
|
this.x += delta.x
|
|
|
|
this.y += delta.y
|
|
|
|
return this
|
|
|
|
}
|
|
|
|
|
|
|
|
snapToGrid(size: number) {
|
|
|
|
const minX = Math.round(this.minX / size) * size
|
|
|
|
const minY = Math.round(this.minY / size) * size
|
|
|
|
const maxX = Math.round(this.maxX / size) * size
|
|
|
|
const maxY = Math.round(this.maxY / size) * size
|
|
|
|
this.minX = minX
|
|
|
|
this.minY = minY
|
|
|
|
this.width = Math.max(1, maxX - minX)
|
|
|
|
this.height = Math.max(1, maxY - minY)
|
|
|
|
}
|
|
|
|
|
2024-01-03 12:13:15 +00:00
|
|
|
collides(B: Box) {
|
|
|
|
return Box.Collides(this, B)
|
2023-04-25 11:01:25 +00:00
|
|
|
}
|
|
|
|
|
2024-01-03 12:13:15 +00:00
|
|
|
contains(B: Box) {
|
|
|
|
return Box.Contains(this, B)
|
2023-04-25 11:01:25 +00:00
|
|
|
}
|
|
|
|
|
2024-01-03 12:13:15 +00:00
|
|
|
includes(B: Box) {
|
|
|
|
return Box.Includes(this, B)
|
2023-04-25 11:01:25 +00:00
|
|
|
}
|
|
|
|
|
`ShapeUtil.getGeometry`, selection rewrite (#1751)
This PR is a significant rewrite of our selection / hit testing logic.
It
- replaces our current geometric helpers (`getBounds`, `getOutline`,
`hitTestPoint`, and `hitTestLineSegment`) with a new geometry API
- moves our hit testing entirely to JS using geometry
- improves selection logic, especially around editing shapes, groups and
frames
- fixes many minor selection bugs (e.g. shapes behind frames)
- removes hit-testing DOM elements from ShapeFill etc.
- adds many new tests around selection
- adds new tests around selection
- makes several superficial changes to surface editor APIs
This PR is hard to evaluate. The `selection-omnibus` test suite is
intended to describe all of the selection behavior, however all existing
tests are also either here preserved and passing or (in a few cases
around editing shapes) are modified to reflect the new behavior.
## Geometry
All `ShapeUtils` implement `getGeometry`, which returns a single
geometry primitive (`Geometry2d`). For example:
```ts
class BoxyShapeUtil {
getGeometry(shape: BoxyShape) {
return new Rectangle2d({
width: shape.props.width,
height: shape.props.height,
isFilled: true,
margin: shape.props.strokeWidth
})
}
}
```
This geometric primitive is used for all bounds calculation, hit
testing, intersection with arrows, etc.
There are several geometric primitives that extend `Geometry2d`:
- `Arc2d`
- `Circle2d`
- `CubicBezier2d`
- `CubicSpline2d`
- `Edge2d`
- `Ellipse2d`
- `Group2d`
- `Polygon2d`
- `Rectangle2d`
- `Stadium2d`
For shapes that have more complicated geometric representations, such as
an arrow with a label, the `Group2d` can accept other primitives as its
children.
## Hit testing
Previously, we did all hit testing via events set on shapes and other
elements. In this PR, I've replaced those hit tests with our own
calculation for hit tests in JavaScript. This removed the need for many
DOM elements, such as hit test area borders and fills which only existed
to trigger pointer events.
## Selection
We now support selecting "hollow" shapes by clicking inside of them.
This involves a lot of new logic but it should work intuitively. See
`Editor.getShapeAtPoint` for the (thoroughly commented) implementation.
![Kapture 2023-07-23 at 23 27
27](https://github.com/tldraw/tldraw/assets/23072548/a743275c-acdb-42d9-a3fe-b3e20dce86b6)
every sunset is actually the sun hiding in fear and respect of tldraw's
quality of interactions
This PR also fixes several bugs with scribble selection, in particular
around the shift key modifier.
![Kapture 2023-07-24 at 23 34
07](https://github.com/tldraw/tldraw/assets/23072548/871d67d0-8d06-42ae-a2b2-021effba37c5)
...as well as issues with labels and editing.
There are **over 100 new tests** for selection covering groups, frames,
brushing, scribbling, hovering, and editing. I'll add a few more before
I feel comfortable merging this PR.
## Arrow binding
Using the same "hollow shape" logic as selection, arrow binding is
significantly improved.
![Kapture 2023-07-22 at 07 46
25](https://github.com/tldraw/tldraw/assets/23072548/5aa724b3-b57d-4fb7-92d0-80e34246753c)
a thousand wise men could not improve on this
## Moving focus between editing shapes
Previously, this was handled in the `editing_shapes` state. This is
moved to `useEditableText`, and should generally be considered an
advanced implementation detail on a shape-by-shape basis. This addresses
a bug that I'd never noticed before, but which can be reproduced by
selecting an shape—but not focusing its input—while editing a different
shape. Previously, the new shape became the editing shape but its input
did not focus.
![Kapture 2023-07-23 at 23 19
09](https://github.com/tldraw/tldraw/assets/23072548/a5e157fb-24a8-42bd-a692-04ce769b1a9c)
In this PR, you can select a shape by clicking on its edge or body, or
select its input to transfer editing / focus.
![Kapture 2023-07-23 at 23 22
21](https://github.com/tldraw/tldraw/assets/23072548/7384e7ea-9777-4e1a-8f63-15de2166a53a)
tldraw, glorious tldraw
### Change Type
- [x] `major` — Breaking change
### Test Plan
1. Erase shapes
2. Select shapes
3. Calculate their bounding boxes
- [ ] Unit Tests // todo
- [ ] End to end tests // todo
### Release Notes
- [editor] Remove `ShapeUtil.getBounds`, `ShapeUtil.getOutline`,
`ShapeUtil.hitTestPoint`, `ShapeUtil.hitTestLineSegment`
- [editor] Add `ShapeUtil.getGeometry`
- [editor] Add `Editor.getShapeGeometry`
2023-07-25 16:10:15 +00:00
|
|
|
containsPoint(V: VecLike, margin = 0) {
|
2024-01-03 12:13:15 +00:00
|
|
|
return Box.ContainsPoint(this, V, margin)
|
2023-04-25 11:01:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
getHandlePoint(handle: SelectionCorner | SelectionEdge) {
|
|
|
|
switch (handle) {
|
|
|
|
case 'top_left':
|
2024-01-03 12:13:15 +00:00
|
|
|
return new Vec(this.minX, this.minY)
|
2023-04-25 11:01:25 +00:00
|
|
|
case 'top_right':
|
2024-01-03 12:13:15 +00:00
|
|
|
return new Vec(this.maxX, this.minY)
|
2023-04-25 11:01:25 +00:00
|
|
|
case 'bottom_left':
|
2024-01-03 12:13:15 +00:00
|
|
|
return new Vec(this.minX, this.maxY)
|
2023-04-25 11:01:25 +00:00
|
|
|
case 'bottom_right':
|
2024-01-03 12:13:15 +00:00
|
|
|
return new Vec(this.maxX, this.maxY)
|
2023-04-25 11:01:25 +00:00
|
|
|
case 'top':
|
2024-01-03 12:13:15 +00:00
|
|
|
return new Vec(this.midX, this.minY)
|
2023-04-25 11:01:25 +00:00
|
|
|
case 'right':
|
2024-01-03 12:13:15 +00:00
|
|
|
return new Vec(this.maxX, this.midY)
|
2023-04-25 11:01:25 +00:00
|
|
|
case 'bottom':
|
2024-01-03 12:13:15 +00:00
|
|
|
return new Vec(this.midX, this.maxY)
|
2023-04-25 11:01:25 +00:00
|
|
|
case 'left':
|
2024-01-03 12:13:15 +00:00
|
|
|
return new Vec(this.minX, this.midY)
|
2023-04-25 11:01:25 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-03 12:13:15 +00:00
|
|
|
toJson(): BoxModel {
|
2023-04-25 11:01:25 +00:00
|
|
|
return { x: this.minX, y: this.minY, w: this.w, h: this.h }
|
|
|
|
}
|
|
|
|
|
|
|
|
resize(handle: SelectionCorner | SelectionEdge | string, dx: number, dy: number) {
|
|
|
|
const { minX: a0x, minY: a0y, maxX: a1x, maxY: a1y } = this
|
|
|
|
let { minX: b0x, minY: b0y, maxX: b1x, maxY: b1y } = this
|
|
|
|
|
|
|
|
// Use the delta to adjust the new box by changing its corners.
|
|
|
|
// The dragging handle (corner or edge) will determine which
|
|
|
|
// corners should change.
|
|
|
|
switch (handle) {
|
|
|
|
case 'left':
|
|
|
|
case 'top_left':
|
|
|
|
case 'bottom_left': {
|
|
|
|
b0x += dx
|
|
|
|
break
|
|
|
|
}
|
|
|
|
case 'right':
|
|
|
|
case 'top_right':
|
|
|
|
case 'bottom_right': {
|
|
|
|
b1x += dx
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
switch (handle) {
|
|
|
|
case 'top':
|
|
|
|
case 'top_left':
|
|
|
|
case 'top_right': {
|
|
|
|
b0y += dy
|
|
|
|
break
|
|
|
|
}
|
|
|
|
case 'bottom':
|
|
|
|
case 'bottom_left':
|
|
|
|
case 'bottom_right': {
|
|
|
|
b1y += dy
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const scaleX = (b1x - b0x) / (a1x - a0x)
|
|
|
|
const scaleY = (b1y - b0y) / (a1y - a0y)
|
|
|
|
|
|
|
|
const flipX = scaleX < 0
|
|
|
|
const flipY = scaleY < 0
|
|
|
|
|
|
|
|
if (flipX) {
|
|
|
|
const t = b1x
|
|
|
|
b1x = b0x
|
|
|
|
b0x = t
|
|
|
|
}
|
|
|
|
|
|
|
|
if (flipY) {
|
|
|
|
const t = b1y
|
|
|
|
b1y = b0y
|
|
|
|
b0y = t
|
|
|
|
}
|
|
|
|
|
|
|
|
this.minX = b0x
|
|
|
|
this.minY = b0y
|
|
|
|
this.width = Math.abs(b1x - b0x)
|
|
|
|
this.height = Math.abs(b1y - b0y)
|
|
|
|
}
|
|
|
|
|
2024-01-03 12:13:15 +00:00
|
|
|
union(box: BoxModel) {
|
Measure individual words instead of just line breaks for text exports (#1397)
This diff fixes a number of issues with text export by completely
overhauling how we approach laying out text in exports.
Currently, we try to carefully replicate in-browser behaviour around
line breaks and whitespace collapsing. We do this using an iterative
algorithm that forces the browser to perform a layout for each word, and
attempting to re-implement how the browser does things like whitespace
collapsing & finding line break opportunities. Lots of export issues
come from the fact that this is almost impossible to do well (short of
sending a complete text layout algorithm & full unicode lookup tables).
Luckily, the browser already has a complete text layout algorithm and
full unicode lookup tables! In the new approach, we ask the browser to
lay the text out once. Then, we use the
[`Range`](https://developer.mozilla.org/en-US/docs/Web/API/Range) API to
loop over every character in the rendered text and measure its position.
These character positions are then grouped into "spans". A span is a
contiguous range of either whitespace or non-whitespace characters,
uninterrupted by any browser-inserting line breaks. When we come to
render the SVG, each span gets its own `<tspan>` element, absolutely
positioned according to where it ended up in the user's browser.
This fixes a bunch of issues:
**Misaligned text due to whitespace collapsing at line breaks**
![Kapture 2023-05-17 at 12 07
30](https://github.com/tldraw/tldraw/assets/1489520/5ab66fe0-6ceb-45bb-8787-90ccb124664a)
**Hyphenated text (or text with non-trivial/whitespace-based breaking
rules like Thai) not splitting correctly**
![Kapture 2023-05-17 at 12 21
40](https://github.com/tldraw/tldraw/assets/1489520/d2d5fd13-3e79-48c4-8e76-ae2c70a6471e)
**Weird alignment issues in note shapes**
![Kapture 2023-05-17 at 12 24
59](https://github.com/tldraw/tldraw/assets/1489520/a0e51d57-7c1c-490e-9952-b92417ffdf9e)
**Frame labels not respecting multiple spaces & not truncating
correctly**
![Kapture 2023-05-17 at 12 27
27](https://github.com/tldraw/tldraw/assets/1489520/39b2f53c-0180-460e-b10a-9fd955a6fa78)
#### Quick note on browser compatibility
This approach works well across all browsers, but in some cases actually
_increases_ x-browser variance. Consider these screenshots of the same
element (original above, export below):
![image](https://github.com/tldraw/tldraw/assets/1489520/5633b041-8cb3-4c92-bef6-4f3c202305de)
Notice how on chrome, the whitespace at the end of each line of
right-aligned text is preserved. On safari, it's collapsed. The safari
option looks better - so our manual line-breaking/white-space-collapsing
algorithm preferred safari's approach. That meant that in-app, this
shape looks very slightly different from browser to browser. But out of
the app, the exports would have been the same (although also note that
hyphenation is broken). Now, because these shapes look different across
browsers, the exports now look different across browsers too. We're
relying on the host-browsers text layout algorithm, which means we'll
faithfully reproduce any quirks/inconsistencies of that algorithm. I
think this is an acceptable tradeoff.
### Change Type
- [x] `patch` — Bug Fix
### Test Plan
* Comprehensive testing of text in exports, paying close attention to
details around white-space, line-breaking and alignment
* Consider setting `tldrawDebugSvg = true`
* Check text shapes, geo shapes with labels, arrow shapes with labels,
note shapes, frame labels
* Check different alignments and fonts (including vertical alignment)
### Release Notes
- Add a brief release note for your PR here.
2023-05-22 15:10:03 +00:00
|
|
|
const minX = Math.min(this.minX, box.x)
|
|
|
|
const minY = Math.min(this.minY, box.y)
|
2023-06-01 15:22:47 +00:00
|
|
|
const maxX = Math.max(this.maxX, box.w + box.x)
|
|
|
|
const maxY = Math.max(this.maxY, box.h + box.y)
|
Measure individual words instead of just line breaks for text exports (#1397)
This diff fixes a number of issues with text export by completely
overhauling how we approach laying out text in exports.
Currently, we try to carefully replicate in-browser behaviour around
line breaks and whitespace collapsing. We do this using an iterative
algorithm that forces the browser to perform a layout for each word, and
attempting to re-implement how the browser does things like whitespace
collapsing & finding line break opportunities. Lots of export issues
come from the fact that this is almost impossible to do well (short of
sending a complete text layout algorithm & full unicode lookup tables).
Luckily, the browser already has a complete text layout algorithm and
full unicode lookup tables! In the new approach, we ask the browser to
lay the text out once. Then, we use the
[`Range`](https://developer.mozilla.org/en-US/docs/Web/API/Range) API to
loop over every character in the rendered text and measure its position.
These character positions are then grouped into "spans". A span is a
contiguous range of either whitespace or non-whitespace characters,
uninterrupted by any browser-inserting line breaks. When we come to
render the SVG, each span gets its own `<tspan>` element, absolutely
positioned according to where it ended up in the user's browser.
This fixes a bunch of issues:
**Misaligned text due to whitespace collapsing at line breaks**
![Kapture 2023-05-17 at 12 07
30](https://github.com/tldraw/tldraw/assets/1489520/5ab66fe0-6ceb-45bb-8787-90ccb124664a)
**Hyphenated text (or text with non-trivial/whitespace-based breaking
rules like Thai) not splitting correctly**
![Kapture 2023-05-17 at 12 21
40](https://github.com/tldraw/tldraw/assets/1489520/d2d5fd13-3e79-48c4-8e76-ae2c70a6471e)
**Weird alignment issues in note shapes**
![Kapture 2023-05-17 at 12 24
59](https://github.com/tldraw/tldraw/assets/1489520/a0e51d57-7c1c-490e-9952-b92417ffdf9e)
**Frame labels not respecting multiple spaces & not truncating
correctly**
![Kapture 2023-05-17 at 12 27
27](https://github.com/tldraw/tldraw/assets/1489520/39b2f53c-0180-460e-b10a-9fd955a6fa78)
#### Quick note on browser compatibility
This approach works well across all browsers, but in some cases actually
_increases_ x-browser variance. Consider these screenshots of the same
element (original above, export below):
![image](https://github.com/tldraw/tldraw/assets/1489520/5633b041-8cb3-4c92-bef6-4f3c202305de)
Notice how on chrome, the whitespace at the end of each line of
right-aligned text is preserved. On safari, it's collapsed. The safari
option looks better - so our manual line-breaking/white-space-collapsing
algorithm preferred safari's approach. That meant that in-app, this
shape looks very slightly different from browser to browser. But out of
the app, the exports would have been the same (although also note that
hyphenation is broken). Now, because these shapes look different across
browsers, the exports now look different across browsers too. We're
relying on the host-browsers text layout algorithm, which means we'll
faithfully reproduce any quirks/inconsistencies of that algorithm. I
think this is an acceptable tradeoff.
### Change Type
- [x] `patch` — Bug Fix
### Test Plan
* Comprehensive testing of text in exports, paying close attention to
details around white-space, line-breaking and alignment
* Consider setting `tldrawDebugSvg = true`
* Check text shapes, geo shapes with labels, arrow shapes with labels,
note shapes, frame labels
* Check different alignments and fonts (including vertical alignment)
### Release Notes
- Add a brief release note for your PR here.
2023-05-22 15:10:03 +00:00
|
|
|
|
|
|
|
this.x = minX
|
|
|
|
this.y = minY
|
|
|
|
this.width = maxX - minX
|
|
|
|
this.height = maxY - minY
|
|
|
|
|
|
|
|
return this
|
|
|
|
}
|
|
|
|
|
2024-01-03 12:13:15 +00:00
|
|
|
static From(box: BoxModel) {
|
|
|
|
return new Box(box.x, box.y, box.w, box.h)
|
2023-04-25 11:01:25 +00:00
|
|
|
}
|
|
|
|
|
2024-01-24 10:19:20 +00:00
|
|
|
static FromCenter(center: VecLike, size: VecLike) {
|
|
|
|
return new Box(center.x - size.x / 2, center.y - size.y / 2, size.x, size.y)
|
|
|
|
}
|
|
|
|
|
2023-04-25 11:01:25 +00:00
|
|
|
static FromPoints(points: VecLike[]) {
|
2024-01-03 12:13:15 +00:00
|
|
|
if (points.length === 0) return new Box()
|
2023-04-25 11:01:25 +00:00
|
|
|
let minX = Infinity
|
|
|
|
let minY = Infinity
|
|
|
|
let maxX = -Infinity
|
|
|
|
let maxY = -Infinity
|
|
|
|
let point: VecLike
|
|
|
|
for (let i = 0, n = points.length; i < n; i++) {
|
|
|
|
point = points[i]
|
|
|
|
minX = Math.min(point.x, minX)
|
|
|
|
minY = Math.min(point.y, minY)
|
|
|
|
maxX = Math.max(point.x, maxX)
|
|
|
|
maxY = Math.max(point.y, maxY)
|
|
|
|
}
|
|
|
|
|
2024-01-03 12:13:15 +00:00
|
|
|
return new Box(minX, minY, maxX - minX, maxY - minY)
|
2023-04-25 11:01:25 +00:00
|
|
|
}
|
|
|
|
|
2024-01-03 12:13:15 +00:00
|
|
|
static Expand(A: Box, B: Box) {
|
2023-04-25 11:01:25 +00:00
|
|
|
const minX = Math.min(B.minX, A.minX)
|
|
|
|
const minY = Math.min(B.minY, A.minY)
|
|
|
|
const maxX = Math.max(B.maxX, A.maxX)
|
|
|
|
const maxY = Math.max(B.maxY, A.maxY)
|
|
|
|
|
2024-01-03 12:13:15 +00:00
|
|
|
return new Box(minX, minY, maxX - minX, maxY - minY)
|
2023-04-25 11:01:25 +00:00
|
|
|
}
|
|
|
|
|
2024-01-03 12:13:15 +00:00
|
|
|
static ExpandBy(A: Box, n: number) {
|
|
|
|
return new Box(A.minX - n, A.minY - n, A.width + n * 2, A.height + n * 2)
|
2023-04-25 11:01:25 +00:00
|
|
|
}
|
|
|
|
|
2024-01-03 12:13:15 +00:00
|
|
|
static Collides = (A: Box, B: Box) => {
|
2023-04-25 11:01:25 +00:00
|
|
|
return !(A.maxX < B.minX || A.minX > B.maxX || A.maxY < B.minY || A.minY > B.maxY)
|
|
|
|
}
|
|
|
|
|
2024-01-03 12:13:15 +00:00
|
|
|
static Contains = (A: Box, B: Box) => {
|
2023-04-25 11:01:25 +00:00
|
|
|
return A.minX < B.minX && A.minY < B.minY && A.maxY > B.maxY && A.maxX > B.maxX
|
|
|
|
}
|
|
|
|
|
2024-01-03 12:13:15 +00:00
|
|
|
static Includes = (A: Box, B: Box) => {
|
|
|
|
return Box.Collides(A, B) || Box.Contains(A, B)
|
2023-04-25 11:01:25 +00:00
|
|
|
}
|
|
|
|
|
2024-01-03 12:13:15 +00:00
|
|
|
static ContainsPoint = (A: Box, B: VecLike, margin = 0) => {
|
`ShapeUtil.getGeometry`, selection rewrite (#1751)
This PR is a significant rewrite of our selection / hit testing logic.
It
- replaces our current geometric helpers (`getBounds`, `getOutline`,
`hitTestPoint`, and `hitTestLineSegment`) with a new geometry API
- moves our hit testing entirely to JS using geometry
- improves selection logic, especially around editing shapes, groups and
frames
- fixes many minor selection bugs (e.g. shapes behind frames)
- removes hit-testing DOM elements from ShapeFill etc.
- adds many new tests around selection
- adds new tests around selection
- makes several superficial changes to surface editor APIs
This PR is hard to evaluate. The `selection-omnibus` test suite is
intended to describe all of the selection behavior, however all existing
tests are also either here preserved and passing or (in a few cases
around editing shapes) are modified to reflect the new behavior.
## Geometry
All `ShapeUtils` implement `getGeometry`, which returns a single
geometry primitive (`Geometry2d`). For example:
```ts
class BoxyShapeUtil {
getGeometry(shape: BoxyShape) {
return new Rectangle2d({
width: shape.props.width,
height: shape.props.height,
isFilled: true,
margin: shape.props.strokeWidth
})
}
}
```
This geometric primitive is used for all bounds calculation, hit
testing, intersection with arrows, etc.
There are several geometric primitives that extend `Geometry2d`:
- `Arc2d`
- `Circle2d`
- `CubicBezier2d`
- `CubicSpline2d`
- `Edge2d`
- `Ellipse2d`
- `Group2d`
- `Polygon2d`
- `Rectangle2d`
- `Stadium2d`
For shapes that have more complicated geometric representations, such as
an arrow with a label, the `Group2d` can accept other primitives as its
children.
## Hit testing
Previously, we did all hit testing via events set on shapes and other
elements. In this PR, I've replaced those hit tests with our own
calculation for hit tests in JavaScript. This removed the need for many
DOM elements, such as hit test area borders and fills which only existed
to trigger pointer events.
## Selection
We now support selecting "hollow" shapes by clicking inside of them.
This involves a lot of new logic but it should work intuitively. See
`Editor.getShapeAtPoint` for the (thoroughly commented) implementation.
![Kapture 2023-07-23 at 23 27
27](https://github.com/tldraw/tldraw/assets/23072548/a743275c-acdb-42d9-a3fe-b3e20dce86b6)
every sunset is actually the sun hiding in fear and respect of tldraw's
quality of interactions
This PR also fixes several bugs with scribble selection, in particular
around the shift key modifier.
![Kapture 2023-07-24 at 23 34
07](https://github.com/tldraw/tldraw/assets/23072548/871d67d0-8d06-42ae-a2b2-021effba37c5)
...as well as issues with labels and editing.
There are **over 100 new tests** for selection covering groups, frames,
brushing, scribbling, hovering, and editing. I'll add a few more before
I feel comfortable merging this PR.
## Arrow binding
Using the same "hollow shape" logic as selection, arrow binding is
significantly improved.
![Kapture 2023-07-22 at 07 46
25](https://github.com/tldraw/tldraw/assets/23072548/5aa724b3-b57d-4fb7-92d0-80e34246753c)
a thousand wise men could not improve on this
## Moving focus between editing shapes
Previously, this was handled in the `editing_shapes` state. This is
moved to `useEditableText`, and should generally be considered an
advanced implementation detail on a shape-by-shape basis. This addresses
a bug that I'd never noticed before, but which can be reproduced by
selecting an shape—but not focusing its input—while editing a different
shape. Previously, the new shape became the editing shape but its input
did not focus.
![Kapture 2023-07-23 at 23 19
09](https://github.com/tldraw/tldraw/assets/23072548/a5e157fb-24a8-42bd-a692-04ce769b1a9c)
In this PR, you can select a shape by clicking on its edge or body, or
select its input to transfer editing / focus.
![Kapture 2023-07-23 at 23 22
21](https://github.com/tldraw/tldraw/assets/23072548/7384e7ea-9777-4e1a-8f63-15de2166a53a)
tldraw, glorious tldraw
### Change Type
- [x] `major` — Breaking change
### Test Plan
1. Erase shapes
2. Select shapes
3. Calculate their bounding boxes
- [ ] Unit Tests // todo
- [ ] End to end tests // todo
### Release Notes
- [editor] Remove `ShapeUtil.getBounds`, `ShapeUtil.getOutline`,
`ShapeUtil.hitTestPoint`, `ShapeUtil.hitTestLineSegment`
- [editor] Add `ShapeUtil.getGeometry`
- [editor] Add `Editor.getShapeGeometry`
2023-07-25 16:10:15 +00:00
|
|
|
return !(
|
|
|
|
B.x < A.minX - margin ||
|
|
|
|
B.y < A.minY - margin ||
|
|
|
|
B.x > A.maxX + margin ||
|
|
|
|
B.y > A.maxY + margin
|
|
|
|
)
|
2023-04-25 11:01:25 +00:00
|
|
|
}
|
|
|
|
|
2024-01-03 12:13:15 +00:00
|
|
|
static Common = (boxes: Box[]): Box => {
|
2023-04-25 11:01:25 +00:00
|
|
|
let minX = Infinity
|
|
|
|
let minY = Infinity
|
|
|
|
let maxX = -Infinity
|
|
|
|
let maxY = -Infinity
|
|
|
|
|
|
|
|
for (let i = 0; i < boxes.length; i++) {
|
|
|
|
const B = boxes[i]
|
|
|
|
minX = Math.min(minX, B.minX)
|
|
|
|
minY = Math.min(minY, B.minY)
|
|
|
|
maxX = Math.max(maxX, B.maxX)
|
|
|
|
maxY = Math.max(maxY, B.maxY)
|
|
|
|
}
|
|
|
|
|
2024-01-03 12:13:15 +00:00
|
|
|
return new Box(minX, minY, maxX - minX, maxY - minY)
|
2023-04-25 11:01:25 +00:00
|
|
|
}
|
|
|
|
|
2024-01-03 12:13:15 +00:00
|
|
|
static Sides = (A: Box, inset = 0) => {
|
2023-04-25 11:01:25 +00:00
|
|
|
const { corners } = A
|
|
|
|
if (inset) {
|
|
|
|
// TODO: Inset the corners by the inset amount.
|
|
|
|
}
|
|
|
|
|
|
|
|
return [
|
|
|
|
[corners[0], corners[1]],
|
|
|
|
[corners[1], corners[2]],
|
|
|
|
[corners[2], corners[3]],
|
|
|
|
[corners[3], corners[0]],
|
|
|
|
]
|
|
|
|
}
|
|
|
|
|
|
|
|
static Resize(
|
2024-01-03 12:13:15 +00:00
|
|
|
box: Box,
|
2023-04-25 11:01:25 +00:00
|
|
|
handle: SelectionCorner | SelectionEdge | string,
|
|
|
|
dx: number,
|
|
|
|
dy: number,
|
|
|
|
isAspectRatioLocked = false
|
|
|
|
) {
|
|
|
|
const { minX: a0x, minY: a0y, maxX: a1x, maxY: a1y } = box
|
|
|
|
let { minX: b0x, minY: b0y, maxX: b1x, maxY: b1y } = box
|
|
|
|
|
|
|
|
// Use the delta to adjust the new box by changing its corners.
|
|
|
|
// The dragging handle (corner or edge) will determine which
|
|
|
|
// corners should change.
|
|
|
|
switch (handle) {
|
|
|
|
case 'left':
|
|
|
|
case 'top_left':
|
|
|
|
case 'bottom_left': {
|
|
|
|
b0x += dx
|
|
|
|
break
|
|
|
|
}
|
|
|
|
case 'right':
|
|
|
|
case 'top_right':
|
|
|
|
case 'bottom_right': {
|
|
|
|
b1x += dx
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
switch (handle) {
|
|
|
|
case 'top':
|
|
|
|
case 'top_left':
|
|
|
|
case 'top_right': {
|
|
|
|
b0y += dy
|
|
|
|
break
|
|
|
|
}
|
|
|
|
case 'bottom':
|
|
|
|
case 'bottom_left':
|
|
|
|
case 'bottom_right': {
|
|
|
|
b1y += dy
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const scaleX = (b1x - b0x) / (a1x - a0x)
|
|
|
|
const scaleY = (b1y - b0y) / (a1y - a0y)
|
|
|
|
|
|
|
|
const flipX = scaleX < 0
|
|
|
|
const flipY = scaleY < 0
|
|
|
|
|
|
|
|
/*
|
|
|
|
2. Aspect ratio
|
|
|
|
If the aspect ratio is locked, adjust the corners so that the
|
|
|
|
new box's aspect ratio matches the original aspect ratio.
|
|
|
|
*/
|
|
|
|
if (isAspectRatioLocked) {
|
|
|
|
const aspectRatio = (a1x - a0x) / (a1y - a0y)
|
|
|
|
const bw = Math.abs(b1x - b0x)
|
|
|
|
const bh = Math.abs(b1y - b0y)
|
|
|
|
const tw = bw * (scaleY < 0 ? 1 : -1) * (1 / aspectRatio)
|
|
|
|
const th = bh * (scaleX < 0 ? 1 : -1) * aspectRatio
|
|
|
|
const isTall = aspectRatio < bw / bh
|
|
|
|
|
|
|
|
switch (handle) {
|
|
|
|
case 'top_left': {
|
|
|
|
if (isTall) b0y = b1y + tw
|
|
|
|
else b0x = b1x + th
|
|
|
|
break
|
|
|
|
}
|
|
|
|
case 'top_right': {
|
|
|
|
if (isTall) b0y = b1y + tw
|
|
|
|
else b1x = b0x - th
|
|
|
|
break
|
|
|
|
}
|
|
|
|
case 'bottom_right': {
|
|
|
|
if (isTall) b1y = b0y - tw
|
|
|
|
else b1x = b0x - th
|
|
|
|
break
|
|
|
|
}
|
|
|
|
case 'bottom_left': {
|
|
|
|
if (isTall) b1y = b0y - tw
|
|
|
|
else b0x = b1x + th
|
|
|
|
break
|
|
|
|
}
|
|
|
|
case 'bottom':
|
|
|
|
case 'top': {
|
|
|
|
const m = (b0x + b1x) / 2
|
|
|
|
const w = bh * aspectRatio
|
|
|
|
b0x = m - w / 2
|
|
|
|
b1x = m + w / 2
|
|
|
|
break
|
|
|
|
}
|
|
|
|
case 'left':
|
|
|
|
case 'right': {
|
|
|
|
const m = (b0y + b1y) / 2
|
|
|
|
const h = bw / aspectRatio
|
|
|
|
b0y = m - h / 2
|
|
|
|
b1y = m + h / 2
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (flipX) {
|
|
|
|
const t = b1x
|
|
|
|
b1x = b0x
|
|
|
|
b0x = t
|
|
|
|
}
|
|
|
|
|
|
|
|
if (flipY) {
|
|
|
|
const t = b1y
|
|
|
|
b1y = b0y
|
|
|
|
b0y = t
|
|
|
|
}
|
|
|
|
|
2024-01-03 12:13:15 +00:00
|
|
|
const final = new Box(b0x, b0y, Math.abs(b1x - b0x), Math.abs(b1y - b0y))
|
2023-04-25 11:01:25 +00:00
|
|
|
|
|
|
|
return {
|
|
|
|
box: final,
|
|
|
|
scaleX: +((final.width / box.width) * (scaleX > 0 ? 1 : -1)).toFixed(5),
|
|
|
|
scaleY: +((final.height / box.height) * (scaleY > 0 ? 1 : -1)).toFixed(5),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-03 12:13:15 +00:00
|
|
|
equals(other: Box | BoxModel) {
|
|
|
|
return Box.Equals(this, other)
|
2023-04-25 11:01:25 +00:00
|
|
|
}
|
|
|
|
|
2024-01-03 12:13:15 +00:00
|
|
|
static Equals(a: Box | BoxModel, b: Box | BoxModel) {
|
2023-04-25 11:01:25 +00:00
|
|
|
return b.x === a.x && b.y === a.y && b.w === a.w && b.h === a.h
|
|
|
|
}
|
2023-09-08 14:45:30 +00:00
|
|
|
|
|
|
|
zeroFix() {
|
|
|
|
this.w = Math.max(1, this.w)
|
|
|
|
this.h = Math.max(1, this.h)
|
|
|
|
return this
|
|
|
|
}
|
|
|
|
|
2024-01-03 12:13:15 +00:00
|
|
|
static ZeroFix(other: Box | BoxModel) {
|
|
|
|
return new Box(other.x, other.y, Math.max(1, other.w), Math.max(1, other.h))
|
2023-09-08 14:45:30 +00:00
|
|
|
}
|
2023-04-25 11:01:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/** @public */
|
|
|
|
export function flipSelectionHandleY(handle: SelectionHandle) {
|
|
|
|
switch (handle) {
|
|
|
|
case 'top':
|
|
|
|
return 'bottom'
|
|
|
|
case 'bottom':
|
|
|
|
return 'top'
|
|
|
|
case 'top_left':
|
|
|
|
return 'bottom_left'
|
|
|
|
case 'top_right':
|
|
|
|
return 'bottom_right'
|
|
|
|
case 'bottom_left':
|
|
|
|
return 'top_left'
|
|
|
|
case 'bottom_right':
|
|
|
|
return 'top_right'
|
|
|
|
default:
|
|
|
|
return handle
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/** @public */
|
|
|
|
export function flipSelectionHandleX(handle: SelectionHandle) {
|
|
|
|
switch (handle) {
|
|
|
|
case 'left':
|
|
|
|
return 'right'
|
|
|
|
case 'right':
|
|
|
|
return 'left'
|
|
|
|
case 'top_left':
|
|
|
|
return 'top_right'
|
|
|
|
case 'top_right':
|
|
|
|
return 'top_left'
|
|
|
|
case 'bottom_left':
|
|
|
|
return 'bottom_right'
|
|
|
|
case 'bottom_right':
|
|
|
|
return 'bottom_left'
|
|
|
|
default:
|
|
|
|
return handle
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const ORDERED_SELECTION_HANDLES = [
|
|
|
|
'top',
|
|
|
|
'top_right',
|
|
|
|
'right',
|
|
|
|
'bottom_right',
|
|
|
|
'bottom',
|
|
|
|
'bottom_left',
|
|
|
|
'left',
|
|
|
|
'top_left',
|
|
|
|
] as const
|
|
|
|
|
|
|
|
/** @public */
|
|
|
|
export function rotateSelectionHandle(handle: SelectionHandle, rotation: number): SelectionHandle {
|
|
|
|
// first find out how many tau we need to rotate by
|
|
|
|
rotation = rotation % PI2
|
|
|
|
const numSteps = Math.round(rotation / (PI / 4))
|
|
|
|
|
|
|
|
const currentIndex = ORDERED_SELECTION_HANDLES.indexOf(handle)
|
|
|
|
return ORDERED_SELECTION_HANDLES[(currentIndex + numSteps) % ORDERED_SELECTION_HANDLES.length]
|
|
|
|
}
|
|
|
|
|
|
|
|
/** @public */
|
|
|
|
export function isSelectionCorner(selection: string): selection is SelectionCorner {
|
|
|
|
return (
|
|
|
|
selection === 'top_left' ||
|
|
|
|
selection === 'top_right' ||
|
|
|
|
selection === 'bottom_right' ||
|
|
|
|
selection === 'bottom_left'
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
/** @public */
|
|
|
|
export const ROTATE_CORNER_TO_SELECTION_CORNER = {
|
|
|
|
top_left_rotate: 'top_left',
|
|
|
|
top_right_rotate: 'top_right',
|
|
|
|
bottom_right_rotate: 'bottom_right',
|
|
|
|
bottom_left_rotate: 'bottom_left',
|
|
|
|
mobile_rotate: 'top_left',
|
|
|
|
} as const
|