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`
This commit is contained in:
Steve Ruiz 2023-07-25 17:10:15 +01:00 committed by GitHub
parent 4bd8a257ac
commit d750da8f40
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
421 changed files with 11059 additions and 6689 deletions

View file

@ -10,11 +10,11 @@ export function sleep(ms: number) {
// } // }
// export async function expectToHaveNShapes(page: Page, numberOfShapes: number) { // export async function expectToHaveNShapes(page: Page, numberOfShapes: number) {
// expect(await page.evaluate(() => editor.shapesArray.length)).toBe(numberOfShapes) // expect(await page.evaluate(() => editor.shapesOnCurrentPage.length)).toBe(numberOfShapes)
// } // }
// export async function expectToHaveNSelectedShapes(page: Page, numberOfSelectedShapes: number) { // export async function expectToHaveNSelectedShapes(page: Page, numberOfSelectedShapes: number) {
// expect(await page.evaluate(() => editor.selectedIds.length)).toBe(numberOfSelectedShapes) // expect(await page.evaluate(() => editor.selectedShapeIds.length)).toBe(numberOfSelectedShapes)
// } // }
declare const editor: Editor declare const editor: Editor

View file

@ -1,227 +1,229 @@
import test, { Page, expect } from '@playwright/test' export {}
import { Editor, TLShapeId, TLShapePartial } from '@tldraw/tldraw'
import { rename, writeFile } from 'fs/promises'
import { setupPage } from '../shared-e2e'
declare const editor: Editor // import test, { Page, expect } from '@playwright/test'
// import { Editor, TLShapeId, TLShapePartial } from '@tldraw/tldraw'
// import { rename, writeFile } from 'fs/promises'
// import { setupPage } from '../shared-e2e'
test.describe('Export snapshots', () => { // declare const editor: Editor
const snapshots = {} as Record<string, TLShapePartial[]>
for (const fill of ['none', 'semi', 'solid', 'pattern']) { // test.describe('Export snapshots', () => {
snapshots[`geo fill=${fill}`] = [ // const snapshots = {} as Record<string, TLShapePartial[]>
{
id: 'shape:testShape' as TLShapeId,
type: 'geo',
props: {
fill,
color: 'green',
w: 100,
h: 100,
},
},
]
snapshots[`arrow fill=${fill}`] = [ // for (const fill of ['none', 'semi', 'solid', 'pattern']) {
{ // snapshots[`geo fill=${fill}`] = [
id: 'shape:testShape' as TLShapeId, // {
type: 'arrow', // id: 'shape:testShape' as TLShapeId,
props: { // type: 'geo',
color: 'light-green', // props: {
fill: fill, // fill,
arrowheadStart: 'square', // color: 'green',
arrowheadEnd: 'dot', // w: 100,
start: { type: 'point', x: 0, y: 0 }, // h: 100,
end: { type: 'point', x: 100, y: 100 }, // },
bend: 20, // },
}, // ]
},
]
snapshots[`draw fill=${fill}`] = [ // snapshots[`arrow fill=${fill}`] = [
{ // {
id: 'shape:testShape' as TLShapeId, // id: 'shape:testShape' as TLShapeId,
type: 'draw', // type: 'arrow',
props: { // props: {
color: 'light-violet', // color: 'light-green',
fill: fill, // fill: fill,
segments: [ // arrowheadStart: 'square',
{ // arrowheadEnd: 'dot',
type: 'straight', // start: { type: 'point', x: 0, y: 0 },
points: [{ x: 0, y: 0 }], // end: { type: 'point', x: 100, y: 100 },
}, // bend: 20,
{ // },
type: 'straight', // },
points: [ // ]
{ x: 0, y: 0 },
{ x: 100, y: 0 },
],
},
{
type: 'straight',
points: [
{ x: 100, y: 0 },
{ x: 0, y: 100 },
],
},
{
type: 'straight',
points: [
{ x: 0, y: 100 },
{ x: 100, y: 100 },
],
},
{
type: 'straight',
points: [
{ x: 100, y: 100 },
{ x: 0, y: 0 },
],
},
],
isClosed: true,
isComplete: true,
},
},
]
}
for (const font of ['draw', 'sans', 'serif', 'mono']) { // snapshots[`draw fill=${fill}`] = [
snapshots[`geo font=${font}`] = [ // {
{ // id: 'shape:testShape' as TLShapeId,
id: 'shape:testShape' as TLShapeId, // type: 'draw',
type: 'geo', // props: {
props: { // color: 'light-violet',
text: 'test', // fill: fill,
color: 'blue', // segments: [
font, // {
w: 100, // type: 'straight',
h: 100, // points: [{ x: 0, y: 0 }],
}, // },
}, // {
] // type: 'straight',
// points: [
// { x: 0, y: 0 },
// { x: 100, y: 0 },
// ],
// },
// {
// type: 'straight',
// points: [
// { x: 100, y: 0 },
// { x: 0, y: 100 },
// ],
// },
// {
// type: 'straight',
// points: [
// { x: 0, y: 100 },
// { x: 100, y: 100 },
// ],
// },
// {
// type: 'straight',
// points: [
// { x: 100, y: 100 },
// { x: 0, y: 0 },
// ],
// },
// ],
// isClosed: true,
// isComplete: true,
// },
// },
// ]
// }
snapshots[`arrow font=${font}`] = [ // for (const font of ['draw', 'sans', 'serif', 'mono']) {
{ // snapshots[`geo font=${font}`] = [
id: 'shape:testShape' as TLShapeId, // {
type: 'arrow', // id: 'shape:testShape' as TLShapeId,
props: { // type: 'geo',
color: 'blue', // props: {
fill: 'solid', // text: 'test',
arrowheadStart: 'square', // color: 'blue',
arrowheadEnd: 'arrow', // font,
font, // w: 100,
start: { type: 'point', x: 0, y: 0 }, // h: 100,
end: { type: 'point', x: 100, y: 100 }, // },
bend: 20, // },
text: 'test', // ]
},
},
]
snapshots[`arrow font=${font}`] = [ // snapshots[`arrow font=${font}`] = [
{ // {
id: 'shape:testShape' as TLShapeId, // id: 'shape:testShape' as TLShapeId,
type: 'arrow', // type: 'arrow',
props: { // props: {
color: 'blue', // color: 'blue',
fill: 'solid', // fill: 'solid',
arrowheadStart: 'square', // arrowheadStart: 'square',
arrowheadEnd: 'arrow', // arrowheadEnd: 'arrow',
font, // font,
start: { type: 'point', x: 0, y: 0 }, // start: { type: 'point', x: 0, y: 0 },
end: { type: 'point', x: 100, y: 100 }, // end: { type: 'point', x: 100, y: 100 },
bend: 20, // bend: 20,
text: 'test', // text: 'test',
}, // },
}, // },
] // ]
snapshots[`note font=${font}`] = [ // snapshots[`arrow font=${font}`] = [
{ // {
id: 'shape:testShape' as TLShapeId, // id: 'shape:testShape' as TLShapeId,
type: 'note', // type: 'arrow',
props: { // props: {
color: 'violet', // color: 'blue',
font, // fill: 'solid',
text: 'test', // arrowheadStart: 'square',
}, // arrowheadEnd: 'arrow',
}, // font,
] // start: { type: 'point', x: 0, y: 0 },
// end: { type: 'point', x: 100, y: 100 },
// bend: 20,
// text: 'test',
// },
// },
// ]
snapshots[`text font=${font}`] = [ // snapshots[`note font=${font}`] = [
{ // {
id: 'shape:testShape' as TLShapeId, // id: 'shape:testShape' as TLShapeId,
type: 'text', // type: 'note',
props: { // props: {
color: 'red', // color: 'violet',
font, // font,
text: 'test', // text: 'test',
}, // },
}, // },
] // ]
}
for (const [name, shapes] of Object.entries(snapshots)) { // snapshots[`text font=${font}`] = [
test(`Exports with ${name}`, async ({ browser }) => { // {
const page = await browser.newPage() // id: 'shape:testShape' as TLShapeId,
await setupPage(page) // type: 'text',
await page.evaluate((shapes) => { // props: {
editor // color: 'red',
.updateInstanceState({ exportBackground: false }) // font,
.selectAll() // text: 'test',
.deleteShapes() // },
.createShapes(shapes) // },
}, shapes as any) // ]
// }
snapshotTest(page) // for (const [name, shapes] of Object.entries(snapshots)) {
}) // test(`Exports with ${name}`, async ({ browser }) => {
} // const page = await browser.newPage()
// await setupPage(page)
// await page.evaluate((shapes) => {
// editor
// .updateInstanceState({ exportBackground: false })
// .selectAll()
// .deleteShapes(editor.selectedShapeIds)
// .createShapes(shapes)
// }, shapes as any)
for (const [name, shapes] of Object.entries(snapshots)) { // snapshotTest(page)
test(`Exports with ${name} in dark mode`, async ({ browser }) => { // })
const page = await browser.newPage() // }
await setupPage(page)
await page.evaluate((shapes) => {
editor.user.updateUserPreferences({ isDarkMode: true })
editor
.updateInstanceState({ exportBackground: false })
.selectAll()
.deleteShapes()
.createShapes(shapes)
}, shapes as any)
snapshotTest(page) // for (const [name, shapes] of Object.entries(snapshots)) {
}) // test(`Exports with ${name} in dark mode`, async ({ browser }) => {
} // const page = await browser.newPage()
}) // await setupPage(page)
// await page.evaluate((shapes) => {
// editor.user.updateUserPreferences({ isDarkMode: true })
// editor
// .updateInstanceState({ exportBackground: false })
// .selectAll()
// .deleteShapes(editor.selectedShapeIds)
// .createShapes(shapes)
// }, shapes as any)
async function snapshotTest(page: Page) { // snapshotTest(page)
const downloadEvent = page.waitForEvent('download') // })
await page.click('[data-testid="main.menu"]') // }
await page.click('[data-testid="menu-item.edit"]') // })
await page.click('[data-testid="menu-item.export-as"]')
await page.click('[data-testid="menu-item.export-as-svg"]')
const download = await downloadEvent // async function snapshotTest(page: Page) {
const path = (await download.path()) as string // const downloadEvent = page.waitForEvent('download')
// assert(path) // await page.click('[data-testid="main.menu"]')
await rename(path, path + '.svg') // await page.click('[data-testid="menu-item.edit"]')
await writeFile( // await page.click('[data-testid="menu-item.export-as"]')
path + '.html', // await page.click('[data-testid="menu-item.export-as-svg"]')
`
<!DOCTYPE html>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<img src="${path}.svg" />
`,
'utf-8'
)
await page.goto(`file://${path}.html`) // const download = await downloadEvent
const clip = await page.$eval('img', (img) => img.getBoundingClientRect()) // const path = (await download.path()) as string
await expect(page).toHaveScreenshot({ // // assert(path)
omitBackground: true, // await rename(path, path + '.svg')
clip, // await writeFile(
}) // path + '.html',
} // `
// <!DOCTYPE html>
// <meta charset="utf-8" />
// <meta name="viewport" content="width=device-width, initial-scale=1" />
// <img src="${path}.svg" />
// `,
// 'utf-8'
// )
// await page.goto(`file://${path}.html`)
// const clip = await page.$eval('img', (img) => img.getBoundingClientRect())
// await expect(page).toHaveScreenshot({
// omitBackground: true,
// clip,
// })
// }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 930 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 933 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 922 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 923 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 933 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 874 B

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 874 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 923 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 930 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 922 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 923 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 923 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 923 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Some files were not shown because too many files have changed in this diff Show more